Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/codex-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
- 'src/server/**'
- 'src/shared/**'
- 'tests/codex*.test.ts'
- 'tests/worker-input.test.ts'
- 'package*.json'
- '.github/workflows/codex-windows.yml'
workflow_dispatch:
Expand All @@ -26,4 +27,4 @@ jobs:
- run: npm run typecheck
# Runs the generated commands through Windows PowerShell and a local authenticated bridge.
# The Codex CLI and API credentials are not needed.
- run: node --import tsx --test tests/codex.test.ts tests/codex-usage.test.ts tests/codex-hook-shell.test.ts tests/codex-hook-transport.test.ts
- run: node --import tsx --test tests/codex.test.ts tests/codex-usage.test.ts tests/codex-hook-shell.test.ts tests/codex-hook-transport.test.ts tests/worker-input.test.ts
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@ deploy/coolify.sh ssh 'node /opt/agent-office/bin/agent-office.js accounts invit

The full list is in [docs/controls.md](docs/controls.md).

In Claude and Codex terminals, submitting a reply with **Enter** (or interrupting with **Ctrl+C**) clears the current input alert; a new question raises it again. On Windows, Enter waits briefly after a paste so it submits the prompt instead of joining the pasted text. This also applies to the PR and queue agents.

## Development

```bash
Expand Down
4 changes: 4 additions & 0 deletions docs/controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ On a phone, use the 2D view at `/lite` instead: a terminal there has a row of ke

## In a terminal

For Claude and Codex, **Enter** submits a reply and clears the current “needs input” alert without waiting for the approved tool to finish. **Ctrl+C** also clears that wait while the interrupt is delivered. Typing, moving through choices, and pasting alone keep the wait active. A new question or permission hook raises a fresh alert; trust and login screens stay flagged until the provider reports that they are ready.

On a Windows office server, Enter after a paste waits for a short pause (up to half a second) before reaching the worker. This keeps it outside the CLI’s paste burst, where it could otherwise become another newline. PR and queue agent terminals, and prompts sent through **Ask agent**, use the same ordered input path.

The prompt edits the way it does in your own terminal (iTerm2's *Natural Text Editing*, or VS Code's), in Claude Code, Codex, OpenCode and a shell alike:

| Key | Action |
Expand Down
1 change: 1 addition & 0 deletions src/server/providers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export const claude: ProviderAdapter<undefined, ClaudeSetup> = {
// Resuming a conversation Claude no longer has ("No conversation found") exits before Claude ever starts.
freshIfResumeFails: true,
hook: { strictJson: false, handle: claudeHook },
inputSubmitted: (h) => answered(h),
// OSC 9;4 progress (Claude Code emits it): 0 = idle, anything else = busy. Catches Esc-cancel,
// which fires no Stop hook.
screen: { progress: true, blocked: claudeBlocked },
Expand Down
8 changes: 7 additions & 1 deletion src/server/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { codexHookArgs, codexModelArgs, normalizeCodexHook, writeCodexHook } fro
import { hasCodexSwitch, withCodexArgs } from '../codex-args.js';
import { CodexUsageReader } from '../codex-usage.js';
import { codexMcpArgs } from '../office-workers.js';
import { reduceLifecycle, type ToolTracker } from '../workers/lifecycle.js';
import { answered, reduceLifecycle, type ToolTracker } from '../workers/lifecycle.js';
import type { WorkerHandle } from '../workers/types.js';
import { withYolo } from '../yolo.js';
import type { ProviderAdapter } from './types.js';
Expand Down Expand Up @@ -92,6 +92,12 @@ function codexHook(h: WorkerHandle<CodexState>, event: string, payload: unknown)

export const codex: ProviderAdapter<CodexState, CodexSetup> = {
id: 'codex',
inputSubmitted(h) {
// Keep calls in flight, but don't let their later events revive the answered wait.
h.state.pending.clear();
h.state.permissionUnknown = false;
answered(h);
},
scrubEnv: ['CODEX_THREAD_ID', 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE'],
createState: () => ({ usage: new CodexUsageReader(), tools: new Map(), pending: new Set() }),
prepare: ({ dataDir, mcpScript, codexArgs }) => ({ hook: writeCodexHook(dataDir), mcpScript, args: codexArgs }),
Expand Down
2 changes: 2 additions & 0 deletions src/server/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export interface ProviderAdapter<S = undefined, P = undefined> {
freshIfResumeFails?: boolean;
/** Its hook route, /hooks/<id>. */
hook?: ProviderHook<S>;
/** A person submitted Enter/Ctrl+C while waiting in a terminal; hooks may only resume after a long tool finishes. */
inputSubmitted?(h: WorkerHandle<S>): void;
/** The provider whose hook route its workers report on, when not their own (a custom --agent speaks Claude Code's). */
hooksAs?: AgentProvider;
screen?: {
Expand Down
81 changes: 81 additions & 0 deletions src/server/workers/input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Terminal replies can precede PostToolUse by minutes: the approved command may still be running.
import type { SomeAdapter } from '../providers/types.js';
import type { Pty } from '../ptys.js';
import type { Worker, WorkerHandle } from './types.js';
import { TerminalInput } from './terminal-input.js';

/** Distinguishes Enter/Ctrl+C from editing, terminal reports and bracketed pastes, across chunks. */
export class ReplyInput {
private escape = '';
private paste = false;
private tail = '';

submitted(data: string): boolean {
let submitted = false;
// Without bracketed-paste mode xterm sends the clipboard as one plain chunk, including CRs.
const plainPaste = data.length > 1 && !data.includes('\x1b');
for (const ch of data) {
if (this.paste) {
this.tail = (this.tail + ch).slice(-6);
if (this.tail === '\x1b[201~') {
this.paste = false;
this.tail = '';
}
} else if (ch === '\r' || ch === '\x03') {
submitted ||= !plainPaste;
this.escape = '';
} else if (this.escape) {
this.escape += ch;
if (this.escape === '\x1b[' || this.escape === '\x1bO') continue;
if (this.escape === '\x1b[200~') this.paste = true;
if (this.escape.length === 2 || /[@-~]/.test(ch)) this.escape = '';
} else if (ch === '\x1b') this.escape = ch;
}
return submitted;
}
}

// A resumed/restarted process gets a fresh parser; old terminals can be collected.
const replies = new WeakMap<Pty, ReplyInput>();
const terminals = new WeakMap<Pty, TerminalInput>();

function terminalInput(w: Worker): TerminalInput {
const pty = w.pty!;
let input = terminals.get(pty);
if (!input) {
input = new TerminalInput((data) => {
// A delayed Enter belongs only to the process that received the paste.
if (w.pty === pty) pty.write(data);
});
terminals.set(pty, input);
}
return input;
}

export function pastePrompt(w: Worker, text: string) {
terminalInput(w).paste(text);
}

/** Keystrokes from a browser, including presence and a provider's submitted-reply fallback. */
export function writeInput(w: Worker, data: string, by: string, adapter: SomeAdapter | undefined, handle: () => WorkerHandle): boolean {
if (w.dsh) w.dsh.writeInput(data);
else if (w.pty) {
let reply = replies.get(w.pty);
if (!reply) replies.set(w.pty, (reply = new ReplyInput()));
const submitted = reply.submitted(data);
const pty = w.pty;
terminalInput(w).send(data, () => {
if (w.pty === pty && submitted && w.info.status === 'needs_input' && !w.bootBlocked) adapter?.inputSubmitted?.(handle());
});
} else return false;

const now = Date.now();
const last = w.info.lastInput;
let changed = !last || last.by !== by || now - last.at >= 15_000;
if (changed) w.info.lastInput = { by, at: now };
if (w.info.status === 'needs_input' && w.info.acked === false) {
w.info.acked = true;
changed = true;
}
return changed;
}
39 changes: 4 additions & 35 deletions src/server/workers/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { PROVIDERS, providerAdapter, titleNoise, type LaunchPlan, type ProviderF
import { launchAcp } from './acp.js';
import { clockWork } from './clock.js';
import { childEnv } from './env.js';
import { pastePrompt, writeInput } from './input.js';
import { midTurn } from './lifecycle.js';
import { restoreWorkers, saveWorkers } from './persist.js';
import { WorkerPrs } from './pr.js';
Expand All @@ -31,8 +32,6 @@ import { COLORS, NAMES, newWorker } from './worker.js';
import { WorkerTrees, lostMessage } from './worktree.js';

const SCREEN_INTERVAL_MS = 250;
/** How often a steady typist's "last typed" time is refreshed for everyone. */
const TYPED_REFRESH_MS = 15_000;
/** The most other repositories one worker can take on (see WorkerInfo.repos). */
export const MAX_REPOS = 8;
/** How often every worker's transcript is checked for new spend, on top of the hook-driven checks. */
Expand Down Expand Up @@ -458,44 +457,15 @@ export class WorkerManager {
write(id: string, data: string, by: string) {
const w = this.workers.get(id);
if (!w) return;
if (w.dsh) {
// ACP has no terminal: the session buffers these into a line and submits it on Enter.
w.dsh.writeInput(data);
let changed = this.typed(w, by);
if (w.info.status === 'needs_input' && w.info.acked === false) {
w.info.acked = true;
changed = true;
}
if (changed) this.emitUpdate(w);
return;
}
if (!w.pty) return;
w.pty.write(data);
let changed = this.typed(w, by);
if (w.info.status === 'needs_input' && w.info.acked === false) {
w.info.acked = true;
changed = true;
}
if (changed) this.emitUpdate(w);
const adapter = w.info.kind === 'agent' ? providerAdapter(w.info.provider) : undefined;
if (writeInput(w, data, by, adapter, () => this.handleOf(w))) this.emitUpdate(w);
}

/** Keeps a file dropped or pasted into a worker's terminal on this machine; where it is, for the terminal to type. */
drop(id: string, name: string, type: string, body: Buffer): string | undefined {
return this.workers.has(id) ? this.drops.save(id, name, type, body) : undefined;
}

/**
* Remembers who typed into the terminal last. Says whether that's news: another person, or the
* same one after a pause (not every keystroke, or a typist would flood everyone with updates).
*/
private typed(w: Worker, by: string): boolean {
const now = Date.now();
const last = w.info.lastInput;
if (last?.by === by && now - last.at < TYPED_REFRESH_MS) return false;
w.info.lastInput = { by, at: now };
return true;
}

/** Types a prompt into the agent's input box and submits it; `by` is the person who sent it, if any. */
prompt(id: string, text: string, by?: string): string | undefined {
const w = this.workers.get(id);
Expand All @@ -514,8 +484,7 @@ export class WorkerManager {
const clean = text.replace(/\r\n?/g, '\n').trim();
if (!clean) return 'Empty prompt';
// Bracketed paste keeps multi-line prompts in one message, then Enter submits.
w.pty.write(`\x1b[200~${clean}\x1b[201~`);
setTimeout(() => w.pty?.write('\r'), 120);
pastePrompt(w, clean);
w.info.activity = truncate(clean, 80);
this.tasks.notePrompt(w, clean);
if (by) w.info.lastInput = { by, at: Date.now() };
Expand Down
43 changes: 43 additions & 0 deletions src/server/workers/terminal-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/** Keep an explicit Enter out of a Windows CLI's paste burst, without reordering later keys. */
export class TerminalInput {
private readyAt = 0;
private pending: { data: string; written?: () => void; settle?: number }[] = [];
private timer?: ReturnType<typeof setTimeout>;

constructor(private write: (data: string) => void, private windows = process.platform === 'win32') {}

send(data: string, written?: () => void) {
this.pending.push({ data, written });
this.flush();
}

paste(text: string) {
// Even outside Windows, the program needs a moment to consume the paste before Enter.
this.pending.push({ data: `\x1b[200~${text}\x1b[201~`, settle: this.windows ? 500 : 120 });
this.send('\r');
}

private flush() {
if (this.timer) return;
while (this.pending.length) {
const next = this.pending[0];
const wait = next.data === '\r' ? this.readyAt - Date.now() : 0;
if (wait > 0) {
this.timer = setTimeout(() => {
this.timer = undefined;
this.flush();
}, wait);
return;
}
this.pending.shift();
this.write(next.data);
if (next.settle) this.readyAt = Date.now() + next.settle;
// xterm sends a paste as one chunk. ConPTY can deliver it to the CLI as individual keys;
// Codex then treats a following Enter as another pasted newline for 120ms after the burst.
if (this.windows && next.data.length > 1 && (!next.data.includes('\x1b') || next.data.includes('\x1b[201~'))) {
this.readyAt = Date.now() + 500;
}
next.written?.();
}
}
}
2 changes: 1 addition & 1 deletion tests/size.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const BUDGET = 600;
*/
const CEILINGS: Readonly<Record<string, number>> = {
'src/server/dsh.ts': 1148,
'src/server/workers/manager.ts': 1029,
'src/server/workers/manager.ts': 998,
'src/client/features/rooftop/world.ts': 989,
'src/client/world/sky.ts': 966,
'src/server/meetings.ts': 764,
Expand Down
Loading
Loading