/** * What a caller asks for when it wants an agent running somewhere, independent of which surface * asked and of whether the answer turns out to be a structured session or a terminal. * * Every launch surface builds one of these: the renderer's agent tabs and workspace creates, * mobile's create sheet and new-tab button, `orchestration.workerStart`, and the CLI. The host * resolves it once — settings default plus per-launch feasibility from * `structured-native-chat-launch-route` — so no surface carries its own copy of that decision. * * A caller may name the surface it wants (`surface`); otherwise picking structured vs terminal is * the host's job. Either way the host decides feasibility and reports the result in the receipt. */ import type { TuiAgent } from './tui-agent' import type { AgentLaunchSurface } from './agent-launch-surface' /** How a launch's initial text reaches the agent. */ export type AgentLaunchPromptDelivery = /** Sent as the agent's first turn once it is ready. */ | 'submit' /** Left unsent for the user to edit and send. Historically this forced a terminal, because a * draft lived in the TUI's input and chat only mirrored it; a structured session accepts one * directly, so it no longer decides the route. */ | 'draft' export type AgentLaunchPrompt = { text: string delivery: AgentLaunchPromptDelivery } /** * Where the agent lands. * * `create-worktree` is part of the intent rather than a separate call the caller makes first, * because the route cannot be settled before the workspace exists: `agentSession.createSupport` * can only answer for a workspace the host can resolve. Splitting the two is exactly what made * every new-worktree launch a terminal — the worktree was created agent-first, so the structured * branch below it was unreachable. */ export type AgentLaunchTarget = /** A workspace that already exists, addressed by any selector the runtime resolves. */ | { kind: 'existing' worktree: string /** The workspace root the host resolved for that selector. Host-set, never accepted from a * caller: it decides whether a requested `cwd` names the root or somewhere else. */ workspacePath?: string /** The workspace's SSH connection, `null` when local. Host-set, like `workspacePath`: it * decides whether the window can read the agent's transcript, so which view the tab opens in. */ connectionId?: string | null } /** A worktree this launch creates. `create` is the `worktree.create` request minus its agent * fields — the launch owns those, so a caller cannot set a startup agent behind the router. */ | { kind: 'create-worktree'; create: Readonly> } /** A folder workspace this launch creates. `create` is the `folderWorkspace.create` request. */ | { kind: 'create-folder-workspace'; create: Readonly> } /** An existing terminal the caller wants reused rather than a fresh surface. Always resolves to a * terminal agent: a running PTY keeps its execution transport. */ export type AgentLaunchReusedTerminal = { handle: string } export type AgentLaunchIntent = { agent: TuiAgent target: AgentLaunchTarget prompt?: AgentLaunchPrompt /** Seeded launch options: narrowed to what a structured create accepts, and read as the model, * effort and mode preferences of a terminal launch. */ sessionOptions?: Readonly> reuseTerminal?: AgentLaunchReusedTerminal /** * Per-call replacement for the user's configured launch arguments, as a saved launch recipe * carries. Tri-state and must stay so: absent means "use the settings default", `null` means the * caller explicitly wants none, and collapsing the two would make a recipe that clears its args * silently inherit whatever the settings happen to hold. * * Deliberately NOT a route input. Structured chat reads the execution host's saved Arguments; * per-call overrides remain terminal-only and the host reports that in `warning`. */ agentArgs?: string | null /** * Where the agent starts, when that is not the workspace root — a resumed session's recorded * subdirectory is the case that needs it. * * Unlike `agentArgs` this one DOES decide the route: only a terminal can be started somewhere * other than its workspace, so a launch carrying one downgrades with `tui_launch_command` rather * than running a structured session in the wrong directory. */ cwd?: string /** * Which surface the user acted on, for the `agent_started` telemetry triple. Never read as * behaviour — the host derives the other two members of that triple and this one is the only part * it cannot know. */ launchSource?: string /** The `tabId:leafId` a terminal launch creates its pane under, for a caller that places its own * tabs. Not a route input; refused when that pane is already live. */ paneKey?: string /** The caller-minted id of the chat session a structured launch creates. Not a route input; * refused when that session already exists. */ sessionId?: string /** Account routing env for a terminal agent (`agent.launch`'s `accountEnv`). Not a route input. */ accountEnv?: Readonly> /** `agent.launch`'s `nativeChatDefault`: the caller's chat default in place of the host's own. */ nativeChatDefault?: boolean /** `agent.launch`'s `surface`: the user's pick for this launch, ahead of either default. */ surface?: AgentLaunchSurface } /** * Where in the workspace's tab layout a new tab goes: a group, the tab it follows (a host tab id; a * terminal's tab is the tab half of its pane key), or both. Never fails a launch: a group that is * gone falls back to the anchor's group, then to the active one. */ export type AgentLaunchPlacement = { groupId?: string; afterTabId?: string } /** Whether the caller's own view moves to the new tab. Never names another viewer's screen. */ export type AgentLaunchPresentation = 'focused' | 'background' /** Where the tab landed, as the window that owns the layout reported it. */ export type AgentLaunchPlacementReceipt = { groupId: string /** Present when the requested group was not used: the anchor tab's group, or the active one. */ fallback?: 'anchor-group' | 'active-group' } /** The surface the host actually created. */ export type AgentLaunchOutcome = | { kind: 'structured' sessionId: string handle: string /** The host-owned id of the tab that shows this chat: the tab half of the reserved `paneKey` * when one was sent, else the one the host gave its tab. Identity, not placement, like the * terminal arm's `paneKey`. Absent from hosts that predate it. */ tabId?: string } | { kind: 'terminal' handle: string /** * The pane the host minted for this agent, as `tabId:leafId` — read it with `parsePaneKey`. * * Identity, not placement. The host already mints this pair, bakes it into the PTY's * environment and hands it to its own reveal; a client that draws its own tabs previously had * no way to learn it, because a `term_*` handle is a main-side mapping the renderer cannot * resolve. Where that pane goes — which group, what order, whether it takes focus — stays * with the client and never rides this wire. * * One field rather than a `tabId`/`leafId` pair, because the key already carries both and two * copies of one fact can disagree. * * Absent when this launch minted no pane, such as a reused terminal that was already running, * or when the runtime could not report the pane it created. */ paneKey?: string } /** * What became of the launch text. * * An enum rather than a boolean because "not delivered" and "handed to a surface that delivers it * out of band" are different answers, and a caller deciding whether to resend needs to tell them * apart. A receipt may under-claim — reporting a delivery it cannot vouch for as `not-delivered` is * a wasted resend, while over-claiming loses the text silently. */ export type AgentLaunchPromptOutcome = AgentLaunchPromptDisposal['outcome'] /** `messageId` hangs off the `journaled` arm rather than sitting optional beside all three: a * producer must not be able to claim the text was committed and then not say where. */ export type AgentLaunchPromptDisposal = /** Committed to the session's transcript, which `messageId` names. */ | { outcome: 'journaled'; messageId: string } /** * Handed to a terminal agent, either on the launch command that started it or as a bracketed * paste into its live PTY. No `messageId`, because a terminal keeps no transcript to name a row * in: what the agent does with the text is observable only in the pane. The caller must NOT * resend — a second paste arrives as a second turn, which is worse than the wasted resend * `not-delivered` costs. */ | { outcome: 'handed-to-terminal' } /** Not delivered by this call; the caller still owns the text. */ | { outcome: 'not-delivered' } /** * Only ever replayed, never a live answer: the host recorded the running agent, then stopped * before the delivery reported back, so the text may or may not have arrived. The caller must not * resend. Sent only to a caller advertising `agent.launch.prompt-unconfirmed.v1`; every other * caller is refused with `agent_session_operation_unknown` instead. */ | { outcome: 'unconfirmed' } export type AgentLaunchPromptReceipt = { delivery: AgentLaunchPromptDelivery } & AgentLaunchPromptDisposal export type AgentLaunchResult = { outcome: AgentLaunchOutcome /** The workspace the agent runs in, resolved or created. */ worktreeId: string /** * The launch completed but something in it did not: a startup terminal that failed to spawn, * untracked files that could not be copied. `worktree.create` returns this at the top level and * mobile already surfaces it, so a launch that drops it lands the user on a workspace that is * quietly incomplete. * * Top level rather than on the outcome, and deliberately the ONLY place a launch warning lives: * it is produced by the create as often as by the surface, it applies to a structured session * and a terminal alike, and a reader should not have to branch on `outcome.kind` to discover * that the workspace it just opened is missing something. */ warning?: string /** Why the outcome is what it is — always populated, so a downgrade is never silent. */ receipt: AgentLaunchModeReceipt prompt?: AgentLaunchPromptReceipt /** Absent when no window placed the tab: an older host, no requested placement, or a host with no * window owning the layout. */ placement?: AgentLaunchPlacementReceipt } export type AgentLaunchMode = 'structured' | 'terminal' /** Why a launch ran in the mode it did. `user_default` is the preference being honoured and * `requested` the caller's explicit pick; every other member is a reason a chat could not run. */ export type AgentLaunchModeReason = | 'user_default' | 'requested' | 'remote_execution_host' | 'reused_terminal' | 'agent_without_structured_session' /** Historical name, kept because receipts carry it: the launch asked for a start directory * outside its workspace. A custom launch command no longer produces it. */ | 'tui_launch_command' | 'structured_sessions_unavailable' | 'structured_support_unknown' | 'wsl_execution_runtime' | 'codex_on_windows' | 'structured_unsupported_on_host' /** Restates `WorkerStartModeReceipt` in surface-neutral terms so orchestration's receipt and a * mobile or renderer launch report the same vocabulary. */ export type AgentLaunchModeReceipt = { /** The mode the launch actually ran in. */ mode: AgentLaunchMode /** The caller's explicit pick, else the user's settings default for a new agent tab. */ preferred: AgentLaunchMode reason: AgentLaunchModeReason /** One sentence, always present, so a fallback is never silent. */ detail: string } /** * Narrows a launch result read back from durable storage. * * Lives beside the type rather than in the store so the two cannot drift: a field added above and * not checked here is a field a replay can hand back unvalidated. Every optional field is checked * when present and ignored when absent, so a row written by an older host still reads. */ export function isAgentLaunchResult(value: unknown): value is AgentLaunchResult { if (typeof value !== 'object' || value === null) { return false } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: narrowing an unknown for field-by-field validation; every field read below is checked before use. const result = value as Partial return ( isAgentLaunchOutcome(result.outcome) && typeof result.worktreeId === 'string' && isAgentLaunchModeReceipt(result.receipt) && (result.warning === undefined || typeof result.warning === 'string') && (result.prompt === undefined || isAgentLaunchPromptReceipt(result.prompt)) && (result.placement === undefined || isAgentLaunchPlacementReceipt(result.placement)) ) } function isAgentLaunchPlacementReceipt(value: unknown): value is AgentLaunchPlacementReceipt { if (typeof value !== 'object' || value === null) { return false } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: narrowing an unknown for field-by-field validation; every field read below is checked before use. const placement = value as Partial // A fallback word this build does not know still reads: placement is a report, not a gate. return ( typeof placement.groupId === 'string' && (placement.fallback === undefined || typeof placement.fallback === 'string') ) } function isAgentLaunchPromptReceipt(value: unknown): value is AgentLaunchPromptReceipt { if (typeof value !== 'object' || value === null) { return false } if (!('delivery' in value) || !isAgentLaunchPromptDelivery(value.delivery)) { return false } if (!('outcome' in value)) { return false } return value.outcome === 'journaled' ? 'messageId' in value && typeof value.messageId === 'string' : value.outcome === 'handed-to-terminal' || value.outcome === 'not-delivered' || value.outcome === 'unconfirmed' } function isAgentLaunchOutcome(value: unknown): value is AgentLaunchOutcome { if (typeof value !== 'object' || value === null) { return false } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the assertion claims only that the keys may be present and unknown, which is true of any object. const outcome = value as { kind?: unknown handle?: unknown sessionId?: unknown paneKey?: unknown tabId?: unknown } if (typeof outcome.handle !== 'string' || outcome.handle.length === 0) { return false } return outcome.kind === 'terminal' ? // Checked when present, ignored when absent: a row written before this field existed, or by a // runtime that minted no pane, still reads. Deliberately not parsed — a read-side shape rule // stricter than the write side turns one odd row into a refused replay. outcome.paneKey === undefined || typeof outcome.paneKey === 'string' : outcome.kind === 'structured' && typeof outcome.sessionId === 'string' && outcome.sessionId.length > 0 && // Optional on the same terms as the terminal arm's `paneKey`. (outcome.tabId === undefined || typeof outcome.tabId === 'string') } function isAgentLaunchModeReceipt(value: unknown): value is AgentLaunchModeReceipt { if (typeof value !== 'object' || value === null) { return false } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: narrowing an unknown for field-by-field validation; every field read below is checked before use. const receipt = value as Partial return ( (receipt.mode === 'structured' || receipt.mode === 'terminal') && (receipt.preferred === 'structured' || receipt.preferred === 'terminal') && typeof receipt.reason === 'string' && typeof receipt.detail === 'string' ) } function isAgentLaunchPromptDelivery(value: unknown): value is AgentLaunchPromptDelivery { return value === 'submit' || value === 'draft' } /** The agent fields a create payload must not carry: the launch owns placement, and a caller that * sets one of these would route itself around the host's decision. */ export const AGENT_LAUNCH_RESERVED_CREATE_FIELDS = [ 'startupAgent', 'startupCommand', 'startupPrompt', 'startupDraft', 'startupLaunchConfig', 'startupEnv', 'startupCommandDelivery', // The launch carries its own; a create's copy would be a second, possibly contradicting, answer. 'launchSource' ] as const /** Strips the reserved agent fields from a create payload. Callers migrating from * `worktree.create` pass their existing params; this keeps a stale `startupAgent` from * re-creating the agent-first path the router exists to replace. */ export function withoutReservedAgentCreateFields>>( create: Create ): Create { const stripped: Record = { ...create } for (const field of AGENT_LAUNCH_RESERVED_CREATE_FIELDS) { delete stripped[field] } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every reserved field is optional on a create payload, so dropping them leaves the caller's own shape. return stripped as Create }