From 344edc942bc78288754efc5027656379121bc4e8 Mon Sep 17 00:00:00 2001 From: Randalix Date: Fri, 25 Sep 2026 16:58:00 +0200 Subject: [PATCH 1/2] fix(tmux): submit text as a bracketed paste so Codex does not swallow Enter sendInput() typed the text of a text+Enter pair with `send-keys -l` and sent Enter 50 ms later. Codex detects an unmarked paste from a fast character burst and folds an Enter inside that window into the paste as a newline, so the prompt stays in the composer unsubmitted. Measured with codex-cli 0.162.1 in a throwaway tmux pane: 1505 chars stuck 3/3 as `[Pasted Content 1506 chars]`, a 98-char line stuck 1/4. SubmitVerifier re-presses Enter for the short case, but it cannot match the long one: the composer shows the placeholder instead of the prompt's head. The text now goes in via set-buffer + `paste-buffer -p -d` (markers only when the pane requested bracketed paste), followed by the separate Enter as before. Same pane, same texts: 4/4 and 3/3 submitted. Each send gets its own named buffer; a paste that fails (pane or server gone) deletes it, since tmux never trims named buffers on its own. Also escapes a trailing `;` in the text, which tmux treats as a command separator (`send-keys -l 'x;'` typed `x`, a lone `;` typed nothing). sendInputToPane() has no callers in src/ and is left as it was. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- docs/cron-guide.md | 2 +- src/session-submit-verifier.ts | 2 +- src/session.ts | 2 +- src/tmux-manager.ts | 77 ++++++++++++++++-- src/web/routes/session-routes.ts | 4 +- test/tmux-submit-paste.test.ts | 129 +++++++++++++++++++++++++++++++ 8 files changed, 206 insertions(+), 14 deletions(-) create mode 100644 test/tmux-submit-paste.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index adb38400..e78de085 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,7 +124,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Key Patterns -**Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) +**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) **Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1836d1bf..4bd4864a 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -119,7 +119,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden. ### Input delivery and WS resilience -**Input**: `session.writeViaMux()` for programmatic/curl input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`). +**Input**: `session.writeViaMux()` for programmatic/curl input — the text as one tmux bracketed paste (`paste-buffer -p`, so Codex cannot fold the Enter into a paste burst) + `send-keys Enter`. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`). ### Per-session env overrides: exact-key allowlist and CLAUDE_CONFIG_DIR diff --git a/docs/cron-guide.md b/docs/cron-guide.md index 92ba3320..4e93a58b 100644 --- a/docs/cron-guide.md +++ b/docs/cron-guide.md @@ -204,7 +204,7 @@ trailing carriage return: | Mode | Mechanism | Use when | | ------- | --------------------------------------------------------------- | ------------------------------------------------ | -| `typed` | `session.writeViaMux()` — tmux `send-keys -l` (literal) + Enter | Default; behaves like a human typing the prompt. | +| `typed` | `session.writeViaMux()` — the text as a tmux bracketed paste + Enter | Default; like a human pasting the prompt and pressing Enter. | | `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. | > ⚠️ **Single-line only — enforced.** Like all programmatic input in Codeman, diff --git a/src/session-submit-verifier.ts b/src/session-submit-verifier.ts index eb39edc8..063ad6ce 100644 --- a/src/session-submit-verifier.ts +++ b/src/session-submit-verifier.ts @@ -4,7 +4,7 @@ * * Claude Code 2.1.277 (auto-installed 2026-09-18) takes typed text the moment its * composer paints but ignores Enter for the first 30 to 50 seconds after it, so the - * `send-keys -l ` + `send-keys Enter` pair `TmuxManager.sendInput()` sends 50 ms + * text + `send-keys Enter` pair `TmuxManager.sendInput()` sends 50 ms * apart leaves the prompt sitting on the composer with `0 tokens`, and every caller * that then waits for the turn (send-and-wait, the agent skill, the maintainer bot, * cron, Ralph) burns its whole timeout on a turn that never started. Measured through diff --git a/src/session.ts b/src/session.ts index 5a2bb677..6e461267 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4527,7 +4527,7 @@ export class Session extends EventEmitter { * * More reliable than direct PTY write for programmatic input, especially * with Claude CLI which uses Ink (React for terminals). - * Uses tmux `send-keys -l` to inject text + Enter. + * Uses tmux to inject the text (one bracketed paste) + Enter. * * @param data - Input data with optional `\r` for Enter * @returns true if input was sent, false if no mux session or PTY diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index cd0d07c1..9931f487 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -436,6 +436,65 @@ export function resolveTmuxPaneTarget(muxName: string, paneTarget?: string): str return `${muxName}.${paneTarget}`; } +let submitBufferSeq = 0; + +/** The tmux commands of one bracketed paste, see buildPasteTextCommands(). */ +export interface PasteTextCommands { + /** Fills a buffer of its own with the text. */ + set: string; + /** Pastes that buffer into the target, bracketed, and deletes it (`-d`). */ + paste: string; + /** Deletes the buffer when `paste` failed; `-d` only runs on a successful paste. */ + discard: string; +} + +/** + * tmux commands that type `text` into `target` as ONE bracketed paste, ahead of a + * separately sent `Enter` that submits it. Exported for unit testing. + * + * Why a paste and not `send-keys -l`: Codex detects a paste that arrives WITHOUT + * bracketed-paste markers heuristically, from characters arriving in a fast burst, + * and folds an Enter that lands inside that burst window into the paste as a + * newline. `send-keys -l` types exactly such an unmarked burst, so a text+Enter + * pair sat in Codex's composer unsubmitted: always for long text (Codex shows it as + * `[Pasted Content N chars]`, which SubmitVerifier cannot match against the prompt, + * so nothing re-presses Enter), intermittently for short text at the 50 ms gap. + * `paste-buffer -p` wraps the text in the markers (only when the pane asked for + * them, so an app without bracketed paste sees the same bytes as before) and the + * Enter after it is unambiguous. + * + * Each call gets its own named buffer so concurrent sends cannot paste each other's + * text. tmux keeps a named buffer until something deletes it (`buffer-limit` trims + * only automatic ones), hence `discard` for a paste that fails. + * + * tmux splits commands on an argument that ENDS in `;` (`set-buffer -- 'a;'` stores + * `a`, a lone `;` fails with "no data specified"); a final `\;` is its escape for a + * literal `;`. + */ +export function buildPasteTextCommands(tmux: string, target: string, text: string): PasteTextCommands { + const buffer = `codeman-submit-${process.pid}-${++submitBufferSeq}`; + const data = text.endsWith(';') ? `${text.slice(0, -1)}\\;` : text; + return { + set: `${tmux} set-buffer -b ${buffer} -- ${shellescape(data)}`, + paste: `${tmux} paste-buffer -p -d -b ${buffer} -t ${shellescape(target)}`, + discard: `${tmux} delete-buffer -b ${buffer}`, + }; +} + +/** + * Run a bracketed paste. A failed paste (the pane or the server is gone) deletes + * the buffer before the paste's own error propagates; a failing delete is ignored. + */ +export async function pasteText(run: (cmd: string) => Promise, cmds: PasteTextCommands): Promise { + await run(cmds.set); + try { + await run(cmds.paste); + } catch (err) { + await run(cmds.discard).catch(() => undefined); + throw err; + } +} + /** * Pick the active pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'` * output (lines like `%0:1`). Returns the pane id whose active flag is 1. @@ -3738,10 +3797,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } /** - * Send input directly to a tmux session using `send-keys`. + * Send input directly to a tmux session. * - * Uses tmux send-keys for reliable input delivery: - * - `-l` flag sends literal text (no key interpretation) + * - Text followed by `\r` goes in as ONE bracketed paste (buildPasteTextCommands), + * so Codex cannot fold the Enter into a paste burst + * - Text without `\r` is typed literally with `send-keys -l` (no key interpretation) * - `Enter` key is sent as a SEPARATE tmux invocation after a small delay * - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline */ @@ -3776,10 +3836,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Send text first, then Enter as a SEPARATE tmux command after a short delay. // Ink (Claude CLI's terminal framework) needs them split — sending both in a // single tmux invocation (via \;) causes Ink to interpret Enter as a newline - // character in the input buffer rather than as form submission. - await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, { - timeout: EXEC_TIMEOUT_MS, - }); + // character in the input buffer rather than as form submission. The text goes + // in as a bracketed paste so Codex cannot fold the Enter into it either + // (see buildPasteTextCommands). + await pasteText( + (cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }), + buildPasteTextCommands(this.tmux(), session.muxName, textPart) + ); await new Promise((resolve) => setTimeout(resolve, 50)); await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, { timeout: EXEC_TIMEOUT_MS, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 953cc553..a325bc30 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2014,7 +2014,7 @@ export function registerSessionRoutes( const stopDeathWatch = wantsWait ? watchForDeadWorker(ctx.mux, session, id) : () => {}; // Write input to PTY. Direct write is synchronous; writeViaMux - // (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response. + // (tmux paste + Enter) is fire-and-forget to avoid blocking the HTTP response. // Every write here is `fromUser`: this route carries a person's prompt, or an // agent's on their behalf, so it may name the tab (Ralph, respawn, cron and // approvals write through the session directly and never say so). @@ -2037,7 +2037,7 @@ export function registerSessionRoutes( // A plain prompt (`\r`) goes through the mux even when the caller did not // ask for it: written straight into the pane it arrives as one burst, and Claude // Code takes a long burst as a paste whose `\r` becomes a newline, so the prompt - // sat unsent (see isPlainPromptInput). The mux path types the text, presses Enter + // sat unsent (see isPlainPromptInput). The mux path pastes the text, presses Enter // separately and arms the SubmitVerifier. An explicit `useMux: false` keeps the // raw write for a caller that really wants it. const autoMux = useMux === undefined && isPlainPromptInput(inputStr); diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts new file mode 100644 index 00000000..7832f79e --- /dev/null +++ b/test/tmux-submit-paste.test.ts @@ -0,0 +1,129 @@ +/** + * Covers `buildPasteTextCommands()` and `pasteText()`: the text half of a text+Enter + * submit (`sendInput`) goes in as ONE bracketed paste. + * + * Why: `send-keys -l` typed the text as an unmarked burst, and Codex's paste-burst + * heuristic folded the Enter that followed 50 ms later into the paste as a newline, + * so the prompt sat in the composer unsubmitted (always at 1500 chars, sometimes for + * a one-line prompt). With `paste-buffer -p` the paste is marked and the separate + * Enter submits. + * + * Strategy: pure command shape, plus a round trip through a real shell with a fake + * `tmux` that prints its argv, so quoting and tmux's trailing-`;` rule are checked + * on the bytes a shell actually hands over. `pasteText` runs against a recording + * runner. No real tmux. + * + * Port: N/A (no server / no real tmux) + */ +import { execSync } from 'node:child_process'; +import { describe, expect, it } from 'vitest'; +import { buildPasteTextCommands, pasteText } from '../src/tmux-manager.js'; + +const FAKE_TMUX = `${JSON.stringify(process.execPath)} -e 'console.log(JSON.stringify(process.argv.slice(1)))' --`; + +function argvOf(cmd: string): string[] { + return JSON.parse(execSync(cmd, { encoding: 'utf-8' })) as string[]; +} + +describe('buildPasteTextCommands', () => { + it('fills a buffer, pastes it bracketed into the target and deletes it', () => { + const { set, paste, discard } = buildPasteTextCommands('tmux -L codeman', 'codeman-abc12345', 'hello'); + const buffer = /set-buffer -b (\S+) -- /.exec(set)?.[1]; + expect(buffer).toMatch(/^codeman-submit-\d+-\d+$/); + expect(set).toBe(`tmux -L codeman set-buffer -b ${buffer} -- 'hello'`); + expect(paste).toBe(`tmux -L codeman paste-buffer -p -d -b ${buffer} -t 'codeman-abc12345'`); + expect(discard).toBe(`tmux -L codeman delete-buffer -b ${buffer}`); + }); + + it('never types the text with send-keys (the unmarked burst Codex misreads)', () => { + for (const cmd of Object.values(buildPasteTextCommands('tmux', 'codeman-abc12345', 'read your inbox'))) { + expect(cmd).not.toContain('send-keys'); + } + }); + + it('uses a fresh buffer per call, so concurrent sends cannot paste each other', () => { + const a = buildPasteTextCommands('tmux', 't', 'a').set; + const b = buildPasteTextCommands('tmux', 't', 'b').set; + expect(/-b (\S+)/.exec(a)?.[1]).not.toBe(/-b (\S+)/.exec(b)?.[1]); + }); + + it.each([ + ['quotes and shell syntax', `it's "quoted" $HOME \`id\` $(id) & | > <`], + ['tmux format syntax', '#{session_name} #S'], + ['leading dash', '-t other --help'], + ['non-ASCII', 'Mailbox lesen — äöü “x”'], + ['a long prompt', 'x'.repeat(1500)], + ])('hands the text to tmux unchanged through the shell: %s', (_label, text) => { + const { set } = buildPasteTextCommands(FAKE_TMUX, 't', text); + expect(argvOf(set).at(-1)).toBe(text); + }); + + it.each([ + ['x;', 'x\\;'], + [';', '\\;'], + ['x;;', 'x;\\;'], + ['x\\;', 'x\\\\;'], + ])('escapes a trailing ";" (tmux command separator): %s', (text, expected) => { + const { set } = buildPasteTextCommands(FAKE_TMUX, 't', text); + expect(argvOf(set).at(-1)).toBe(expected); + }); + + it('leaves a ";" inside the text alone', () => { + const { set } = buildPasteTextCommands(FAKE_TMUX, 't', 'a; b'); + expect(argvOf(set).at(-1)).toBe('a; b'); + }); +}); + +describe('pasteText', () => { + const cmds = { set: 'SET', paste: 'PASTE', discard: 'DISCARD' }; + + function recorder(failOn: string[] = []) { + const ran: string[] = []; + const run = async (cmd: string) => { + ran.push(cmd); + if (failOn.includes(cmd)) throw new Error(`${cmd} failed`); + }; + return { ran, run }; + } + + it('fills the buffer, then pastes it; the paste deletes it, so nothing else runs', async () => { + const { ran, run } = recorder(); + await pasteText(run, cmds); + expect(ran).toEqual(['SET', 'PASTE']); + }); + + it('deletes the buffer when the paste fails, and reports the paste error', async () => { + const { ran, run } = recorder(['PASTE']); + await expect(pasteText(run, cmds)).rejects.toThrow('PASTE failed'); + expect(ran).toEqual(['SET', 'PASTE', 'DISCARD']); + }); + + it('still reports the paste error when the delete fails too', async () => { + const { ran, run } = recorder(['PASTE', 'DISCARD']); + await expect(pasteText(run, cmds)).rejects.toThrow('PASTE failed'); + expect(ran).toEqual(['SET', 'PASTE', 'DISCARD']); + }); + + it('does not paste when filling the buffer failed', async () => { + const { ran, run } = recorder(['SET']); + await expect(pasteText(run, cmds)).rejects.toThrow('SET failed'); + expect(ran).toEqual(['SET']); + }); +}); + +describe('sendInput wiring', () => { + // sendInput() returns before touching tmux under vitest (IS_TEST_MODE), so the + // branch that submits text is pinned on the source instead. + it('submits text through pasteText, not send-keys -l', async () => { + const { readFileSync } = await import('node:fs'); + const src = readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf-8'); + const body = src.slice(src.indexOf('async sendInput('), src.indexOf('// ========== Pane Methods')); + const submitBranch = body.slice( + body.indexOf('if (textPart && hasCarriageReturn)'), + body.indexOf('} else if (textPart)') + ); + expect(submitBranch).toContain('pasteText('); + expect(submitBranch).toContain('buildPasteTextCommands('); + expect(submitBranch).not.toMatch(/send-keys[^`]*-l/); + }); +}); From 7fd76f06c3711d7f04fdf8384dd5e904624484d1 Mon Sep 17 00:00:00 2001 From: Randalix Date: Wed, 14 Oct 2026 21:23:58 +0200 Subject: [PATCH 2/2] fix(tmux): type a single-character submit so a dialog answer selects Claude Code's and Codex's selection dialogs ignore a bracketed paste, so after the previous commit a pasted `3` + Enter confirmed the highlighted option instead of option 3. sendInput() now types a single character with `send-keys -l` as master did (submitsAsKeys()). One character cannot form the burst Codex reads as a paste, so the Codex fix is unaffected. The trailing-`;` escape moves into escapeTmuxTrailingSemicolon() and now also covers every `send-keys -l` call in sendInput() and sendInputToPane(); tmux 3.5a types `x;` for `send-keys -l 'x\;'`. Docs: the @fileoverview lines and orchestrator-loop-architecture.md still described `send-keys -l`; CLAUDE.md, architecture-invariants.md and cron-guide.md name the single-character exception. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- docs/cron-guide.md | 2 +- docs/orchestrator-loop-architecture.md | 2 +- src/tmux-manager.ts | 84 ++++++++++++++++++-------- test/tmux-submit-paste.test.ts | 67 +++++++++++++++++--- 6 files changed, 123 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e78de085..4784e2df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,7 +124,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Key Patterns -**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) +**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`, single-line only. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) **Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 4bd4864a..729f01be 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -119,7 +119,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden. ### Input delivery and WS resilience -**Input**: `session.writeViaMux()` for programmatic/curl input — the text as one tmux bracketed paste (`paste-buffer -p`, so Codex cannot fold the Enter into a paste burst) + `send-keys Enter`. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`). +**Input**: `session.writeViaMux()` for programmatic/curl input — the text as one tmux bracketed paste (`paste-buffer -p`, so Codex cannot fold the Enter into a paste burst) + `send-keys Enter`. A single character is typed with `send-keys -l` instead (`submitsAsKeys()`): Claude Code's and Codex's selection dialogs ignore a pasted `3`, and the Enter then confirms the highlighted option. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`). ### Per-session env overrides: exact-key allowlist and CLAUDE_CONFIG_DIR diff --git a/docs/cron-guide.md b/docs/cron-guide.md index 4e93a58b..0be6ec77 100644 --- a/docs/cron-guide.md +++ b/docs/cron-guide.md @@ -204,7 +204,7 @@ trailing carriage return: | Mode | Mechanism | Use when | | ------- | --------------------------------------------------------------- | ------------------------------------------------ | -| `typed` | `session.writeViaMux()` — the text as a tmux bracketed paste + Enter | Default; like a human pasting the prompt and pressing Enter. | +| `typed` | `session.writeViaMux()` — the text as a tmux bracketed paste + Enter (a single character is typed, so a dialog answer selects) | Default; like a human pasting the prompt and pressing Enter. | | `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. | > ⚠️ **Single-line only — enforced.** Like all programmatic input in Codeman, diff --git a/docs/orchestrator-loop-architecture.md b/docs/orchestrator-loop-architecture.md index a81d99b6..1026b53b 100644 --- a/docs/orchestrator-loop-architecture.md +++ b/docs/orchestrator-loop-architecture.md @@ -244,7 +244,7 @@ RalphLoop.tick() → assignTasks() // OR OrchestratorLoop does its own assignme session.sendInput(task.prompt) │ ▼ -writeViaMux() → tmux send-keys -l "prompt..." + Enter +writeViaMux() → tmux paste-buffer -p (prompt as one bracketed paste) + Enter │ ▼ Claude CLI receives prompt, executes, outputs results diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 9931f487..c3806303 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -7,13 +7,13 @@ * - **Persistence**: Sessions survive server restarts and disconnects * - **Ghost recovery**: Orphaned sessions are discovered and reattached on startup * - **Resource tracking**: Memory, CPU, and child process stats per session - * - **Reliable input**: `send-keys -l` sends literal text in a single command + * - **Reliable input**: a submitted prompt goes in as one bracketed paste, then a separate Enter * - **Teammate support**: Immutable pane IDs enable targeting individual teammates * * tmux sessions are named `codeman-{sessionId}` and stored in ~/.codeman/mux-sessions.json. * * Key features: - * - `send-keys 'text' Enter` sends literal text in a single command + * - `paste-buffer -p` + a separate `send-keys Enter` submits a prompt (see sendInput) * - `list-sessions -F` provides structured queries * - `display-message -p '#{pane_pid}'` for reliable PID discovery * - Single server architecture @@ -448,6 +448,27 @@ export interface PasteTextCommands { discard: string; } +/** + * Escape a final `;` for tmux. tmux splits commands on an argument that ENDS in + * `;` (`set-buffer -- 'a;'` stores `a`, `send-keys -l 'a;'` types `a`, a lone `;` + * is no argument at all); a final `\;` is its escape for a literal `;`. A `;` + * anywhere else in the argument is literal already. Exported for unit testing. + */ +export function escapeTmuxTrailingSemicolon(text: string): string { + return text.endsWith(';') ? `${text.slice(0, -1)}\\;` : text; +} + +/** + * Whether a text+Enter submit types `text` as keys instead of pasting it: true for + * a single character. Claude Code's and Codex's selection dialogs ignore a bracketed + * paste, so a pasted `3` + Enter confirms the highlighted option instead of option 3; + * a typed `3` selects it. One character cannot form the burst Codex reads as a paste, + * so typing it is safe there. Exported for unit testing. + */ +export function submitsAsKeys(text: string): boolean { + return [...text].length === 1; +} + /** * tmux commands that type `text` into `target` as ONE bracketed paste, ahead of a * separately sent `Enter` that submits it. Exported for unit testing. @@ -467,15 +488,13 @@ export interface PasteTextCommands { * text. tmux keeps a named buffer until something deletes it (`buffer-limit` trims * only automatic ones), hence `discard` for a paste that fails. * - * tmux splits commands on an argument that ENDS in `;` (`set-buffer -- 'a;'` stores - * `a`, a lone `;` fails with "no data specified"); a final `\;` is its escape for a - * literal `;`. + * A single character is typed instead (submitsAsKeys), so a dialog answer selects. + * A final `;` is escaped (escapeTmuxTrailingSemicolon). */ export function buildPasteTextCommands(tmux: string, target: string, text: string): PasteTextCommands { const buffer = `codeman-submit-${process.pid}-${++submitBufferSeq}`; - const data = text.endsWith(';') ? `${text.slice(0, -1)}\\;` : text; return { - set: `${tmux} set-buffer -b ${buffer} -- ${shellescape(data)}`, + set: `${tmux} set-buffer -b ${buffer} -- ${shellescape(escapeTmuxTrailingSemicolon(text))}`, paste: `${tmux} paste-buffer -p -d -b ${buffer} -t ${shellescape(target)}`, discard: `${tmux} delete-buffer -b ${buffer}`, }; @@ -3800,7 +3819,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * Send input directly to a tmux session. * * - Text followed by `\r` goes in as ONE bracketed paste (buildPasteTextCommands), - * so Codex cannot fold the Enter into a paste burst + * so Codex cannot fold the Enter into a paste burst; a single character is typed + * with `send-keys -l` instead, so a dialog answer selects (submitsAsKeys) * - Text without `\r` is typed literally with `send-keys -l` (no key interpretation) * - `Enter` key is sent as a SEPARATE tmux invocation after a small delay * - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline @@ -3838,20 +3858,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // single tmux invocation (via \;) causes Ink to interpret Enter as a newline // character in the input buffer rather than as form submission. The text goes // in as a bracketed paste so Codex cannot fold the Enter into it either - // (see buildPasteTextCommands). - await pasteText( - (cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }), - buildPasteTextCommands(this.tmux(), session.muxName, textPart) - ); + // (see buildPasteTextCommands). A single character is typed, because a + // selection dialog ignores a pasted `3` and Enter then confirms the + // highlighted option (see submitsAsKeys). + if (submitsAsKeys(textPart)) { + await execAsync( + `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, + { timeout: EXEC_TIMEOUT_MS } + ); + } else { + await pasteText( + (cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }), + buildPasteTextCommands(this.tmux(), session.muxName, textPart) + ); + } await new Promise((resolve) => setTimeout(resolve, 50)); await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, { timeout: EXEC_TIMEOUT_MS, }); } else if (textPart) { // Text only, no Enter - await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, { - timeout: EXEC_TIMEOUT_MS, - }); + await execAsync( + `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, + { timeout: EXEC_TIMEOUT_MS } + ); } else if (hasCarriageReturn) { // Enter only await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, { @@ -3993,19 +4023,25 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd(); if (textPart && hasCarriageReturn) { - execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }); + execSync( + `${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, + { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + } + ); execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); } else if (textPart) { - execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }); + execSync( + `${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, + { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + } + ); } else if (hasCarriageReturn) { execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, { encoding: 'utf-8', diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index 7832f79e..ba529f18 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -1,6 +1,9 @@ /** * Covers `buildPasteTextCommands()` and `pasteText()`: the text half of a text+Enter - * submit (`sendInput`) goes in as ONE bracketed paste. + * submit (`sendInput`) goes in as ONE bracketed paste. Also `submitsAsKeys()`: a + * single character is typed instead, since a selection dialog ignores a pasted `3` + * and the Enter then confirms the highlighted option. And + * `escapeTmuxTrailingSemicolon()`, which every literal text path runs through. * * Why: `send-keys -l` typed the text as an unmarked burst, and Codex's paste-burst * heuristic folded the Enter that followed 50 ms later into the paste as a newline, @@ -17,7 +20,7 @@ */ import { execSync } from 'node:child_process'; import { describe, expect, it } from 'vitest'; -import { buildPasteTextCommands, pasteText } from '../src/tmux-manager.js'; +import { buildPasteTextCommands, escapeTmuxTrailingSemicolon, pasteText, submitsAsKeys } from '../src/tmux-manager.js'; const FAKE_TMUX = `${JSON.stringify(process.execPath)} -e 'console.log(JSON.stringify(process.argv.slice(1)))' --`; @@ -74,6 +77,27 @@ describe('buildPasteTextCommands', () => { }); }); +describe('submitsAsKeys', () => { + it.each(['3', '9', 'y', 'n', ';', 'ä', '👍'])('types a single character: %s', (text) => { + expect(submitsAsKeys(text)).toBe(true); + }); + + it.each(['10', 'yes', '/cost', 'read your inbox', 'x'.repeat(1500)])('pastes anything longer: %s', (text) => { + expect(submitsAsKeys(text)).toBe(false); + }); +}); + +describe('escapeTmuxTrailingSemicolon', () => { + it.each([ + ['x;', 'x\\;'], + [';', '\\;'], + ['a; b', 'a; b'], + ['plain', 'plain'], + ])('%s -> %s', (text, expected) => { + expect(escapeTmuxTrailingSemicolon(text)).toBe(expected); + }); +}); + describe('pasteText', () => { const cmds = { set: 'SET', paste: 'PASTE', discard: 'DISCARD' }; @@ -114,16 +138,43 @@ describe('pasteText', () => { describe('sendInput wiring', () => { // sendInput() returns before touching tmux under vitest (IS_TEST_MODE), so the // branch that submits text is pinned on the source instead. - it('submits text through pasteText, not send-keys -l', async () => { + async function source(): Promise { const { readFileSync } = await import('node:fs'); - const src = readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf-8'); - const body = src.slice(src.indexOf('async sendInput('), src.indexOf('// ========== Pane Methods')); + return readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf-8'); + } + + async function sendInputBody(): Promise { + const src = await source(); + return src.slice(src.indexOf('async sendInput('), src.indexOf('// ========== Pane Methods')); + } + + it('submits text through pasteText, not send-keys -l', async () => { + const body = await sendInputBody(); const submitBranch = body.slice( body.indexOf('if (textPart && hasCarriageReturn)'), body.indexOf('} else if (textPart)') ); - expect(submitBranch).toContain('pasteText('); - expect(submitBranch).toContain('buildPasteTextCommands('); - expect(submitBranch).not.toMatch(/send-keys[^`]*-l/); + const pasteBranch = submitBranch.slice(submitBranch.indexOf('} else {')); + expect(pasteBranch).toContain('pasteText('); + expect(pasteBranch).toContain('buildPasteTextCommands('); + expect(pasteBranch).not.toMatch(/send-keys[^`]*-l/); + }); + + it('types a single-character submit with send-keys -l, so a dialog answer selects', async () => { + const body = await sendInputBody(); + const keysBranch = body.slice(body.indexOf('if (submitsAsKeys(textPart))'), body.indexOf('} else {')); + expect(keysBranch).toMatch(/send-keys[^`]*-l \$\{shellescape\(escapeTmuxTrailingSemicolon\(textPart\)\)\}/); + expect(keysBranch).not.toContain('pasteText('); + }); + + it('escapes a trailing ";" on every send-keys -l in sendInput and sendInputToPane', async () => { + const src = await source(); + const typed = src.match(/send-keys[^`\n]*-l \$\{[^`\n]*\}/g) ?? []; + const inSend = [await sendInputBody(), src.slice(src.indexOf('sendInputToPane(muxName'))] + .map((b) => b.slice(0, b.indexOf('\n }\n'))) + .join('\n'); + const typedHere = typed.filter((cmd) => inSend.includes(cmd)); + expect(typedHere.length).toBe(4); + for (const cmd of typedHere) expect(cmd).toContain('escapeTmuxTrailingSemicolon(textPart)'); }); });