From a431907c95dd30c5058b051c4ce02c35fbf085ad Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 11 Aug 2026 12:46:55 +0300 Subject: [PATCH] fix: steer active Codewith sends by default Agent: agent-ea --- README.md | 30 +++++++++++++++++------------- docs/cli.md | 6 ++++-- docs/reliability.md | 16 +++++++++------- src/lib/engine.test.ts | 22 +++++++++++++++++++--- src/lib/engine.ts | 13 +++++++++++++ src/types.ts | 2 +- 6 files changed, 63 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 03725bf..318f0b9 100644 --- a/README.md +++ b/README.md @@ -176,15 +176,18 @@ bulk send results include detection metadata when available: } ``` -Normal prompt delivery uses `Enter` and refuses active agents unless `--force-active` -is explicitly passed. `--queue` is the safe active-agent path: when detection proves -the target supports queued-message behavior, dispatch types the prompt and presses -the agent's queue key (`Tab` for Codewith, `Enter` for Claude Code); otherwise it -refuses. Prompt sends wait until the delivered text is visibly +Normal single-target prompt delivery uses `Enter`. Idle agents start the prompt +immediately; recognized active Codewith panes accept it as steering input at the next +safe model/tool boundary. Other active or unknown targets remain refused unless +`--force-active` is explicitly passed. `--if-idle` retains idle-only delivery. +`--queue` remains the explicit queued-message path: when detection proves the target +supports queued-message behavior, dispatch types the prompt and presses the agent's +queue key (`Tab` for Codewith, `Enter` for Claude Code); otherwise it refuses. +Prompt sends wait until the delivered text is visibly parked in the composer before pressing Enter/Tab; if it never parks within `DISPATCH_SETTLE_TIMEOUT_MS`, dispatch refuses the submit key. Queued delivery is single-shot to avoid duplicate queued follow-up inputs; `--retries` applies to -idle Enter submission. Detection supports +Enter submission. Detection supports direct binaries and compatible `node`/`bun`/`npx`/`bunx`/`pnpm`/`yarn`/`npm exec` launchers, but wrapper panes still need live composer UI proof so arbitrary `node` output and copied transcripts stay fail-closed. @@ -263,9 +266,9 @@ the dispatch data directory's `artifacts/` folder. `dispatch recover` uses the same triage result to plan a safe route for a recovery prompt. It defaults to dry-run and does not type anything unless `--apply` is passed. When applied, it calls the normal guarded prompt-send path: idle agents use `Enter`; -active Codewith/Claude panes are queued with `Tab` only when detection proves queued -prompt support; shells, arbitrary `node`/`bun`, stale transcripts, and unknown panes are -refused. +active Codewith/Claude panes use their proven queue key (`Tab` for Codewith, `Enter` +for Claude Code) only when detection proves queued prompt support; shells, arbitrary +`node`/`bun`, stale transcripts, and unknown panes are refused. ```bash dispatch recover --to open-dispatch:1.1 --prompt "Summarize status and continue safely" --json @@ -638,10 +641,11 @@ URL/key variables are present. marker with a positive count, and that count exactly matches the complete prompt's Unicode scalar count. Literal delivery, partial or invalid counts, embedded or ambiguous marker text, and stale placeholders all fail closed. -5. Press **Enter**, then re-press until the **delivery probe** confirms submission - (working indicator appeared / composer cleared) or the submit timeout/retries are exhausted. - Queued Tab delivery is not retried because duplicate Tabs can create duplicate - queued follow-up inputs. +5. Press the resolved submit key. Non-queued **Enter** delivery is re-pressed until + the **delivery probe** confirms submission (working indicator appeared / composer + cleared) or the submit timeout/retries are exhausted. Explicit queued delivery is + single-shot regardless of whether the proven queue key is Tab or Enter, because + retrying it can create duplicate queued follow-up inputs. 6. Record a **delivered / not-delivered** verdict with a reason. If a Codewith pane queues input while an auth profile/account switch is visible, the verdict is **not delivered** with `actionNeeded=true` rather than a false success. diff --git a/docs/cli.md b/docs/cli.md index b472c0e..1d68fc9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -12,8 +12,10 @@ `--goal`, `--machine`, `--backend`, `--if-idle`, `--queue`, `--submit-key`, `--force-active`, `--capture-before`, `--dry-run`, `--max-concurrency`, `--jitter`, `--per-machine-limit`, `--no-submit`, `--no-confirm`, `--delay`, - `--retries`, `--mode`, and `--json`. Bulk defaults to the idle guard and is - tmux-only; Mosaic supports single-target send, dry-run, queue, and no-submit. + `--retries`, `--mode`, and `--json`. A plain single-target send steers a + recognized active Codewith pane with Enter; `--queue` explicitly queues and + `--if-idle` preserves idle-only delivery. Bulk defaults to the idle guard and + is tmux-only; Mosaic supports single-target send, dry-run, queue, and no-submit. - `exec` sends a policy-filtered command only to a detected shell pane. It accepts `--policy`, repeatable `--allow-prefix`, `--no-filter`, `--dry-run`, `--interrupt`, the submit controls above, `--machine`, and `--json`. Dry-run diff --git a/docs/reliability.md b/docs/reliability.md index 4274d93..21859da 100644 --- a/docs/reliability.md +++ b/docs/reliability.md @@ -52,13 +52,15 @@ dispatch refuses to press Enter/Tab and records a failed delivery instead of ris swallowed submit. Once parked, `dispatch` presses **Enter**, then confirms and re-presses Enter idempotently -until the delivery probe says it submitted. Defaults are a 10s submit budget with a 2s -retry interval (`DISPATCH_SUBMIT_TIMEOUT_MS`, `DISPATCH_SUBMIT_RETRY_INTERVAL_MS`). -`--retries` still overrides the retry count when a caller needs a fixed policy. +until the delivery probe says it submitted. On a recognized active Codewith pane, this is +steering input accepted at the next safe model/tool boundary; `--if-idle` still refuses +that active target. Defaults are a 10s submit budget with a 2s retry interval +(`DISPATCH_SUBMIT_TIMEOUT_MS`, `DISPATCH_SUBMIT_RETRY_INTERVAL_MS`). `--retries` still +overrides the retry count when a caller needs a fixed policy. -Queued active-agent delivery uses **Tab** only when target detection proves queue -support. Tab delivery is single-shot: dispatch does not retry Tab because repeated -Tabs can create duplicate queued follow-up inputs. +Queued active-agent delivery uses only the queue key proven by target detection: +**Tab** for Codewith and **Enter** for Claude Code. Queued delivery is single-shot +because retrying the queue key can create duplicate follow-up inputs. ## 3. Smart delivery confirmation @@ -89,6 +91,6 @@ success when follow-up input is parked during a profile switch and may never dra | `DISPATCH_MS_PER_WORD` / `DISPATCH_MS_PER_CHAR` | Auto-delay growth | | `DISPATCH_SETTLE_TIMEOUT_MS` | Prompt-parked settle budget before the first submit key; default 2000ms | | `DISPATCH_SUBMIT_TIMEOUT_MS` / `DISPATCH_SUBMIT_RETRY_INTERVAL_MS` | Submit confirmation/retry budget; defaults 10000ms / 2000ms | -| `--retries ` | Fixed Enter retry count before giving up; queued Tab delivery is single-shot | +| `--retries ` | Fixed non-queued Enter retry count before giving up; explicit queued delivery is single-shot | | `--no-confirm` | Skip the confirmation probe | | `--mode auto\|paste\|literal` | Force the delivery method | diff --git a/src/lib/engine.test.ts b/src/lib/engine.test.ts index 1c078ed..b6beccc 100644 --- a/src/lib/engine.test.ts +++ b/src/lib/engine.test.ts @@ -586,18 +586,34 @@ describe("performDispatch", () => { expect(r.argvs().some((a) => a.includes("Enter"))).toBe(false); }); - test("refuses active wrapped Codewith panes before Enter delivery by default", async () => { + test("steers active wrapped Codewith panes with Enter by default", async () => { const r = composerRunner("node", activeCodewithCapture, "✶ Working… (esc to interrupt)", codewithProcessTree); const rec = await performDispatch( - { target: "open-dispatch:1.1", prompt: "Do not send Enter to a busy pane" }, + { target: "open-dispatch:1.1", prompt: "Steer this at the next safe boundary", submitDelayMs: 0 }, + { tmux: new Tmux(r), sleep: noSleep }, + ); + + expect(rec.status).toBe("delivered"); + expect(rec.targetState).toBe("active"); + expect(rec.detection).toMatchObject({ agentKind: "codewith", canReceivePrompt: false, canQueuePrompt: true }); + expect(r.argvs().some((a) => a[1] === "send-keys" && a.includes("-l"))).toBe(true); + expect(r.argvs().some((a) => a[1] === "send-keys" && a.includes("Enter"))).toBe(true); + expect(r.argvs().some((a) => a[1] === "send-keys" && a.includes("Tab"))).toBe(false); + }); + + test("refuses active wrapped Codewith panes when idle-only delivery is requested", async () => { + const r = composerRunner("node", activeCodewithCapture, "✶ Working… (esc to interrupt)", codewithProcessTree); + + const rec = await performDispatch( + { target: "open-dispatch:1.1", prompt: "Do not send Enter to a busy pane", ifIdle: true }, { tmux: new Tmux(r), sleep: noSleep }, ); expect(rec.status).toBe("skipped"); expect(rec.targetState).toBe("active"); expect(rec.detection).toMatchObject({ agentKind: "codewith", canReceivePrompt: false, canQueuePrompt: true }); - expect(rec.detail).toMatch(/cannot receive an Enter prompt safely/); + expect(rec.detail).toMatch(/--if-idle was requested/); expect(r.argvs().some((a) => a[1] === "send-keys" || a[1] === "paste-buffer")).toBe(false); }); diff --git a/src/lib/engine.ts b/src/lib/engine.ts index fd6e2bb..60bd152 100644 --- a/src/lib/engine.ts +++ b/src/lib/engine.ts @@ -126,6 +126,17 @@ export async function performDispatch(options: DispatchOptions, deps: DispatchDe targetState === "active" && detection?.canQueuePrompt === true && submitKey === detection.recommendedSubmitKey; + // Codewith treats Enter during active work as steering input that is accepted + // at the next safe model/tool boundary. Keep that distinct from explicit + // queued delivery (Tab) and from the caller's idle-only guard. + const steeringDelivery = + submitEnabled && + options.queue !== true && + options.ifIdle !== true && + targetState === "active" && + detection?.agentKind === "codewith" && + detection.canQueuePrompt === true && + submitKey === "Enter"; let captureBefore = target.visible && options.captureBeforeLines ? await performCapture({ target: options.target, lines: options.captureBeforeLines }, { tmux }) : undefined; @@ -154,6 +165,8 @@ export async function performDispatch(options: DispatchOptions, deps: DispatchDe submitKey === "Enter" && detection?.canReceivePrompt !== true && !queuedDelivery && + !steeringDelivery && + options.ifIdle !== true && options.forceActive !== true ) { return finish({ diff --git a/src/types.ts b/src/types.ts index d4deb29..80ec91a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -147,7 +147,7 @@ export interface DispatchOptions { goal?: boolean; /** Optional machine id (local when omitted). Resolved via @hasna/machines. */ machine?: string; - /** Submit key for prompt sends. Enter is default; Tab is only for proven queue support. */ + /** Submit key for prompt sends. Enter starts idle work or steers active Codewith; Tab is only for proven queue support. */ submitKey?: SubmitKey; /** Refuse delivery unless the target looks idle. */ ifIdle?: boolean;