mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
344edc942b
commit
7fd76f06c3
6 files changed
+123
-36
No files matched your search
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+60
-24
@@ -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',
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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)');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user