{{ message }}
Repository navigation
Expand file tree
/
Copy pathagent-launch-intent.ts
More file actions
373 lines (349 loc) · 17.4 KB
/
Copy pathagent-launch-intent.ts
File metadata and controls
373 lines (349 loc) · 17.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
/**
* 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<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
/** `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<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
}
You can’t perform that action at this time.
