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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ git diff | dispatch send --to work:agent --prompt "review this diff"
# Type without submitting (leave it in the composer)
dispatch send --to work:agent --prompt "draft" --no-submit

# Queue to an active Codewith/Claude pane that proves Tab queued-message support
# Queue to an active Codewith/Claude pane that proves queued-message support
# (Codewith queues with Tab, Claude Code queues with Enter)
dispatch send --to open-dispatch:1.1 --prompt "Follow up safely" --queue --dry-run

# Explicit submit key. Tab is accepted only when detection proves queue support.
Expand Down Expand Up @@ -173,11 +174,12 @@ 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
`Tab`; otherwise it refuses. Prompt sends wait until the delivered text is visibly
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 Tab delivery
`DISPATCH_SETTLE_TIMEOUT_MS`, dispatch refuses the submit key. Queued delivery
is single-shot to avoid duplicate queued follow-up inputs; `--retries` applies to
Enter submission. Detection supports
idle 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hasna/dispatch",
"version": "0.0.24",
"version": "0.0.25",
"author": "Andrei Hasna <andrei@hasna.com>",
"repository": {
"type": "git",
Expand Down
32 changes: 32 additions & 0 deletions src/lib/agent-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,38 @@ describe("agent triage and recovery", () => {
expect(r.argvs().some((a) => a[1] === "send-keys" || a[1] === "paste-buffer")).toBe(false);
});

test("recover plans queued Enter recovery for active Claude Code seats, never Tab", async () => {
const activeClaudeCapture = `
✶ Wandering… (12m 2s · ↓ 29.6k tokens)

────────────────────────────────────────────────────────────────────────────────
────────────────────────────────────────────────────────────────────────────────
⏵⏵ bypass permissions on · 2 monitors · esc to interrupt · ← for agents
`;
const claudeSeatProcessTree = `
1234272 8038 Ss /bin/bash -l
1301071 1234272 Sl+ \\_ node /home/hasna/.local/bin/accounts launch account005 --tool claude --permissions dangerous
1302384 1301071 Sl+ \\_ claude --dangerously-skip-permissions
`;
const r = recoveryRunner(activeClaudeCapture, claudeSeatProcessTree);

const result = await performAgentRecovery(
{ target: "hq:staff", prompt: "Please post a one-line status.", lines: 20 },
{ tmux: new Tmux(r) },
);

expect(result).toMatchObject({
status: "planned",
dryRun: true,
// Claude Code queues with plain Enter; Tab is completion there, so a Tab
// recovery would corrupt the composer instead of queueing the prompt.
action: { kind: "queue", submitKey: "Enter", safeToApply: true },
triage: { detection: { agentKind: "claude", composerState: "active", canQueuePrompt: true } },
});
expect(r.argvs().some((a) => a[1] === "send-keys" || a[1] === "paste-buffer")).toBe(false);
});

test("recover refuses arbitrary node panes even with copied agent-looking text", async () => {
const r = recoveryRunner(
`${idleCodewithCapture}\nnode server.js\nListening\n`,
Expand Down
9 changes: 6 additions & 3 deletions src/lib/agent-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,18 +133,21 @@ export function recommendRecoveryAction(
};
}
if (opts.queue !== false && detection.canQueuePrompt) {
// The queue key is agent-specific (Tab for Codewith, Enter for Claude
// Code); detection carries the proven key for this target.
const queueKey = detection.recommendedSubmitKey ?? "Tab";
return {
kind: "queue",
submitKey: "Tab",
submitKey: queueKey,
safeToApply: true,
reason: "active agent composer advertises queued Tab prompt support",
reason: `active agent composer advertises queued ${queueKey} prompt support`,
};
}
return {
kind: "refuse",
safeToApply: false,
reason: detection.canQueuePrompt
? "target is active; pass queue=true to allow queued Tab recovery"
? "target is active; pass queue=true to allow queued prompt recovery"
: detection.reason,
};
}
Expand Down
74 changes: 74 additions & 0 deletions src/lib/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,80 @@ describe("performDispatch", () => {
expect(r.argvs().some((a) => a[1] === "send-keys" && a.includes("Enter"))).toBe(false);
});

test("queues active wrapped Claude Code seats with Enter when queue is explicitly requested", async () => {
const activeClaudeCapture = `
✶ Wandering… (12m 2s · ↓ 29.6k tokens)

────────────────────────────────────────────────────────────────────────────────
────────────────────────────────────────────────────────────────────────────────
⏵⏵ bypass permissions on · 2 monitors · esc to interrupt · ← for agents
`;
const claudeSeatProcessTree = `
1234272 8038 Ss /bin/bash -l
1301071 1234272 Sl+ \\_ node /home/hasna/.local/bin/accounts launch account005 --tool claude --permissions dangerous
1302384 1301071 Sl+ \\_ claude --dangerously-skip-permissions
`;
const r = composerRunner(
"node",
activeClaudeCapture,
"✶ Wandering… (esc to interrupt)\nQueued: Queue this directive",
claudeSeatProcessTree,
);

const rec = await performDispatch(
{ target: "hq:staff", prompt: "Queue this directive", queue: true, submitDelayMs: 0 },
{ tmux: new Tmux(r), sleep: noSleep },
);

expect(rec.status).toBe("delivered");
expect(rec.targetState).toBe("active");
expect(rec.confirm?.queued).toBe(true);
expect(rec.detection).toMatchObject({ agentKind: "claude", canQueuePrompt: true, recommendedSubmitKey: "Enter" });
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 --queue --submit-key Tab against active Claude Code seats", async () => {
const activeClaudeCapture = `
✶ Wandering… (12m 2s · ↓ 29.6k tokens)

────────────────────────────────────────────────────────────────────────────────
────────────────────────────────────────────────────────────────────────────────
⏵⏵ bypass permissions on · 2 monitors · esc to interrupt · ← for agents
`;
const claudeSeatProcessTree = `
1234272 8038 Ss /bin/bash -l
1301071 1234272 Sl+ \\_ node /home/hasna/.local/bin/accounts launch account005 --tool claude --permissions dangerous
1302384 1301071 Sl+ \\_ claude --dangerously-skip-permissions
`;
const r = composerRunner("node", activeClaudeCapture, "✶ Wandering… (esc to interrupt)", claudeSeatProcessTree);

const rec = await performDispatch(
{ target: "hq:staff", prompt: "Queue this", queue: true, submitKey: "Tab", submitDelayMs: 0 },
{ tmux: new Tmux(r), sleep: noSleep },
);

expect(rec.status).toBe("skipped");
expect(rec.detail).toMatch(/does not prove queued Tab prompt support/);
expect(r.argvs().some((a) => a[1] === "send-keys" || a[1] === "paste-buffer")).toBe(false);
});

test("refuses --queue --submit-key Enter against active Codewith panes", async () => {
const r = composerRunner("node", activeCodewithCapture, "✶ Working… (esc to interrupt)", codewithProcessTree);

const rec = await performDispatch(
{ target: "open-sessions:2.1", prompt: "Queue this", queue: true, submitKey: "Enter", submitDelayMs: 0 },
{ tmux: new Tmux(r), sleep: noSleep },
);

expect(rec.status).toBe("skipped");
expect(rec.detail).toMatch(/cannot receive an Enter prompt safely/);
expect(r.argvs().some((a) => a[1] === "send-keys" || a[1] === "paste-buffer")).toBe(false);
});

test("reports Codewith auth-switch queued stalls as action-needed instead of delivered", async () => {
const r = composerRunner(
"node",
Expand Down
47 changes: 39 additions & 8 deletions src/lib/engine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DispatchOptions, DispatchRecord } from "../types.js";
import type { AgentTargetInfo, DispatchOptions, DispatchRecord } from "../types.js";
import { Tmux } from "./tmux.js";
import type { Store } from "./store.js";
import { computeSubmitDelay } from "./delay.js";
Expand Down Expand Up @@ -40,9 +40,19 @@ export interface DispatchDeps {
sleep?: (ms: number) => Promise<void>;
}

function resolveSubmitKey(options: DispatchOptions, targetState: string): "Enter" | "Tab" {
function resolveSubmitKey(
options: DispatchOptions,
targetState: string,
detection?: AgentTargetInfo,
): "Enter" | "Tab" {
if (options.submitKey === "Enter" || options.submitKey === "Tab") return options.submitKey;
if (options.queue === true && targetState === "active") return "Tab";
if (options.queue === true && targetState === "active") {
// The queue key is agent-specific: Codewith stages queued messages with
// Tab, Claude Code queues with plain Enter. Detection carries the proven
// key; fall back to Tab so unproven targets hit the Tab safety gate.
if (detection?.canQueuePrompt === true && detection.recommendedSubmitKey) return detection.recommendedSubmitKey;
return "Tab";
}
return "Enter";
}

Expand Down Expand Up @@ -105,7 +115,17 @@ export async function performDispatch(options: DispatchOptions, deps: DispatchDe
}
const targetState = target.activity ?? "unknown";
const detection = target.detection;
const submitKey = resolveSubmitKey(options, targetState);
const submitKey = resolveSubmitKey(options, targetState, detection);
// A queued delivery: explicitly requested, the active target proved it can
// queue, and the resolved submit key IS the proven queue key. Lets
// Enter-queueing agents (Claude Code) through the idle-only Enter gates
// below, exactly as Tab does for Codewith — but an explicit --submit-key
// that differs from the proven queue key never rides this exemption.
const queuedDelivery =
options.queue === true &&
targetState === "active" &&
detection?.canQueuePrompt === true &&
submitKey === detection.recommendedSubmitKey;
let captureBefore = target.visible && options.captureBeforeLines
? await performCapture({ target: options.target, lines: options.captureBeforeLines }, { tmux })
: undefined;
Expand All @@ -115,7 +135,10 @@ export async function performDispatch(options: DispatchOptions, deps: DispatchDe
const before = tmux.capturePane(options.target, { start: 50 });
record = { ...record, targetState, detection, captureBefore };

if (submitKey === "Tab" && detection?.canQueuePrompt !== true) {
// Tab submits only where Tab is the proven queue key: an agent may prove
// queueing generally (Claude Code queues with Enter) while Tab remains a
// destructive key in its composer.
if (submitKey === "Tab" && !(detection?.canQueuePrompt === true && detection.recommendedSubmitKey === "Tab")) {
return finish({
status: "skipped",
detail: `target does not prove queued Tab prompt support (${detection?.reason ?? "no detection available"})`,
Expand All @@ -126,7 +149,13 @@ export async function performDispatch(options: DispatchOptions, deps: DispatchDe
});
}

if (submitEnabled && submitKey === "Enter" && detection?.canReceivePrompt !== true && options.forceActive !== true) {
if (
submitEnabled &&
submitKey === "Enter" &&
detection?.canReceivePrompt !== true &&
!queuedDelivery &&
options.forceActive !== true
) {
return finish({
status: "skipped",
detail: `target cannot receive an Enter prompt safely (${detection?.reason ?? "no detection available"}); pass --queue for supported active agents or --force-active to override`,
Expand All @@ -148,7 +177,7 @@ export async function performDispatch(options: DispatchOptions, deps: DispatchDe
});
}

if (options.ifIdle && targetState !== "idle" && submitKey !== "Tab" && options.forceActive !== true) {
if (options.ifIdle && targetState !== "idle" && submitKey !== "Tab" && !queuedDelivery && options.forceActive !== true) {
return finish({
status: "skipped",
detail: `target is ${targetState}; refusing because --if-idle was requested (pass --queue to let the agent queue it, or --force-active to override)`,
Expand Down Expand Up @@ -255,7 +284,9 @@ export async function performDispatch(options: DispatchOptions, deps: DispatchDe

const submitResult = await submit(tmux, options.target, {
delayMs,
maxRetries: submitKey === "Tab" ? 0 : options.maxSubmitRetries,
// Queued deliveries are single-shot: retrying the queue key against a
// busy agent risks double-queueing the same prompt.
maxRetries: submitKey === "Tab" || queuedDelivery ? 0 : options.maxSubmitRetries,
submitKey,
isPromptParked,
isSubmitted: probe,
Expand Down
113 changes: 112 additions & 1 deletion src/lib/exec-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,114 @@ Goal active Objective: Copied from a real Codewith pane
});
});

// Measured on station01 (2026-07-31): the hq seats run Claude Code behind
// `accounts launch`, so the pane command is bare `node` and the agent proof
// is the `claude` child process plus the live TUI footer/composer chrome.
const claudeSeatProcessTree = `
1234272 8038 Ss /bin/bash -l
1301071 1234272 Sl+ \\_ node /home/hasna/.local/bin/accounts launch account005 --tool claude --permissions dangerous
1302384 1301071 Sl+ \\_ claude --dangerously-skip-permissions
`;

const idleClaudeSeatCapture = `
The previous turn's summary text is still visible in the transcript above
the composer, exactly as a live seat renders it.

✻ Churned for 1m 43s · 2 monitors still running

────────────────────────────────────────────────────────────────────────────────
❯ run the follow-through check now
────────────────────────────────────────────────────────────────────────────────
account006 · wks_c0ffee1234567890abcdef1
opus 5 (1m context) (medium) · 7% left · $12.34
⏵⏵ bypass permissions on · 2 monitors · ← for agents
`;

const activeClaudeSeatCapture = `
✶ Wandering… (12m 2s · ↓ 29.6k tokens)
⎿ Tip: Use /btw to ask a quick side question without interrupting Claude's current work

────────────────────────────────────────────────────────────────────────────────
────────────────────────────────────────────────────────────────────────────────
⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt · ← for agents
`;

test("recognizes wrapped idle Claude Code seats launched through the accounts wrapper", () => {
expect(
looksLikeWrappedAgentComposer(idleClaudeSeatCapture, { processTree: claudeSeatProcessTree }),
).toBe(true);
expect(
detectAgentTargetFromSignals({
paneCommand: "node",
visible: idleClaudeSeatCapture,
processTree: claudeSeatProcessTree,
cwd: "/home/hasna/.hasna/projects/workspaces/wks_c0ffee1234567890abcdef1",
}),
).toMatchObject({
targetKind: "agent",
agentKind: "claude",
composerState: "idle",
canReceivePrompt: true,
canQueuePrompt: false,
submitKeys: ["Enter"],
recommendedSubmitKey: "Enter",
});
});

test("recognizes busy wrapped Claude Code seats and offers Enter queueing", () => {
expect(
detectAgentTargetFromSignals({
paneCommand: "node",
visible: activeClaudeSeatCapture,
processTree: claudeSeatProcessTree,
cwd: "/home/hasna/.hasna/projects/workspaces/wks_c0ffee1234567890abcdef1",
}),
).toMatchObject({
targetKind: "agent",
agentKind: "claude",
composerState: "active",
canReceivePrompt: false,
canQueuePrompt: true,
submitKeys: ["Enter"],
recommendedSubmitKey: "Enter",
});
});

test("recognizes the Claude Code composer glyph followed by a non-breaking space", () => {
const capture = `
❯${"\u00a0"}deliver the directive to the seat
────────────────────────────────────────────────────────────────────────────────
⏵⏵ bypass permissions on · 2 monitors · ← for agents
`;
expect(
detectAgentTargetFromSignals({
paneCommand: "node",
visible: capture,
processTree: claudeSeatProcessTree,
}),
).toMatchObject({ targetKind: "agent", agentKind: "claude", composerState: "idle", canReceivePrompt: true });
});

test("refuses Claude Code seat content without Claude process evidence", () => {
for (const processTree of [
"1234 1 Ss /usr/bin/bash\n1240 1234 Sl+ node /srv/transcript-viewer.js\n",
// The accounts launcher alone (no claude child) is not agent evidence:
// the pane could be mid-launch or showing a copied transcript.
"1234 1 Ss /bin/bash -l\n1240 1234 Sl+ \\_ node /home/hasna/.local/bin/accounts launch account005 --tool claude --permissions dangerous\n",
]) {
expect(
detectAgentTargetFromSignals({
paneCommand: "node",
visible: idleClaudeSeatCapture,
processTree,
}),
processTree,
).toMatchObject({ targetKind: "unknown", agentKind: "unknown", canReceivePrompt: false });
expect(looksLikeWrappedAgentComposer(idleClaudeSeatCapture, { processTree })).toBe(false);
}
});

test("detects direct Claude Code and OpenCode panes", () => {
const claude = detectAgentTargetFromSignals({
paneCommand: "claude",
Expand All @@ -420,7 +528,10 @@ Goal active Objective: Copied from a real Codewith pane
agentKind: "claude",
composerState: "idle",
canReceivePrompt: true,
submitKeys: ["Enter", "Tab"],
// Claude Code submits and queues with Enter only; Tab is completion, not
// a submit key, so advertising it would let `dispatch key Tab` corrupt
// the composer.
submitKeys: ["Enter"],
recommendedSubmitKey: "Enter",
});

Expand Down
Loading
Loading