/** * daemon/dispatch.ts — deliver a work order to a project's session. * * The transport half of PAI's task bus: PAI decides which project owns a task, * hands us the project name plus a message body, and we resolve that to a live * session and deliver it — spawning the session if none is running. * * ONE atomic call on purpose. A caller doing list → launch → send itself races: * a session can start or die between the check and the send, and the caller ends * up duplicating session-lifecycle logic it doesn't own. * * Outcomes are RESULTS, not errors. A task the bus can't route is an ordinary * thing to report and move past — a batch must not abort because one project * lacks an alias. Only genuine infrastructure failure throws. * * delivered — a live session accepted it * queued — typed into a live session that was mid-turn. Claude Code * queues input during a turn, so silence is not evidence of * non-delivery. This is SUCCESS: never retry it. Retrying * duplicates, and one trigger became three job sweeps. * spawned — no session ran; we launched one and it accepted it * unlaunchable — no curated alias. Setup gap: `pai project name ` * unreachable — tab opened but the session never accepted input. Runtime bug. * skipped — no live session and spawning was disabled * * `unlaunchable` and `unreachable` are deliberately distinct: the first is a * missing alias, the second is a session that failed to come up. Collapsing * them sends whoever reads the result looking in the wrong place. */ import { type PaiProject } from "./pai-projects.js"; import { type AckResult } from "./sessions.js"; import { type TerminalIO } from "./terminal-screen.js"; export type DispatchOutcome = "delivered" | "queued" | "spawned" | "unlaunchable" | "unreachable" | "skipped"; export interface DispatchResult { outcome: DispatchOutcome; project: string; session: string; reason: string; } export interface DispatchOptions { /** Never launch a session; report `skipped` instead. */ noSpawn?: boolean; /** * Total wall-clock budget for the WHOLE dispatch, caller-supplied. * * Stages must share one deadline, not hold their own. Spawning runs * readiness and then delivery in sequence, so per-stage limits add up: a 180s * readiness limit plus a 120s delivery limit is a 300s worst case, which * silently outlives a caller that budgeted 180s and kills the process itself. * The caller then sees its own timeout instead of our reason — a failure we * cannot reproduce from this side. One budget, split across the stages. */ budgetMs?: number; /** Cap on the readiness wait, within the budget. */ spawnTimeoutMs?: number; /** Cap on the delivery wait, within the budget. */ deliverTimeoutMs?: number; /** * Routing prefix, when the default does not fit. * * A comment on a task in flight is a correction, not a new work order, and * `[Task]` tells the receiving session to start. `[Task:comment]` tells it to * adjust what it already has. */ prefix?: string; } /** * Everything dispatch() touches outside itself, injected so the outcome matrix * can be tested without iTerm, a daemon, or a real `pai` binary. Production * callers omit it and get the real implementations. */ export interface DispatchDeps { resolve: (name: string) => Promise; sessions: () => { id: string; name: string; paiName: string | null; }[]; deliver: (sessionId: string, body: string, timeoutMs: number, io?: TerminalIO, retries?: number) => Promise; launch: (project: PaiProject, opts?: { initialPrompt?: string; }) => Promise<{ itermSessionId: string; }>; waitReady: (sessionId: string, timeoutMs: number) => Promise; /** Read a session's screen, to confirm Claude still owns the tty. */ capture: (sessionId: string) => string | null; /** Clock for the shared budget; injectable so budget maths is testable. */ now: () => number; } /** * Routing prefix for dispatched work. * * Deliberately NOT `[Session:PAI]`. That prefix means "reply to the sender on * this channel", and for a dispatched task there is no sender left to reply to — * the CLI that sent it has already exited. Promising a reply path that doesn't * exist is worse than promising none, so `[Task]` says: act on it, don't reply, * report by closing it on the tracker. * * The body carries the same contract in words, because a session that has never * seen `[Task]` before must still do the right thing. */ export declare const TASK_PREFIX = "[Task]"; export { isClaudeReady, hasBeenSubmitted, flatten, realIO, type TerminalIO, } from "./terminal-screen.js"; /** * Find a running session for `project`. * * Matches the project's display name, canonical name and every curated alias, * case-insensitively — session labels and aliases disagree on capitalisation * often enough that an exact match silently spawns a duplicate tab. */ export declare function findSessionForProject(project: PaiProject, sessions: { id: string; name: string; paiName: string | null; }[]): { id: string; label: string; } | null; /** A live session with its persistent PAI name resolved. */ export interface LiveSession { id: string; name: string; paiName: string | null; } /** * Wait until a freshly launched session can ACCEPT input. * * Note "accept", not "be idle". A launched session immediately runs its * `/Name … go` preamble and stays busy for minutes; waiting for the screen to * settle times out on a session that is perfectly healthy — which is exactly * what the first version did. Claude Code queues typed input while it works, so * idleness is the wrong gate. * * But "the box is drawn" was too weak. The preamble is typed into that box and * sits there unsubmitted while it is drawn, so a dispatcher that fired on the * first drawn box appended its work order to `/Name Voice Notes` and `go` — * three inputs racing in one box, with the user's own typing landing in the * middle of it. Reported live on 2026-08-04. * * The gate is therefore drawn AND empty: the preamble has been submitted and * the box is free. A busy session still qualifies, which preserves the point of * the paragraph above. */ export declare function waitForReady(sessionId: string, timeoutMs: number, io?: TerminalIO): Promise; /** * Type `body` into a session and confirm Claude actually took it. * * Frame-counting (what `sessions checkpoint` uses) can't be trusted here: a * session mid-task animates constantly, so "the screen changed" is true whether * or not our text was submitted. Instead we use the one transition that only * happens on submit — the text leaves the input box and appears above it: * * present in the frame, AND no longer on the ❯ input line -> submitted * * That works identically whether the session is idle or busy, which is the * whole point for a freshly spawned session that is still running `go`. */ export declare function submitAndConfirm(sessionId: string, body: string, timeoutMs: number, io?: TerminalIO, retries?: number): Promise; /** * Resolve `project` to a session and deliver `message`, spawning if needed. * * Never throws for a routing outcome — see the module comment. */ export declare function dispatch(projectName: string, message: string, opts?: DispatchOptions, deps?: DispatchDeps): Promise; //# sourceMappingURL=dispatch.d.ts.map