mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* feat(wsl): accounts shim for agents on a WSL orcad host (WSL-via-orcad C9) The desktop prepares the selected Claude/Codex account's in-distro home with the existing WSL account code and sends it as env on terminal.create / session.tabs.createTerminal, or as agent.launch's new optional accountEnv. Structured sessions keep their host-derived home and warn. Launch-time CLI resolution inside a WSL guest skips /mnt/<drive> shims. * refactor(wsl): track WSL host environments beside their registry
369 lines
17 KiB
TypeScript
369 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>>
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|