Files
orca/src/shared/agent-launch-intent.ts
T
Jinwoo HongandClaude 5f134d8f01 feat(mobile): show and use the desktop's server workspaces on the phone (#26501)
* test(e2e): add a paired phone client helper and phone-mirror baseline specs

Later phone-mirror slices need one oracle that drives a real mobile-scoped device with the
phone's own capabilities against a serve host or a paired desktop. Pins #26022 across a serve
restart and today's gap: a desktop's phone does not list the desktop's server workspaces.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): show SSH host health on the workspace row host badge

Rows on an SSH host the desktop cannot reach looked healthy on the phone. The badge now
adds the desktop sidebar's own word ("devbox · Disconnected"), derived by the same shared
function from the connection status ssh.listTargetSummaries already sends; old desktops keep today's badge.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(e2e): compose the phone-mirror topology and check the cold snapshot

Slices need one composer for host, desktop and phone with a single teardown, so specs stop
copying setup. The serve-restart spec now reads the first tab-stream snapshot after the cold
start, which is what the phone builds its tab strip from.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): name an unhealthy SSH host even in a single-host list

The desktop card shows SSH status on any host count, so the phone now reads target summaries
whenever a repo is on SSH and badges an unhealthy host alone too. Labels and health share one
target-to-host keying, and a hook-level test pins the reply-to-row wiring.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(e2e): keep phone-mirror teardown errors from masking test failures

Teardown failures become a test attachment instead of replacing the real failure, and the
cold-snapshot poll reports stream errors together with the PTY list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): clear stale SSH host badges and announce them without the dot

A catalog that no longer spans hosts or holds an SSH repo now clears the health and label maps,
so a host that left cannot keep badging rows. The badge gets a screen-reader label ("devbox,
Disconnected") instead of reading the middle dot.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(runtime): let a paired desktop register its phones as delegated mobile devices

A desktop paired to this runtime can now call pairing.delegatedMobileDevice.sync to make each of
its phones an ordinary mobile-scope device it owns, so the phone mirrors that host through the
desktop. Children are never pending, inherit the parent's reach, and die with the parent's grant.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(runtime): move the mobile device revoke primitive below dispatch

Dispatch binds the caller-bound delegated-device context, so the revoke path it needs now lives in
a layer under it as a pure move; this drops the abstract method seam. No behaviour change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): keep a runtime grant until its delegated phones are revoked

A failed child cleanup now leaves the parent in place so a retry can still find the children, and
revokeRuntimeAccess lives beside the revoke it cascades to. Adds restart, rename and bounds coverage,
shares credential minting, and drops the timing-dependent census.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): fail a delegated sync whose dropped phone cannot be revoked

Matches the parent revoke: a child whose cleanup could not be saved is kept and the sync errors
before minting anything, so the desktop retries instead of believing the server matches its list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): relay a paired phone's server-workspace requests through the desktop

A phone mirrors its desktop, including workspaces on the desktop's configured servers. The desktop
now forwards a request that names `executionHost: runtime:<env>` to that server over one socket per
(phone, server), signed in as the phone's delegated device; replies pass through untouched except
stream ids. Hidden until the phone client ships: the relay capability is not advertised yet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the single-host health fixtures inline so the test tsc passes

Fixtures hoisted into consts widened their health maps to string, which the now-required
hostHealthById rejects under the tests-typecheck ratchet; passing them inline keeps the union.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state the desktop relay contract in its entry module

main dropped docs/reference, so the relay's contract lives beside the code. Also pins that a
server whose delegated sync fails (e.g. delegated_device_revoke_failed) is unavailable until a
later request syncs it again.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the relay's configured-server adapter

The production hosts adapter was only typechecked; this pins that it resolves configured servers
by exact id only (never a name or a removed server), fences by pairing revision and runtime
identity, and stops reporting retirements once unsubscribed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the repo-metadata goldens for SSH host health

The settled metadata state now carries hostHealthById, and an SSH-only catalog now reads the
target summaries so a lone unhealthy host can be badged. Scoped re-record via rpc-recording.mts.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): tighten the desktop relay after elegance review

Reuses the shared E2EE socket opener, folds relay state into fewer fields, never sends a request
whose link ended while opening, passes failure codes through, relays only this desktop's own
phones, and drops the unused host-state reader until S2 needs one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): bound relay frames and stop sticky relay verdicts

Restores the 8 MiB frame cap on every remote runtime socket, re-checks an update-needed server on
each new link so an in-place upgrade relays, answers a phone the server just declined without
syncing again, and ends a link whose mirrored capability update cannot be queued.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): run targeted desktop-owned calls on the desktop

