Files
orca/src/shared/agent-launch-intent.ts
T
Brennan Benson 39a01f9c0b feat(agent-launch): create a folder workspace and start its agent in one host operation (#26083)
* feat(agent-launch): accept a create-folder-workspace target

agent.launch can now create a folder workspace and start its agent there,
the same operation it already runs for a new git worktree. The host
creates the folder workspace (metadata only, no startup terminal), resolves
it the way an existing target is resolved, then settles the mode and builds
the surface as for any existing folder workspace.

Hosts advertise agent.launch.create-folder-workspace.v1 so a client offers
the target only to a host that accepts it.

* fix(agent-launch): authorize, classify and navigate folder creates like their create method

A create-folder-workspace launch could do three things its two-call
equivalent never did:

- A phone could create folder workspaces. The mobile allowlist admits
  agent.launch but not folderWorkspace.create, and is checked by method
  name only. A create target now needs the authorization of the create
  method it stands for, read from the same allowlist, and a phone is
  refused with agent_launch_target_forbidden before admission, so no
  ledger row and no create. create-worktree maps to worktree.create,
  which phones already may call, so nothing changes for it.
- A create refused before anything was stored (group gone, folder
  missing, SSH host unreachable) replayed as
  agent_session_operation_unknown, and a retry under the same operation
  id was refused until the row expired. runtime.createFolderWorkspace now
  raises those refusals as FolderWorkspaceCreateRefusedError, with the
  same message text, and the launch records it as failed for any target.
  A failure after the create still replays unknown.
- A paired caller's folder create activated the new chat on the host
  window and did not select the caller's own tab. Only create-worktree,
  whose host activation runs setup, keeps the host navigation now; a
  folder create navigates like an existing workspace.

Tests cover the phone refusal, replay of a success, a refused create
settling failed and replaying that failure, an after-create failure
staying unknown, an SSH connection reaching the agent's view mode, paired
navigation, and an older host refusing the target at parse.

* fix(agent-launch): replay a refused folder create with its whole path

A folder create refused before anything was stored is recorded as the
launch's failure, and a retry under the same operation id answers from
that record. The record went through the 128-character cap every launch
failure code gets, and folder refusals carry the folder's absolute path,
so a retry could show the user a path cut short: a different folder.

The refusal is now recorded whole when it fits a path as long as Linux's
PATH_MAX after its code. Past that, which no folder a stat accepts
reaches, only the code before the path is kept, so the replay degrades to
the bare code instead of a partial path.

Also: the controller test asserts the exact refusal message, the
create-folder-workspace capability comment says a phone needs its own
gate, and the forbidden-target code says why it is not `forbidden`.

* test(folder-workspace): type the controller test's store stub without an assertion

The changed-code quality gate refuses the `as never` cast on the stub
store. It is now a RuntimeStore whose unreached members throw.

* test(agent-launch): run the folder-create launch tests in the Node runtime project

The operationId replay tests open the real SQLite record store, so the
file belongs with the other ledger-backed agent.launch tests.

* test(agent-launch): pin that a paired folder create's terminal stays off the host window

The folder-create test now asserts surfaceOwner: false on the terminal a
paired caller's launch creates, rather than matching any options. The
caller-selection header now says only an in-process worktree create keeps
worktree.create's navigation.

* test(agent-launch): drop the per-connection view-mode expectation main removed
2026-10-09 01:23:06 -07:00

373 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
}
/**
* 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'
}
export function agentLaunchTargetIsCreate(
target: AgentLaunchTarget
): target is Extract<AgentLaunchTarget, { kind: 'create-worktree' }> {
return target.kind === 'create-worktree'
}
/** 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
}