From 98b38e15a301fa84f1edc072f2eb98f4ea7a6a32 Mon Sep 17 00:00:00 2001 From: dex Date: Fri, 23 Oct 2026 02:32:04 +0200 Subject: [PATCH] Fix stale worker alerts and Windows paste submission --- .github/workflows/codex-windows.yml | 3 +- README.md | 2 + docs/controls.md | 4 + src/server/providers/claude.ts | 1 + src/server/providers/codex.ts | 8 +- src/server/providers/types.ts | 2 + src/server/workers/input.ts | 81 +++++++++++++++ src/server/workers/manager.ts | 39 +------- src/server/workers/terminal-input.ts | 43 ++++++++ tests/size.test.ts | 2 +- tests/worker-input.test.ts | 142 +++++++++++++++++++++++++++ 11 files changed, 289 insertions(+), 38 deletions(-) create mode 100644 src/server/workers/input.ts create mode 100644 src/server/workers/terminal-input.ts create mode 100644 tests/worker-input.test.ts diff --git a/.github/workflows/codex-windows.yml b/.github/workflows/codex-windows.yml index be41b4dc3..b273e7cc8 100644 --- a/.github/workflows/codex-windows.yml +++ b/.github/workflows/codex-windows.yml @@ -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: @@ -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 diff --git a/README.md b/README.md index 73680a569..a0cfcec62 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/controls.md b/docs/controls.md index daa05f8a3..1dce284cf 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -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 | diff --git a/src/server/providers/claude.ts b/src/server/providers/claude.ts index fa507e0d7..0e6bcf3a2 100644 --- a/src/server/providers/claude.ts +++ b/src/server/providers/claude.ts @@ -236,6 +236,7 @@ export const claude: ProviderAdapter = { // 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 }, diff --git a/src/server/providers/codex.ts b/src/server/providers/codex.ts index 27a25f3fd..e87ca240c 100644 --- a/src/server/providers/codex.ts +++ b/src/server/providers/codex.ts @@ -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'; @@ -92,6 +92,12 @@ function codexHook(h: WorkerHandle, event: string, payload: unknown) export const codex: ProviderAdapter = { 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 }), diff --git a/src/server/providers/types.ts b/src/server/providers/types.ts index 1dc6d97af..413424e0c 100644 --- a/src/server/providers/types.ts +++ b/src/server/providers/types.ts @@ -99,6 +99,8 @@ export interface ProviderAdapter { freshIfResumeFails?: boolean; /** Its hook route, /hooks/. */ hook?: ProviderHook; + /** A person submitted Enter/Ctrl+C while waiting in a terminal; hooks may only resume after a long tool finishes. */ + inputSubmitted?(h: WorkerHandle): void; /** The provider whose hook route its workers report on, when not their own (a custom --agent speaks Claude Code's). */ hooksAs?: AgentProvider; screen?: { diff --git a/src/server/workers/input.ts b/src/server/workers/input.ts new file mode 100644 index 000000000..e649a64e2 --- /dev/null +++ b/src/server/workers/input.ts @@ -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(); +const terminals = new WeakMap(); + +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; +} diff --git a/src/server/workers/manager.ts b/src/server/workers/manager.ts index 92b300d31..761333e0e 100644 --- a/src/server/workers/manager.ts +++ b/src/server/workers/manager.ts @@ -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'; @@ -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. */ @@ -458,25 +457,8 @@ 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. */ @@ -484,18 +466,6 @@ export class WorkerManager { 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); @@ -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() }; diff --git a/src/server/workers/terminal-input.ts b/src/server/workers/terminal-input.ts new file mode 100644 index 000000000..d827933b9 --- /dev/null +++ b/src/server/workers/terminal-input.ts @@ -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; + + 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?.(); + } + } +} diff --git a/tests/size.test.ts b/tests/size.test.ts index be8ce888f..d03ae1a26 100644 --- a/tests/size.test.ts +++ b/tests/size.test.ts @@ -21,7 +21,7 @@ const BUDGET = 600; */ const CEILINGS: Readonly> = { '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, diff --git a/tests/worker-input.test.ts b/tests/worker-input.test.ts new file mode 100644 index 000000000..357a69ed5 --- /dev/null +++ b/tests/worker-input.test.ts @@ -0,0 +1,142 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ReplyInput, writeInput, pastePrompt } from '../src/server/workers/input.js'; +import { TerminalInput } from '../src/server/workers/terminal-input.js'; +import { claude } from '../src/server/providers/claude.js'; +import { codex } from '../src/server/providers/codex.js'; +import type { SomeAdapter } from '../src/server/providers/types.js'; +import type { Worker, WorkerHandle } from '../src/server/workers/types.js'; +import * as pty from '@lydell/node-pty'; + +function fixture(adapter: SomeAdapter) { + const written: string[] = []; + const w = { info: { kind:'agent', status:'needs_input', acked:false }, + pty: { write: (data: string) => written.push(data) }, state: adapter.createState?.(), tracker: {}, + } as Worker; + let updates = 0; + const h = { info:w.info, state:w.state, tracker:w.tracker, running:true, leftNeedsInputAt:0, + emit() {}, persist() {}, scheduleScan() {}, clearTask() {}, notePrompt() {}, noteTool() {}, notePr() {}, + setStatus(status) { w.info.status = status; w.info.acked = status !== 'needs_input'; updates++; }, + } as WorkerHandle; + const write = (data: string) => writeInput(w, data, 'Ada', adapter, () => h); + return { w, h, written, write, updates: () => updates }; +} + +test('only submitting or interrupting answers a terminal wait; pasted newlines and editing do not', () => { + const input = new ReplyInput(); + for (const data of ['yes', 'first\rsecond\r', '\x1b[A', '\x1b[64;2;3M', '\x1b[1;2R', '\n', '\x7f', '\x1b[20', '0~first\r\nsecond', '\r', '\x1b[20', '1~']) { + assert.equal(input.submitted(data), false, JSON.stringify(data)); + } + assert.equal(input.submitted('\r'), true); + assert.equal(input.submitted('\x03'), true); + assert.equal(input.submitted('\x1b'), false); + assert.equal(input.submitted('\r'), true, 'Enter still works after sending an Esc'); +}); + +for (const adapter of [claude, codex]) { + test(`${adapter.id}: a reply clears the wait before the approved tool finishes`, () => { + const f = fixture(adapter); + const hook = (event: string, extra = {}) => adapter.hook!.handle(f.h, event, {session_id:'root', ...extra}); + hook('PreToolUse', {tool_name:'Bash', tool_use_id:'approved'}); + hook('PermissionRequest', {tool_name:'Bash'}); + f.write('y'); + f.write('\x1b[B'); + assert.equal(f.w.info.status, 'needs_input', 'editing does not dismiss the question'); + f.write('\r'); + assert.equal(f.w.info.status, 'working'); + assert.equal(f.w.info.acked, true); + assert.ok(f.h.leftNeedsInputAt > 0); + assert.ok(f.updates() > 0, 'the reply publishes a status change'); + hook('PreToolUse', {tool_name:'read_file', tool_use_id:'parallel'}); + hook('PostToolUse', {tool_name:'read_file', tool_use_id:'parallel'}); + assert.equal(f.w.info.status, 'working', 'parallel events cannot revive the answered wait'); + hook('PermissionRequest', {tool_name:'Bash'}); + assert.equal(f.w.info.status, 'needs_input', 'a new permission request still raises the alert'); + f.write('\x03'); + assert.equal(f.w.info.status, 'working'); + hook('Stop'); + assert.equal(f.w.info.status, 'done'); + }); +} + +test('trust/login waits and stopped processes are not cleared by pressing Enter', () => { + const f = fixture(claude); + f.w.bootBlocked = true; + f.write('\r'); + assert.equal(f.w.info.status, 'needs_input'); + f.w.bootBlocked = false; + f.w.pty = undefined; + f.write('\r'); + assert.equal(f.w.info.status, 'needs_input'); +}); + +test('Windows paste then Enter waits for the paste burst and preserves subsequent key order', (t) => { + t.mock.timers.enable({apis:['Date', 'setTimeout']}); + const written: string[] = []; + const input = new TerminalInput(data => written.push(data), true); + input.send('Pasted prompt'); + input.send('\r'); + input.send('next'); + assert.deepEqual(written, ['Pasted prompt']); + t.mock.timers.tick(499); + assert.equal(written.length, 1); + t.mock.timers.tick(1); + assert.deepEqual(written, ['Pasted prompt', '\r', 'next']); +}); + +for (const windows of [false, true]) { + test(`board prompts keep multiline paste separate from Enter (${windows ? 'Windows' : 'POSIX'})`, (t) => { + t.mock.timers.enable({apis:['Date', 'setTimeout']}); + const written: string[] = []; + const input = new TerminalInput(data => written.push(data), windows); + input.paste('first\nsecond'); + input.paste('next request'); + assert.deepEqual(written, ['\x1b[200~first\nsecond\x1b[201~']); + t.mock.timers.tick(windows ? 500 : 120); + assert.deepEqual(written, ['\x1b[200~first\nsecond\x1b[201~', '\r', '\x1b[200~next request\x1b[201~']); + t.mock.timers.tick(windows ? 500 : 120); + assert.equal(written.at(-1), '\r'); + }); +} + +test('an Enter scheduled for an old worker process cannot reach its replacement', (t) => { + t.mock.timers.enable({apis:['Date', 'setTimeout']}); + const f = fixture(claude); + pastePrompt(f.w, 'old prompt'); + const replacement: string[] = []; + f.w.pty = {write: data => replacement.push(data)} as Worker['pty']; + t.mock.timers.tick(500); + assert.deepEqual(replacement, []); + assert.deepEqual(f.written, ['\x1b[200~old prompt\x1b[201~']); +}); + +test('Windows ConPTY delivers a paste and its explicit Enter outside the paste burst', {skip:process.platform !== 'win32', timeout:10_000}, async (t) => { + const child = pty.spawn(process.execPath, ['-e', ` + process.stdin.setRawMode(true); + let text = '', last = 0; + process.stdin.on('data', chunk => { + for (const ch of chunk.toString()) { + if (ch === '\\r') { + process.stdout.write('RESULT:' + JSON.stringify({text, quiet:Date.now()-last}) + ':END'); + } else { text += ch; last = Date.now(); } + } + }); + process.stdout.write('INPUT-READY'); + `], {name:'xterm-256color', cols:120, rows:30, cwd:process.cwd(), env:process.env as Record}); + t.after(() => child.kill()); + let output = ''; + child.onData(data => { output += data; }); + const until = async (ready: () => boolean) => { + const deadline = Date.now() + 5000; + while (!ready() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 20)); + assert.ok(ready(), 'ConPTY input fixture did not answer'); + }; + await until(() => output.includes('INPUT-READY')); + const input = new TerminalInput(data => child.write(data), true); + input.send('pasted PR and queue request'); + input.send('\r'); + await until(() => output.includes(':END')); + const result = JSON.parse(/RESULT:(.*?):END/.exec(output)![1]); + assert.equal(result.text, 'pasted PR and queue request'); + assert.ok(result.quiet >= 120, 'the explicit Enter must be outside Codex’s paste suppression window'); +});