The phone tags every call on a workspace screen with its execution host, so a
paired-desktop method carrying `runtime:<env>` now runs locally instead of
failing 'forbidden'. The Mac stays the only reader of the routing table.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): list the desktop's configured servers and their last workspace rows for the phone

The phone needs the same host list the desktop shows, including servers that are offline or
too old to relay to. mobileRelay.hosts.list/worktrees answer from the desktop's own status and
its own worktree.ps fetches, cached per server and fenced by pairing, never from relayed replies.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): list server rows through one desktop fetch, with no side effect on listing

The phone now always lists a server's rows through mobileRelay.hosts.worktrees, so the
desktop's own fetch is both the live source and the cache, and stamping stays in main.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fetch a server's full workspace list within a bound, and forget removed servers

The desktop now asks for the phone's full-catalog limit, answers within 5s with the last rows
when a server hangs, drops a server's rows when it is removed or re-paired, and checks the
server's identity after the fetch so a reply from before a re-pair is never served.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): name a server workspace's host on the phone's calls

A phone reaches a server's workspace through its desktop only if each call names the host; the
direct, cloud-relay and shell-bridge transports now carry `executionHost`, streams keep it for
replay and unsubscribe, and an older shell (no `executionHost` grant) refuses rather than strips it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): list managed servers as the desktop sidebar does, and hide rows mid-delete

A server Orca manages on an SSH host now lists once under that host's name, or not at all while
the host is reached over the SSH relay, using the sidebar's own folding. Rows the server is
deleting are left out at reply time, as a phone asking the server itself would see.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a disconnected server's last rows, and never join a re-paired server's fetch

Retirement also fires on a manual disconnect or managed-server update, which wrongly emptied
the phone's list; rows are now pruned only once a server is no longer configured. Fetches are
shared per server identity, so a poll after a re-pair asks the new server right away.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): list the paired desktop's server workspaces on the phone

Fixes the phone missing a desktop's `orca serve` workspaces: rows come from the desktop's
mobileRelay.hosts.* with each server's health ("Update needed", "Disconnected"), open through the
relay, follow the desktop's hidden hosts, and the desktop now advertises the relay capability.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a desktop failing the hosts list leaves only its own rows

The page asks a relaying desktop for its servers; a refusal such as method_not_found must leave
today's list with no server rows and no catalog error.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): mirror the desktop's shown hosts exactly, one host included

Jinwoo's call: the phone follows visibleWorkspaceHostIds as the desktop sidebar does, so a
one-host list narrows the phone too instead of being read as a single-host focus.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): scope a server workspace at its route, never the desktop

Every screen under a server workspace's route now reads one context and
gets that server's client and a router that keeps pushes on it, refusing
to fall back to the desktop; pins write to the row's owner like the Mac.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): never open an unreachable server workspace, and prove the relay in e2e

A server workspace the phone can't reach now gets one explicit screen at its route
and no row tap or action-sheet entry opens it; native routes read the route's host,
a census pins every workspace route, and the e2e reads files and git on the server.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): gate a server workspace on that server's own features

A targeted status.get now relays to the server, so a server workspace's route
provides that server's status to every screen under it and its action sheet,
while whether the phone can reach the server stays the desktop's answer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(ci): run the remote-runtime WebSocket opener test on Node

Under the bun vitest project, `ws` resolves to Bun's built-in client, which
ignores maxPayload, so the frame-cap test hung. The opener only ships on
Node/Electron, so run its test in the Node runtime project like its siblings.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give the file preview the workspace router, back to 128 page assets

The preview was the one workspace screen on the plain router, so the scoped router
was reached by a route set of its own and esbuild split it into an extra chunk
(129 assets); every workspace screen now uses it and it shares the route's chunk.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-relay): derive the phone allowlist from the routing table

Adding a phone method now takes one edit (its route). A server-targeted call on a
legacy non-E2EE socket is refused instead of running locally, and only
execution-host methods enter the relay.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-relay): drop unread freshness fields from server workspace lists

The phone reads only the rows, so stale, fetchedAt, totalCount and truncated
were dead wire weight. The desktop still serves a server's last rows while
the server is unreachable.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): read only the rows of a server's workspace list

The desktop no longer sends a freshness flag, so the phone's schema drops it.
The relay capability list names the delegated-devices constant instead of
repeating its literal, which also retires a now-circular type check.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): show a server workspace as disconnected while its server is down

The phone read the desktop's connection state for a server workspace, so a stopped
server kept a green dot and "Loading tabs" spun forever. The workspace route now
narrows that state by the server's health from mobileRelay.hosts.list, reusing the
existing disconnected session UI and refetching tabs when the server returns.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): share one desktop server-list feed between the host list and workspaces

A server workspace polled mobileRelay.hosts.list beside the host list's own read, so
the phone could ask the desktop twice per tick and the row badge and the screen could
disagree. One feed per desktop client now owns that read; both readers join it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(e2e): read a server's reachability from the hosts list, not a dropped stale flag

eff410fe30 dropped `stale` from mobileRelay.hosts.worktrees, so the mirror spec's
schema never parsed and its first poll timed out. It now waits on the server's
relay/health from mobileRelay.hosts.list, the field the phone itself reads.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* build(mobile): re-measure the page's script sweep for server workspaces

Main already sat on its 78-script ceiling (re-swept: 78, table: 74); the workspace
route's shared modules split importer sets three ways more, reaching 81. Re-swept per
route prefix on this head, as #24257 did for its feature; the spread is now 1 to 11.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): export the server-list feed's React reader beside the feed

useRelayHosts moves from workspace-server.ts into relay-hosts-feed.ts, so every reader (workspace screens now, the new-workspace sheet next) subscribes through the feed's own module. No behaviour change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep polling the server list and read a server's reachability from relay

A hosts-list read slower than the poll stopped the feed for good, since the next tick was
scheduled when the read started; it is now scheduled from the finish. A server workspace now
reads connected only when the desktop relays to it, so a server with no status yet is not shown up.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): show a reached server as connecting and a too-old one as disconnected

A server the desktop has reached but whose status is not answered yet is moments from relaying,
so it reads connecting; a server too old to relay to never will, so it reads disconnected.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(shared): move the account capabilities into their own group module

Main's merge put protocol-version.ts at 301 lines, over the 300 cap. The account group moves out
like the relay and skill groups did, same strings in the same order; the page's script sweep is
re-measured because the split changed its chunking (81 to 79 at 16 routes).

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): name a server workspace through the request envelope

The server name now rides the generic request envelope from #27815 instead of
its own executionHost option, bridge grant and per-transport plumbing. Server
rows show only where the transport carries an envelope (native, or a shell
that grants rpcEnvelope), so an older app keeps today's list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the server-call wire and the no-envelope deep link

A server workspace call still reaches the desktop as the same top-level
executionHost frame, on the direct and relay wires, and a deep link into a
server workspace under an app without rpcEnvelope shows the unreachable screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): read a relay stream's envelope slot in sorted key order

Equal envelopes name one cleanup slot whatever their key order, the scoped view
passes its client's envelope answer through as it does the generation, and the
direct wire harness is shared instead of rebuilt.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): a phone's new chat in a server workspace follows the desktop's chat setting

A relayed agent.launch ran on the server, which picked chat vs terminal from its
own setting. The desktop relay now stamps its own default as nativeChatDefault;
new servers use it in place of their setting (feasibility still decides), older
servers strip it. Outside the replay fingerprint.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(mobile): move the relay's delegated phone grant cache into its own module

Keeps mobile-desktop-relay.ts under the line limit once #27749's page relay lands on it.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mobile): show the early terminal tab from the relaying desktop's chat default

The early-tab preflight read the server's own chat setting while the launch
used the desktop's stamped nativeChatDefault, so on a windowed server with
chats off a relayed chat launch flashed a terminal tab. Both now read the
settings through readAgentLaunchModeSettings(runtime, nativeChatDefault).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 23:35:33 -04:00

371 lines
17 KiB
TypeScript

/**
* 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.
*
* The intent deliberately does NOT name a mode. A caller states what it wants to happen, not how
* to deliver it; picking structured vs terminal is the host's job and is reported back in the
* receipt rather than requested here.
*/
import type { TuiAgent } from './tui-agent'
/** 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<Record<string, unknown>> }
/** A folder workspace this launch creates. `create` is the `folderWorkspace.create` request. */
| { kind: 'create-folder-workspace'; create: Readonly<Record<string, unknown>> }
/** 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<Record<string, unknown>>
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<Record<string, string>>
/** `agent.launch`'s `nativeChatDefault`: the caller's chat default in place of the host's own. */
nativeChatDefault?: boolean
}
/**
* 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; every
* other member is a reason the preference could not be applied to this launch. */
export type AgentLaunchModeReason =
| 'user_default'
| '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 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<AgentLaunchResult>
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<AgentLaunchPlacementReceipt>
// 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<AgentLaunchModeReceipt>
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 extends Readonly<Record<string, unknown>>>(
create: Create
): Create {
const stripped: Record<string, unknown> = { ...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
}