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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 9 additions & 7 deletions docs/reliability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <n>` | Fixed Enter retry count before giving up; queued Tab delivery is single-shot |
| `--retries <n>` | 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 |
22 changes: 19 additions & 3 deletions src/lib/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
13 changes: 13 additions & 0 deletions src/lib/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading