@ahpd/agent-claude 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/session.ts ADDED
@@ -0,0 +1,2635 @@
1
+ import { createSdkMcpServer, query } from '@anthropic-ai/claude-agent-sdk';
2
+ import { z } from 'zod';
3
+ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk';
4
+ import { protectedResource, urlOf } from './mcp.js';
5
+ import type { ActiveTurn, McpServerState, ToolCallCompletedState, ToolCallRunningState, ToolResultContent, ToolResultTerminalContent, ToolResultTextContent } from '@microsoft/agent-host-protocol';
6
+ import { Status, idOf, tail } from '@ahpd/server';
7
+ import type { Bag, BoundTool, Chosen, OnWire, Session, SessionOptions, WireTurn } from '@ahpd/server';
8
+
9
+ /**
10
+ * The effort levels this backend has, weakest first.
11
+ *
12
+ * One list, because two of them drifted: a model's own `thinkingLevel` form
13
+ * and the session-wide `effortLevel` key are the same five words reaching the
14
+ * same setting, and a client that read one set of labels from one control and
15
+ * another set from the other is being told they are different things.
16
+ */
17
+ export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
18
+
19
+ /** What a person reads instead of an effort level. The reference client's words. */
20
+ export const EFFORT_LABELS: Record<typeof EFFORTS[number], string> = {
21
+ low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra High', max: 'Max',
22
+ };
23
+
24
+ /**
25
+ * RFC 9728 metadata for a server that needs signing in.
26
+ *
27
+ * `resource` is the one field the protocol requires of it - the canonical
28
+ * identifier a client's `authenticate` must name - so it is the one this
29
+ * spells out; the rest is whatever the server published.
30
+ */
31
+ export type Published = Bag & { resource: string };
32
+
33
+ /** What the SDK will accept as a session id of our choosing. */
34
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
35
+
36
+ /**
37
+ * One agent session, reduced into the state its channels hold.
38
+ *
39
+ * The agent SDK reports what happened as its own message stream; a host has to
40
+ * report the same events as AHP state actions. This module is that
41
+ * translation, and holds the resulting state for a subscription snapshot.
42
+ *
43
+ * Rules the protocol requires of anything emitting chat actions:
44
+ *
45
+ * - A response part must exist before text streams into it: emit
46
+ * `chat/responsePart` to create it, then `chat/delta` to append. A delta
47
+ * naming a part that was never opened appends to nothing.
48
+ * - The running turn is `activeTurn` and is not in `turns`. It moves into
49
+ * `turns` when it completes.
50
+ * - A turn carries both sides: `message.text` is what the person said,
51
+ * `responseParts` is what the agent answered.
52
+ * - The client starts turns. `chat/turnStarted` arrives from the client; the
53
+ * host reduces it and runs the agent.
54
+ */
55
+
56
+ const bag = (value: unknown): Bag => (typeof value === 'object' && value !== null ? value as Bag : {});
57
+ const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
58
+ const str = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined);
59
+
60
+ interface PendingInput {
61
+ id: string;
62
+ entry: Bag;
63
+ /** `AskUserQuestion` needs its own payload echoed back verbatim. */
64
+ questions?: unknown[];
65
+ /** Question id to the question text the SDK keys answers by. */
66
+ asked: Map<string, string>;
67
+ /**
68
+ * What somebody has typed so far, by question id.
69
+ *
70
+ * The protocol calls this the request's synced answer state: a client
71
+ * dispatches `chat/inputAnswerChanged` per question as it is filled in, and
72
+ * `chat/inputCompleted` may arrive with no answers at all because these are
73
+ * the answers. Held here rather than in a client so the other people in the
74
+ * session see the form being filled in.
75
+ */
76
+ answers: Map<string, Bag>;
77
+ settle(result: { behavior: 'allow'; updatedInput: Bag } | { behavior: 'deny'; message: string }): void;
78
+ }
79
+
80
+ /**
81
+ * What a tool call is *about*, in one line.
82
+ *
83
+ * The only thing separating twenty identical rows, so it is worth doing per
84
+ * tool: `Bash` is its command, the file tools are their path. A row reading
85
+ * `{"file_path":"/very/long/…","offset":0}` is a row nobody reads.
86
+ */
87
+ function summarize(name: string, input: Bag): string | undefined {
88
+ if (name === 'Bash') return str(input.command);
89
+ if (name === 'Read' || name === 'Write' || name === 'Edit') return str(input.file_path);
90
+ if (name === 'Glob' || name === 'Grep') return str(input.pattern);
91
+ if (name === 'Task' || name === 'Agent') return str(input.description);
92
+ return Object.keys(input).length > 0 ? JSON.stringify(input).slice(0, 400) : undefined;
93
+ }
94
+
95
+ function resultText(content: unknown): string | undefined {
96
+ if (typeof content === 'string') return content;
97
+ const parts = list(content).map((block) => str(bag(block).text)).filter((t): t is string => t !== undefined);
98
+ return parts.length > 0 ? parts.join('\n') : undefined;
99
+ }
100
+
101
+ /**
102
+ * What the session was handed, in the protocol's shape.
103
+ *
104
+ * Eight `CustomizationType`s and one flat list. `disableUserInvocation` is
105
+ * what decides whether a skill or prompt appears after a slash - offering one
106
+ * the host will refuse is worse than not offering it at all.
107
+ *
108
+ * The source is the CLI's *control* protocol, not its message stream:
109
+ * `initializationResult()` and `mcpServerStatus()` answer without a turn
110
+ * having happened. That matters because everything here is what a client
111
+ * needs **before** anybody says anything - the models to pick from, the
112
+ * commands behind a slash. Waiting for the `init` message would mean a
113
+ * composer that can only offer them once the conversation has started, which
114
+ * is exactly too late.
115
+ */
116
+ export function customizationsOf(init: Bag, mcp: unknown[], skills: unknown[] = [], wanted?: Map<string, Published>): Bag[] {
117
+ const out: Bag[] = [];
118
+
119
+ /*
120
+ * Where a customization of each kind lives, and the container it goes in.
121
+ *
122
+ * A top-level `Customization` is a *container* - a plugin or a directory -
123
+ * whose leaves are its `children`, or a bare MCP server. Skills, prompts and
124
+ * agents are `ChildCustomization`s and belong inside one. Published flat
125
+ * they are read as plugins, and the reference client then walks
126
+ * `<uri>/agents`, `<uri>/skills`, `<uri>/commands` and `<uri>/rules` looking
127
+ * for their contents - four failed reads per customization, against a `uri`
128
+ * that was a bare name rather than anything a filesystem could answer.
129
+ *
130
+ * One container per kind, because `contents` names a single
131
+ * `ChildCustomizationType`. The directory is the conventional one for that
132
+ * kind - the CLI reports *what* it loaded and never where it came from, so
133
+ * this is where a person would go to add one rather than a path this host
134
+ * read off disk. A built-in the CLI ships has no file of its own and the
135
+ * path under it will not exist; nothing dereferences it, because a client
136
+ * reads a directory's `children` rather than walking it.
137
+ */
138
+ const home = process.env.HOME ?? '';
139
+ const folder = (kind: string): string => `file://${home}/.claude/${kind}`;
140
+ const container = (kind: string, contents: string, children: Bag[]): Bag | undefined =>
141
+ (children.length === 0 ? undefined : {
142
+ type: 'directory',
143
+ id: `directory:${kind}`,
144
+ uri: folder(kind),
145
+ name: kind,
146
+ contents,
147
+ enabled: true,
148
+ // The person's own directory, so a client may offer to write one.
149
+ writable: true,
150
+ children,
151
+ });
152
+ const asSkills: Bag[] = [];
153
+ const asPrompts: Bag[] = [];
154
+ const asAgents: Bag[] = [];
155
+
156
+ /*
157
+ * Which of the commands are skills, and which skills a person can invoke.
158
+ *
159
+ * The CLI hands out two lists that overlap and neither says which is which:
160
+ * `commands` is what a slash offers, `skills` is what was loaded from disk.
161
+ * A command in both is a skill; one in `commands` alone is a built-in
162
+ * prompt. And a skill the CLI did *not* put behind a slash is one it will
163
+ * not let a person invoke - which is the agent-only skill the protocol has
164
+ * `disableUserInvocation` for, read off the CLI's own two answers rather
165
+ * than guessed from a name.
166
+ */
167
+ const offered = new Map(list(init.commands)
168
+ .map((raw) => [str(bag(raw).name) ?? '', bag(raw)] as const)
169
+ .filter(([name]) => name !== ''));
170
+ const loaded = new Map(list(skills)
171
+ .map((raw) => [str(bag(raw).name) ?? '', bag(raw)] as const)
172
+ .filter(([name]) => name !== ''));
173
+
174
+ for (const [name, skill] of loaded) {
175
+ const command = offered.get(name);
176
+ const described = str(skill.description) ?? str(bag(command).description);
177
+ const hint = str(skill.argumentHint) ?? str(bag(command).argumentHint);
178
+ asSkills.push({
179
+ type: 'skill',
180
+ id: `skill:${name}`,
181
+ name,
182
+ uri: `${folder('skills')}/${name}`,
183
+ enabled: true,
184
+ ...(command ? {} : { disableUserInvocation: true }),
185
+ ...(described ? { description: described } : {}),
186
+ // Under `_meta` for the reason the session's model is: `SkillCustomization`
187
+ // declares `description` and the two `disable*` flags and nothing else,
188
+ // so an argument hint sent beside them is this host's own extension.
189
+ ...(hint ? { _meta: { argumentHint: hint } } : {}),
190
+ });
191
+ }
192
+
193
+ for (const [name, command] of offered) {
194
+ if (loaded.has(name)) continue;
195
+ asPrompts.push({
196
+ type: 'prompt',
197
+ id: `command:${name}`,
198
+ name,
199
+ uri: `${folder('commands')}/${name}.md`,
200
+ enabled: true,
201
+ ...(str(command.description) ? { description: str(command.description) as string } : {}),
202
+ ...(str(command.argumentHint) ? { argumentHint: str(command.argumentHint) as string } : {}),
203
+ });
204
+ }
205
+
206
+ for (const raw of list(init.agents)) {
207
+ const found = bag(raw);
208
+ const name = str(found.name);
209
+ if (!name) continue;
210
+ asAgents.push({
211
+ type: 'agent',
212
+ id: `agent:${name}`,
213
+ name,
214
+ uri: `${folder('agents')}/${name}.md`,
215
+ enabled: true,
216
+ ...(str(found.description) ? { description: str(found.description) as string } : {}),
217
+ });
218
+ }
219
+
220
+ for (const found of [
221
+ container('skills', 'skill', asSkills),
222
+ container('commands', 'prompt', asPrompts),
223
+ container('agents', 'agent', asAgents),
224
+ ]) {
225
+ if (found) out.push(found);
226
+ }
227
+
228
+ // Bare, and correctly so: an MCP server is the one leaf the protocol lets a
229
+ // session surface at the top level without a container around it.
230
+ for (const raw of mcp) {
231
+ const server = bag(raw);
232
+ const name = str(server.name);
233
+ if (!name) continue;
234
+ const reported = str(server.status);
235
+ const said = str(server.error);
236
+ /*
237
+ * The state, in the shape the kind it claims actually requires.
238
+ *
239
+ * The protocol's words, not the SDK's: the CLI says `connected` and
240
+ * `failed`, a client reads `ready` and `error`. Each kind carries
241
+ * different fields and only `error` carries any - `ready`, `starting` and
242
+ * `stopped` are `{ kind }` and nothing else, and `error` needs a whole
243
+ * `ErrorInfo` rather than the bare `message` this used to send.
244
+ *
245
+ * A server that needs signing in is `authRequired`, carrying the protected
246
+ * resource it published. Discovered rather than invented: the server's own
247
+ * URL is the canonical resource identifier the MCP authorization spec
248
+ * names, and `<url>/.well-known/oauth-protected-resource` is where the
249
+ * authorization server is announced. A stdio server has no URL and so no
250
+ * resource to describe, and stays an error - which is the honest answer
251
+ * for a thing a client cannot sign into over the network.
252
+ */
253
+ const published = wanted?.get(name);
254
+ const state: OnWire<McpServerState> = reported === 'connected' ? { kind: 'ready' }
255
+ : reported === 'disabled' ? { kind: 'stopped' }
256
+ : reported === 'failed'
257
+ ? {
258
+ kind: 'error',
259
+ error: { errorType: 'mcpServerFailed', message: said ?? 'The server did not start.' },
260
+ }
261
+ : reported === 'needs-auth'
262
+ ? (published !== undefined
263
+ ? {
264
+ kind: 'authRequired',
265
+ reason: 'required',
266
+ resource: published,
267
+ ...(Array.isArray(published.scopes_supported) && published.scopes_supported.length > 0
268
+ ? { requiredScopes: published.scopes_supported.filter((one): one is string => typeof one === 'string') }
269
+ : {}),
270
+ ...(said !== undefined ? { description: said } : {}),
271
+ }
272
+ : {
273
+ kind: 'error',
274
+ error: {
275
+ errorType: 'mcpAuthRequired',
276
+ message: said ?? 'This server needs signing in, and it did not say where.',
277
+ },
278
+ })
279
+ : { kind: 'starting' };
280
+ out.push({
281
+ type: 'mcpServer',
282
+ id: `mcp:${name}`,
283
+ name,
284
+ uri: name,
285
+ /*
286
+ * `enablement`, not `enabled`.
287
+ *
288
+ * An MCP server is the one customization the protocol does not give a
289
+ * flat flag: it carries the decision per scope, most specific first,
290
+ * and a consumer reads `enablement[0].enabled`. This host decides at
291
+ * one scope - the session's - because that is where a CLI's answer
292
+ * about a server applies.
293
+ *
294
+ * Off the CLI's own word rather than off the kind above, so a server
295
+ * that needs signing in stays switched *on* - it is enabled and
296
+ * unreachable, which is not the same as somebody having turned it off.
297
+ */
298
+ enablement: [{ kind: 'session', enabled: reported !== 'failed' && reported !== 'disabled' }],
299
+ state,
300
+ });
301
+ }
302
+
303
+ return out;
304
+ }
305
+
306
+ /**
307
+ * A permission mode a client asked for in somebody else's vocabulary.
308
+ *
309
+ * This backend advertises `permissionMode` and its own four values, which is
310
+ * what the protocol asks a backend to do - the config schema is deliberately
311
+ * generic, and VS Code's own hosts advertise different properties for Copilot
312
+ * and for Claude. So the schema stays this harness's.
313
+ *
314
+ * What arrives is another matter. A client draws controls from the schema and
315
+ * *also* dispatches two conventional keys of its own: `autoApprove` (how much
316
+ * may run unasked) and `mode` (how the agent works). VS Code sends both at
317
+ * session creation whatever a host advertises, and this host used to answer
318
+ * `autoApprove is not a config key this backend takes` and leave the session
319
+ * where it was.
320
+ *
321
+ * So they are accepted and mapped here, on the way in, and nothing about what
322
+ * is advertised changes. Planning wins over any approval level - a plan that
323
+ * ran a command would not be a plan - and `autopilot` is the mode axis saying
324
+ * what `autoApprove` says at its top, which is why VS Code's own migration
325
+ * moved `autoApprove: 'autopilot'` onto that axis.
326
+ *
327
+ * `assisted` is the inexact one: VS Code means "assess the risk first" and
328
+ * this harness has no risk model, so it gets `acceptEdits`, which is the rung
329
+ * it does have in that place.
330
+ *
331
+ * Undefined for a key or a value neither axis knows, so a caller refuses it
332
+ * rather than collapsing it into `default`.
333
+ */
334
+ export function permissionFor(key: string, value: string): PermissionMode | undefined {
335
+ if (key === 'mode') {
336
+ if (value === 'plan') return 'plan';
337
+ if (value === 'autopilot') return 'bypassPermissions';
338
+ if (value === 'interactive') return 'default';
339
+ return undefined;
340
+ }
341
+ if (key !== 'autoApprove') return undefined;
342
+ if (value === 'autoApprove' || value === 'autopilot') return 'bypassPermissions';
343
+ if (value === 'assisted') return 'acceptEdits';
344
+ if (value === 'default') return 'default';
345
+ return undefined;
346
+ }
347
+
348
+ /**
349
+ * A `permissions` value, if it is one.
350
+ *
351
+ * `undefined` for anything else, which is what makes `setConfig` able to
352
+ * refuse: a client sending a string where the schema says an object should
353
+ * hear that the value was not taken rather than have it quietly ignored.
354
+ */
355
+ const listsOf = (value: unknown): { allow: string[]; deny: string[] } | undefined => {
356
+ if (typeof value !== 'object' || value === null) return undefined;
357
+ const held = value as { allow?: unknown; deny?: unknown };
358
+ const names = (one: unknown): string[] =>
359
+ (Array.isArray(one) ? one : []).filter((entry): entry is string => typeof entry === 'string');
360
+ if (held.allow === undefined && held.deny === undefined) return undefined;
361
+ return { allow: names(held.allow), deny: names(held.deny) };
362
+ };
363
+
364
+ /**
365
+ * One property of a tool's input schema, as the zod the SDK asks for.
366
+ *
367
+ * `createSdkMcpServer` takes a zod raw shape and turns it back into JSON
368
+ * Schema for the model, so a definition written as JSON Schema - which is
369
+ * what the protocol declares - has to make the round trip. Only the shapes a
370
+ * tool argument is: everything else is a string, which is what an unschema'd
371
+ * argument would have been anyway.
372
+ */
373
+ const shaped = (property: object): z.ZodTypeAny => {
374
+ const kind = str((property as Bag).type);
375
+ const of = (property as Bag).items;
376
+ if (kind === 'number' || kind === 'integer') return z.number();
377
+ if (kind === 'boolean') return z.boolean();
378
+ if (kind === 'array') return z.array(of === undefined ? z.string() : shaped(bag(of)));
379
+ return z.string();
380
+ };
381
+
382
+ /**
383
+ * The host's tools, as an in-process MCP server the CLI can call.
384
+ *
385
+ * In-process: `createSdkMcpServer` registers the handlers here rather than
386
+ * spawning anything, so a host tool is a function call. The result is handed
387
+ * back as text, because that is the one content shape every model reads and
388
+ * a host tool answering with anything richer would be answering in a shape
389
+ * this host cannot check.
390
+ */
391
+ const contributed = (
392
+ tools: BoundTool[],
393
+ /** Hand a call to the client that provides it, and wait for what it says. */
394
+ byClient: (tool: BoundTool, input: Bag) => Promise<{ text: string; ok: boolean }>,
395
+ ): unknown => createSdkMcpServer({
396
+ name: 'ahp',
397
+ version: '1.0.0',
398
+ tools: tools.map((one) => {
399
+ const schema = one.definition.inputSchema;
400
+ const shape: Record<string, z.ZodTypeAny> = {};
401
+ for (const [key, property] of Object.entries(schema?.properties ?? {})) {
402
+ const value = shaped(property);
403
+ shape[key] = (schema?.required ?? []).includes(key) ? value : value.optional();
404
+ }
405
+ return {
406
+ name: one.definition.name,
407
+ description: one.definition.description ?? one.definition.title ?? one.definition.name,
408
+ inputSchema: shape,
409
+ ...(one.definition.annotations ? { annotations: one.definition.annotations } : {}),
410
+ handler: async (input: Record<string, unknown>) => {
411
+ /*
412
+ * Somebody else's tool, run where it lives.
413
+ *
414
+ * A client that announced this one is the only thing that can run it -
415
+ * it is the editor's own command, or a plugin's - so the call goes out
416
+ * against that client and this waits. The wait is what makes the model
417
+ * see a tool at all: an MCP handler that returned before the answer
418
+ * came back would be answering on the client's behalf.
419
+ */
420
+ if (one.owner !== undefined) {
421
+ const answer = await byClient(one, input);
422
+ return {
423
+ content: [{ type: 'text' as const, text: answer.text }],
424
+ ...(answer.ok ? {} : { isError: true }),
425
+ };
426
+ }
427
+ try {
428
+ return { content: [{ type: 'text' as const, text: await one.run?.(input) ?? '' }] };
429
+ }
430
+ catch (error: unknown) {
431
+ // The message, not a throw: an MCP tool that rejects is a transport
432
+ // failure, and a tool that could not do the thing is an answer.
433
+ return {
434
+ content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
435
+ isError: true,
436
+ };
437
+ }
438
+ },
439
+ };
440
+ }),
441
+ }) as unknown;
442
+
443
+ export function createSession(options: SessionOptions): Session {
444
+ const { uri, chatUri, cwd, emit } = options;
445
+
446
+ const turns: Bag[] = [...(options.seed ?? [])];
447
+ let active: Bag | undefined;
448
+ /**
449
+ * Everything the agent is waiting on, by request id.
450
+ *
451
+ * A map because a turn can ask twice at once. The CLI calls `canUseTool`
452
+ * per tool call and an agent that fires two in parallel produces two live
453
+ * questions - this used to be a single slot, so the second overwrote the
454
+ * first, the first's `settle` became unreachable and that tool waited for
455
+ * an answer no one could give any more. Approving the surviving one then
456
+ * did nothing, because the id no longer matched.
457
+ *
458
+ * The protocol has always modelled it this way: `inputNeeded` is a list and
459
+ * `session/inputNeededSet` says it adds or updates *matched by id*.
460
+ */
461
+ const pending = new Map<string, PendingInput>();
462
+ /**
463
+ * The tools this session has already been told about, by name.
464
+ *
465
+ * Held here as well as handed to the SDK, because the SDK takes them when
466
+ * the query is built: a list changed on a running session reaches the agent
467
+ * only through `canUseTool`, which is the one place this host sits between
468
+ * the two.
469
+ */
470
+ let allowed = listsOf(options.settings?.permissions) ?? { allow: [], deny: [] };
471
+ let title = str(bag(bag((options.seed ?? [])[0]).message).text)?.slice(0, 60) || 'New session';
472
+ let modified = new Date().toISOString();
473
+ /**
474
+ * Why the *last* turn failed, or nothing.
475
+ *
476
+ * About one turn, not about the session for the rest of its life. It reads
477
+ * into `Status.Error` and into the summary's `error`, and it used to be set
478
+ * and never unset - so one failed tool call left every client showing a
479
+ * session in error through every turn that followed, and through a restart
480
+ * of the client, because the flag lives here rather than there. Starting a
481
+ * turn supersedes it: what went wrong last time is not what is happening
482
+ * now.
483
+ */
484
+ let failed: string | undefined;
485
+ let startedAt = 0;
486
+ /**
487
+ * The model the turn now running actually answered on, as its own frames
488
+ * reported it.
489
+ *
490
+ * Not the one configured: a session may be set to `sonnet` and a turn may
491
+ * run on whatever that resolved to on the day, and the protocol asks for
492
+ * the model a turn *was* answered by. A client reads it to name the model
493
+ * on a historic turn and to size the context window that turn used.
494
+ */
495
+ let ran: string | undefined;
496
+ let handshake: Bag | undefined;
497
+ /**
498
+ * The id the agent gave this session, which is not the URI it is served at.
499
+ *
500
+ * The client picks the URI before anything exists; the CLI picks its own id
501
+ * when it starts and writes the transcript under that. Both name the same
502
+ * conversation, so the catalogue has to know they do - otherwise the row on
503
+ * disk and the row in memory are two sessions saying the same thing.
504
+ */
505
+ let agentId: string | undefined = options.resume;
506
+ /** What the session is doing, in one line, or nothing when it is idle. */
507
+ let activity: string | undefined;
508
+ /**
509
+ * Messages waiting for the running turn to end.
510
+ *
511
+ * The host's, not a client's. A client that held them would be the only
512
+ * thing that could ever send them, and would not - nothing in a client is
513
+ * watching for a turn to end - and a second client watching the same chat
514
+ * would not see them at all.
515
+ */
516
+ const queued: Bag[] = [];
517
+ /**
518
+ * What somebody is part-way through typing.
519
+ *
520
+ * Held here so two people on one session see each other's, which is the
521
+ * only reason a draft is on the wire at all - a client that kept its own
522
+ * would need nothing from a host for it.
523
+ */
524
+ let draft: Bag | undefined;
525
+ let customizations: Bag[] = [...(options.seedCustomizations ?? [])];
526
+ let offered: { id: string; name: string }[] = [];
527
+ /** What the client picked. Absent means whatever the CLI defaults to. */
528
+ let chosen: string | undefined;
529
+ /** The config in force, by key. What `session/configChanged` merges into. */
530
+ /*
531
+ * What this session was told to run as.
532
+ *
533
+ * `unknown` and not `string`, because the protocol declares a config bag
534
+ * `Record<string, unknown>` and `permissions` is an object. Keys this
535
+ * backend declared a string are narrowed where they are read.
536
+ */
537
+ const settings: Record<string, unknown> = { permissionMode: 'default', ...options.settings };
538
+
539
+ /**
540
+ * The MCP server a tool belongs to, out of its name.
541
+ *
542
+ * `mcp__<server>__<tool>` is the CLI's own naming, and it is the only thing
543
+ * that says a call is somebody else's server's rather than the harness's -
544
+ * which is what `ToolCallMcpContributor` records and what makes a call
545
+ * blocked on a sign-in tellable from one blocked on its own work.
546
+ */
547
+ const serverOf = (toolName: string): string | undefined => /^mcp__(.+?)__/.exec(toolName)?.[1];
548
+
549
+ /*
550
+ * The tools on offer, which is not a fixed list.
551
+ *
552
+ * The host's own are settled when the session is built; a client's arrive
553
+ * when it announces itself and go when it leaves. So this is held rather
554
+ * than read from `options` once, and `setTools` re-declares the server the
555
+ * model reaches them through.
556
+ */
557
+ let offering: BoundTool[] = [...(options.tools ?? [])];
558
+ /** The full name the CLI calls a contributed tool by. */
559
+ const called = (name: string): string => `mcp__ahp__${name}`;
560
+ /** Which client provides a tool, by the name the CLI calls it. */
561
+ const providedBy = (toolName: string): string | undefined =>
562
+ offering.find((one) => called(one.definition.name) === toolName)?.owner;
563
+
564
+ /*
565
+ * Joining the call the model made to the handler that has to answer it.
566
+ *
567
+ * The two arrive separately and neither carries the other's name: the
568
+ * assistant frame opens the call under the CLI's id, and the in-process MCP
569
+ * handler is invoked with the input and nothing else - the SDK surfaces a
570
+ * `toolUseID` to `canUseTool` and to hooks, and not to a tool. So they are
571
+ * matched here, by tool name and then by the input itself, which tells two
572
+ * concurrent calls of one tool apart. Whichever arrives first waits for the
573
+ * other.
574
+ */
575
+ const unclaimed = new Map<string, { id: string; input: string }[]>();
576
+ const expecting = new Map<string, ((id: string) => void)[]>();
577
+
578
+ const opening = (toolName: string, id: string, input: Bag): void => {
579
+ const waiting = expecting.get(toolName) ?? [];
580
+ const first = waiting.shift();
581
+ expecting.set(toolName, waiting);
582
+ if (first) { first(id); return; }
583
+ unclaimed.set(toolName, [...(unclaimed.get(toolName) ?? []), { id, input: JSON.stringify(input) }]);
584
+ };
585
+
586
+ const claim = (toolName: string, input: Bag): Promise<string> => {
587
+ const open = unclaimed.get(toolName) ?? [];
588
+ const written = JSON.stringify(input);
589
+ const at = open.findIndex((one) => one.input === written);
590
+ const took = at >= 0 ? open.splice(at, 1)[0] : open.shift();
591
+ unclaimed.set(toolName, open);
592
+ if (took !== undefined) return Promise.resolve(took.id);
593
+ return new Promise((resolve) => {
594
+ expecting.set(toolName, [...(expecting.get(toolName) ?? []), resolve]);
595
+ });
596
+ };
597
+
598
+ /**
599
+ * Calls a client is running for this session, by call id.
600
+ *
601
+ * Held for the same reason `pending` is: the thing that has to settle them
602
+ * arrives later and from somewhere else, and anything that ends the turn has
603
+ * to settle them itself or the CLI waits for ever on a promise nobody owns.
604
+ */
605
+ const byClient = new Map<string, { owner: string; settle: (answer: { text: string; ok: boolean }) => void }>();
606
+
607
+ /** Every outstanding client call, answered the same way and forgotten. */
608
+ const releaseCalls = (why: string, whose?: string): void => {
609
+ for (const [id, held] of [...byClient.entries()]) {
610
+ if (whose !== undefined && held.owner !== whose) continue;
611
+ byClient.delete(id);
612
+ held.settle({ text: why, ok: false });
613
+ }
614
+ };
615
+
616
+ const ranByClient = async (tool: BoundTool, input: Bag): Promise<{ text: string; ok: boolean }> => {
617
+ const id = await claim(called(tool.definition.name), input);
618
+ const owner = tool.owner ?? '';
619
+ doing(`Waiting on ${owner}: ${tool.definition.title ?? tool.definition.name}`);
620
+ return await new Promise((settle) => { byClient.set(id, { owner, settle }); });
621
+ };
622
+ /**
623
+ * Tool calls running against an MCP server, by call id.
624
+ *
625
+ * Kept so a server that starts asking for a sign-in can say *which* calls
626
+ * are stuck on it: the CLI reports a server's status and never a call's, so
627
+ * the join is here or nowhere.
628
+ */
629
+ const onServer = new Map<string, { server: string; turnId: string; blocked: boolean }>();
630
+
631
+ /** Open parts, keyed by message and index; tool calls by their own id. */
632
+ const parts = new Map<string, Bag>();
633
+ /**
634
+ * Which tool call a streaming content block belongs to.
635
+ *
636
+ * A `content_block_delta` names the block by its index and nothing else, so
637
+ * the id the block opened with has to be kept beside it. Tool calls only:
638
+ * prose parts are already keyed by the same index.
639
+ */
640
+ const calling = new Map<string, string>();
641
+ let streaming: string | undefined;
642
+
643
+ // The input stream. A query with a live stream stays open between turns,
644
+ // which is what makes a session a session rather than a series of them.
645
+ const waiting: { type: 'user'; message: { role: 'user'; content: string }; parent_tool_use_id: null }[] = [];
646
+ let wake: (() => void) | undefined;
647
+ let closed = false;
648
+
649
+ /**
650
+ * The directories beside `cwd`, as this session currently has them.
651
+ *
652
+ * Mutable because a client may add and remove peers on a running session;
653
+ * `cwd` itself never moves, which is what the protocol's `immutablePrimary`
654
+ * says and what the SDK enforces anyway.
655
+ */
656
+ let peers = [...(options.additional ?? [])];
657
+
658
+ /**
659
+ * The MCP servers this session declared, by name, as it declared them.
660
+ *
661
+ * Kept because re-declaring one means sending the whole set back: the SDK
662
+ * replaces its dynamic servers with what it is given, so a set rebuilt from
663
+ * one server would take the others away.
664
+ */
665
+ const declared: Record<string, Bag> = { ...(options.mcpServers ?? {}) };
666
+ /*
667
+ * The host's own tools, as an MCP server the CLI does not have to find.
668
+ *
669
+ * `createSdkMcpServer` runs in this process rather than spawning anything,
670
+ * so a host tool is a function call and not a subprocess. Named `ahp`
671
+ * because that is what a client sees the tools attributed to. Declared once
672
+ * at construction so `setMcpServers` keeps it: that call replaces the whole
673
+ * set, and a set rebuilt without this would take the host's tools away.
674
+ */
675
+ if (offering.length > 0) declared.ahp = contributed(offering, ranByClient) as Bag;
676
+
677
+ /** What each server that needs signing in published about itself, by name. */
678
+ const wanted = new Map<string, Published>();
679
+
680
+ /**
681
+ * Ask each server that needs signing in where to sign in.
682
+ *
683
+ * Only the remote ones: a stdio server has no URL, so there is no protected
684
+ * resource to describe and it stays an error. Cached by name, because the
685
+ * status is re-read on every refresh and the metadata does not move.
686
+ */
687
+ const discover = async (servers: unknown[]): Promise<void> => {
688
+ await Promise.all(servers.map(async (raw) => {
689
+ const server = bag(raw);
690
+ const name = str(server.name);
691
+ if (name === undefined || str(server.status) !== 'needs-auth' || wanted.has(name)) return;
692
+ const url = urlOf(declared[name] ?? server.config);
693
+ if (url === undefined) return;
694
+ wanted.set(name, await protectedResource(url, name).catch(() => ({ resource: url, resource_name: name })) as Published);
695
+ }));
696
+ };
697
+
698
+
699
+ /**
700
+ * A steering message, for as long as it is waiting to be read.
701
+ *
702
+ * The protocol's `ChatState.steeringMessage` is "a message to inject into
703
+ * the current turn at a convenient point", and the convenient point is when
704
+ * the CLI next reads its prompt. Between the two there is a real window - a
705
+ * turn mid-tool-call has not read anything for some time - and this is what
706
+ * fills it. Cleared where the generator hands the message over, because that
707
+ * is the moment it stops waiting.
708
+ */
709
+ let steering: Bag | undefined;
710
+
711
+ async function* input(): AsyncGenerator<(typeof waiting)[number]> {
712
+ for (;;) {
713
+ while (waiting.length > 0) {
714
+ const next = waiting.shift() as (typeof waiting)[number];
715
+ yield next;
716
+ if (steering !== undefined) {
717
+ const said = steering;
718
+ steering = undefined;
719
+ emit('chat', { type: 'chat/pendingMessageRemoved', kind: 'steering', id: String(said.id ?? '') });
720
+ touch();
721
+ }
722
+ }
723
+ if (closed) return;
724
+ await new Promise<void>((resolve) => { wake = resolve; });
725
+ }
726
+ }
727
+
728
+ const touch = (): void => { modified = new Date().toISOString(); };
729
+
730
+ /**
731
+ * Say what it is doing now, if that has changed.
732
+ *
733
+ * On both channels: the chat is where the work happens, and the protocol
734
+ * says a session mirrors its default chat's activity - which is the one a
735
+ * catalogue row and a detail pane read.
736
+ */
737
+ const doing = (said: string | undefined): void => {
738
+ if (activity === said)
739
+ return;
740
+ activity = said;
741
+ emit('chat', { type: 'chat/activityChanged', ...(said !== undefined ? { activity: said } : {}) });
742
+ emit('session', { type: 'session/activityChanged', ...(said !== undefined ? { activity: said } : {}) });
743
+ };
744
+
745
+ /** One line for a tool that is running. The name alone says too little. */
746
+ /**
747
+ * The file a tool is about to change, if it is one of the tools that do.
748
+ *
749
+ * Named tools rather than a guess at the input: a tool called `Bash` may
750
+ * write a file too, and there is nothing in `rm -rf` that says which. What
751
+ * this misses is honest - a changeset that claimed a file it could not name
752
+ * would be worse than one that says nothing about it.
753
+ */
754
+ const edits = (name: string, input: Bag): string | undefined => {
755
+ const known = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'];
756
+ if (!known.includes(name)) return undefined;
757
+ const path = str(input.file_path) ?? str(input.notebook_path);
758
+ return path === '' ? undefined : path;
759
+ };
760
+
761
+ const busyWith = (name: string, input: Bag): string => {
762
+ const what = summarize(name, input);
763
+ return (what ? `${name} ${what}` : name).replace(/\s+/g, ' ').slice(0, 80);
764
+ };
765
+
766
+ /** Retitle, and say so: a client that opened the session holds the old one. */
767
+ const retitle = (said: string): void => {
768
+ if (said === '' || said === title)
769
+ return;
770
+ title = said;
771
+ emit('session', { type: 'session/titleChanged', title });
772
+ };
773
+
774
+ /**
775
+ * The SDK's token counts, in the protocol's spelling.
776
+ *
777
+ * Every field is optional on both sides, so anything missing is left out
778
+ * rather than reported as zero - a nought is a measurement and an absence
779
+ * is not.
780
+ */
781
+ const usageOf = (raw: unknown, model?: string): Bag | undefined => {
782
+ const found = bag(raw);
783
+ const num = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined);
784
+ const info: Bag = {
785
+ ...(num(found.input_tokens) !== undefined ? { inputTokens: num(found.input_tokens) } : {}),
786
+ ...(num(found.output_tokens) !== undefined ? { outputTokens: num(found.output_tokens) } : {}),
787
+ ...(num(found.cache_read_input_tokens) !== undefined ? { cacheReadTokens: num(found.cache_read_input_tokens) } : {}),
788
+ ...(model !== undefined ? { model } : {}),
789
+ };
790
+ return Object.keys(info).length > 0 ? info : undefined;
791
+ };
792
+
793
+ const status = (): number => (pending.size > 0 ? Status.InputNeeded
794
+ : active ? Status.InProgress
795
+ : failed ? Status.Error
796
+ : Status.Idle);
797
+
798
+ /** The session-level summary of what is wanted. Set with the tool call, cleared with it. */
799
+ /*
800
+ * One request at a time, named by its id.
801
+ *
802
+ * `session/inputNeededSet` carries `request` and adds or updates the entry
803
+ * with that id; `session/inputNeededRemoved` carries the `id` to drop. This
804
+ * sent `inputNeeded: [entry]` and a bare removal, so a client reducing the
805
+ * actions could neither add the second question nor tell which one had been
806
+ * answered.
807
+ */
808
+ const inputNeededSet = (entry: Bag): void => {
809
+ emit('session', { type: 'session/inputNeededSet', request: entry });
810
+ };
811
+ const inputNeededRemoved = (id: string): void => {
812
+ emit('session', { type: 'session/inputNeededRemoved', id });
813
+ };
814
+
815
+ // ------------------------------------------------------------- translation
816
+
817
+ const openTurn = (): Bag => {
818
+ if (active) return active;
819
+ // A turn the client did not begin: the agent spoke first, which happens on
820
+ // a resumed session. Better an id of our own than a turn with none.
821
+ // `usage` is required on an `ActiveTurn` and means "not measured yet".
822
+ // Leaving the key off put a turn on the wire that did not satisfy its own
823
+ // type, which nothing here would have noticed.
824
+ active = {
825
+ id: `turn-${Date.now()}`,
826
+ startedAt: new Date().toISOString(),
827
+ // The agent spoke first, so the message in front of this turn is its
828
+ // own. `Message.origin` is required and used to be left off entirely.
829
+ message: { text: '', origin: { kind: 'agent' } },
830
+ responseParts: [],
831
+ usage: undefined,
832
+ } satisfies WireTurn<ActiveTurn> as Bag;
833
+ startedAt = Date.now();
834
+ failed = undefined;
835
+ emit('chat', {
836
+ type: 'chat/turnStarted',
837
+ turnId: active.id,
838
+ startedAt: active.startedAt,
839
+ message: { text: '', origin: { kind: 'agent' } },
840
+ });
841
+ return active;
842
+ };
843
+
844
+ /** Prose: the part is announced, then filled by deltas. */
845
+ const addPart = (turn: Bag, part: Bag): void => {
846
+ (turn.responseParts as Bag[]).push(part);
847
+ emit('chat', { type: 'chat/responsePart', turnId: turn.id, part });
848
+ };
849
+
850
+ /**
851
+ * A tool call: held for the snapshot, and announced by `chat/toolCallStart`.
852
+ *
853
+ * That action *creates* the response part on the client side, so sending
854
+ * `chat/responsePart` for one as well puts the same call in the transcript
855
+ * twice - once as this host's part and once as the reducer's own.
856
+ */
857
+ const holdPart = (turn: Bag, part: Bag): void => {
858
+ (turn.responseParts as Bag[]).push(part);
859
+ };
860
+
861
+ /**
862
+ * Why a turn stopped, as a part of it.
863
+ *
864
+ * 0.9.0 took `error` off `Turn` and gave the reason a response part instead,
865
+ * which is the better home for it: what the agent said before it failed
866
+ * still stands, and the failure belongs after those three things rather than
867
+ * beside them. Without this the state says `error` and nothing anywhere says
868
+ * what went wrong.
869
+ *
870
+ * No `resumable`. It is only ever `true` to offer a resume, and this host
871
+ * cannot resume a turn - saying so with a `false` it never varies would be
872
+ * answering a question nobody asked.
873
+ */
874
+ /*
875
+ * Why a turn stopped, held for the snapshot rather than announced.
876
+ *
877
+ * `chat/error` *carries* this part and appends it itself, so a
878
+ * `chat/responsePart` for the same thing is the failure printed twice. The
879
+ * part is `{ kind, error }` and nothing else: `ErrorResponsePart` has no id.
880
+ */
881
+ const failurePart = (why: string): Bag => ({
882
+ kind: 'error',
883
+ error: { errorType: 'turnFailed', message: why },
884
+ });
885
+ const addFailure = (turn: Bag, why: string): Bag => {
886
+ const part = failurePart(why);
887
+ (turn.responseParts as Bag[]).push(part);
888
+ return part;
889
+ };
890
+
891
+ const streamed = (event: Bag): void => {
892
+ const type = str(event.type);
893
+
894
+ if (type === 'message_start') {
895
+ streaming = str(bag(event.message).id) ?? 'm';
896
+ openTurn();
897
+ return;
898
+ }
899
+
900
+ const of = streaming ?? 'm';
901
+ const key = `#${of}:${String(event.index)}`;
902
+
903
+ if (type === 'content_block_start') {
904
+ const turn = openTurn();
905
+ const block = bag(event.content_block);
906
+ const kind = str(block.type);
907
+ /*
908
+ * A tool call, opened while its arguments are still arriving.
909
+ *
910
+ * `streaming` is the status the protocol has for exactly this, and
911
+ * `partialInput` is where the half-written json goes - a client draws
912
+ * the row as soon as the name is known and fills the arguments in as
913
+ * they come, rather than waiting for the complete block. The permission
914
+ * callback and the completed assistant message both find this call
915
+ * under the same id and carry it on from here.
916
+ */
917
+ if (kind === 'tool_use') {
918
+ const id = str(block.id) ?? `${of}:${String(event.index)}`;
919
+ calling.set(key, id);
920
+ if (parts.has(id)) return;
921
+ const name = str(block.name) ?? 'tool';
922
+ // Whose tool it is, when it is an MCP server's. The reducer refuses
923
+ // `chat/toolCallAuthRequired` on a call with no MCP contributor, so
924
+ // this is also what makes a sign-in mid-call sayable at all.
925
+ const from = serverOf(name);
926
+ const contributor = from === undefined
927
+ ? undefined
928
+ : { kind: 'mcp' as const, customizationId: `mcp:${from}` };
929
+ const call: Bag = {
930
+ toolCallId: id,
931
+ toolName: name,
932
+ displayName: name,
933
+ status: 'streaming',
934
+ ...(contributor ? { contributor } : {}),
935
+ };
936
+ const part: Bag = { id, kind: 'toolCall', toolCall: call };
937
+ parts.set(id, part);
938
+ holdPart(turn, part);
939
+ emit('chat', {
940
+ type: 'chat/toolCallStart',
941
+ turnId: turn.id,
942
+ toolCallId: id,
943
+ toolName: name,
944
+ displayName: name,
945
+ ...(contributor ? { contributor } : {}),
946
+ });
947
+ return;
948
+ }
949
+ // Everything else that is not prose has no part to open.
950
+ if (kind !== 'text' && kind !== 'thinking') return;
951
+ if (parts.has(key)) return;
952
+ const part: Bag = {
953
+ id: `${of}:${String(event.index)}`,
954
+ kind: kind === 'text' ? 'markdown' : 'reasoning',
955
+ content: '',
956
+ };
957
+ parts.set(key, part);
958
+ // The part first, always. A delta naming a part nobody opened is text
959
+ // the client has nowhere to put.
960
+ addPart(turn, part);
961
+ return;
962
+ }
963
+
964
+ if (type === 'content_block_delta') {
965
+ const toolCallId = calling.get(key);
966
+ if (toolCallId !== undefined) {
967
+ const json = str(bag(event.delta).partial_json);
968
+ const call = bag(parts.get(toolCallId)?.toolCall);
969
+ // Only while it is streaming: once the arguments are complete the
970
+ // call carries `toolInput`, and appending to `partialInput` after
971
+ // that is writing into a field the reducer has stopped reading.
972
+ if (json === undefined || str(call.status) !== 'streaming') return;
973
+ call.partialInput = `${String(call.partialInput ?? '')}${json}`;
974
+ emit('chat', { type: 'chat/toolCallDelta', turnId: active?.id, toolCallId, content: json });
975
+ return;
976
+ }
977
+ const part = parts.get(key);
978
+ if (!part) return;
979
+ const text = str(bag(event.delta).text) ?? str(bag(event.delta).thinking);
980
+ if (text === undefined) return;
981
+ part.content = `${String(part.content ?? '')}${text}`;
982
+ /*
983
+ * The append action follows the part it appends to.
984
+ *
985
+ * `chat/delta` is defined against a *markdown* part and `chat/reasoning`
986
+ * against a *reasoning* one, and the canonical reducer enforces the
987
+ * pairing rather than being lenient about it - a delta naming a
988
+ * reasoning part is returned unchanged. Sending thinking as a delta
989
+ * therefore opens the part and never fills it, which draws a thinking
990
+ * header with nothing under it for as long as the model thinks.
991
+ */
992
+ const append = part.kind === 'reasoning' ? 'chat/reasoning' : 'chat/delta';
993
+ emit('chat', { type: append, turnId: active?.id, partId: part.id, content: text });
994
+ }
995
+ };
996
+
997
+ const assistant = (message: Bag): void => {
998
+ const turn = openTurn();
999
+ const of = str(message.id) ?? 'm';
1000
+ ran = str(message.model) ?? ran;
1001
+ const blocks = list(message.content);
1002
+
1003
+ for (let index = 0; index < blocks.length; index++) {
1004
+ const block = bag(blocks[index]);
1005
+ const kind = str(block.type);
1006
+
1007
+ if (kind === 'text' || kind === 'thinking') {
1008
+ // Already opened and already filled by the deltas. Writing the complete
1009
+ // block on top of it prints the whole answer twice.
1010
+ if (parts.has(`#${of}:${index}`)) continue;
1011
+ const part: Bag = {
1012
+ id: `${of}:${index}`,
1013
+ kind: kind === 'text' ? 'markdown' : 'reasoning',
1014
+ content: str(block.text) ?? str(block.thinking) ?? '',
1015
+ };
1016
+ parts.set(`#${of}:${index}`, part);
1017
+ addPart(turn, part);
1018
+ continue;
1019
+ }
1020
+
1021
+ if (kind === 'tool_use') {
1022
+ const id = str(block.id) ?? `${of}:${index}`;
1023
+ /*
1024
+ * The call as it stands, if something opened it already.
1025
+ *
1026
+ * Two things do. The arguments streaming in open it `streaming`, with
1027
+ * the name and nothing else, and leave the input to be filled in here
1028
+ * - which is what `chat/toolCallReady` is for. The permission callback
1029
+ * opens it `pending-confirmation` and has already asked, so that one
1030
+ * is left alone: completing it here would answer a question nobody
1031
+ * put. The same assistant message can also arrive more than once while
1032
+ * it streams, and a second part for it is the same row drawn twice.
1033
+ */
1034
+ const open = parts.get(id);
1035
+ if (open !== undefined && str(bag(open.toolCall).status) !== 'streaming') continue;
1036
+ const name = str(block.name) ?? 'tool';
1037
+ const command = summarize(name, bag(block.input));
1038
+ const from = serverOf(name);
1039
+ /*
1040
+ * Whose tool this is, which decides who has to run it.
1041
+ *
1042
+ * A client's own beats the server it is offered through: the tools a
1043
+ * client provides are carried to the model on this host's in-process
1044
+ * server, so by name they all look like `mcp__ahp__*` - and reporting
1045
+ * one as this host's contribution would tell every client that the
1046
+ * call is nobody's to answer, including the one whose call it is.
1047
+ */
1048
+ const own = providedBy(name);
1049
+ if (own !== undefined) opening(name, id, bag(block.input));
1050
+ const contributor = own !== undefined
1051
+ ? { kind: 'client' as const, clientId: own }
1052
+ : from === undefined
1053
+ ? undefined
1054
+ : { kind: 'mcp' as const, customizationId: `mcp:${from}` };
1055
+ // Running against somebody else's server, and so a call that can end
1056
+ // up waiting on a sign-in rather than on its own work.
1057
+ if (from !== undefined) onServer.set(id, { server: from, turnId: str(turn.id) ?? '', blocked: false });
1058
+ const call: Bag = open !== undefined ? bag(open.toolCall) : {
1059
+ toolCallId: id,
1060
+ toolName: name,
1061
+ displayName: name,
1062
+ status: 'running',
1063
+ ...(contributor ? { contributor } : {}),
1064
+ /*
1065
+ * On the call, and not only on the action that announces it.
1066
+ *
1067
+ * A client driven by actions builds its own state and gets these
1068
+ * from `chat/toolCallReady` below. A client that *subscribes* reads
1069
+ * the snapshot instead, and `ToolCallState` requires both - so every
1070
+ * tool call in a transcript was a row with no sentence to draw and
1071
+ * no answer to whether anybody had approved it. The two have to say
1072
+ * the same thing, and this is the half that was not being said.
1073
+ */
1074
+ invocationMessage: name,
1075
+ confirmed: 'not-needed',
1076
+ ...(command ? { toolInput: command } : {}),
1077
+ } satisfies OnWire<ToolCallRunningState>;
1078
+ if (open === undefined) {
1079
+ const part: Bag = { id, kind: 'toolCall', toolCall: call };
1080
+ parts.set(id, part);
1081
+ holdPart(turn, part);
1082
+ }
1083
+ else {
1084
+ // The half-written json is what `toolInput` now says properly, and
1085
+ // a client that kept both would draw the arguments twice.
1086
+ call.status = 'running';
1087
+ call.invocationMessage = name;
1088
+ call.confirmed = 'not-needed';
1089
+ delete call.partialInput;
1090
+ if (command) call.toolInput = command;
1091
+ }
1092
+ doing(busyWith(name, bag(block.input)));
1093
+ /*
1094
+ * The file as it is *now*, before the tool has run.
1095
+ *
1096
+ * Announced and executed are concurrent - the SDK yields this block
1097
+ * and runs the tool - so this is a race the tool's own disk I/O
1098
+ * usually loses. Best effort, and the reference host relies on the
1099
+ * same headroom.
1100
+ */
1101
+ const changing = edits(name, bag(block.input));
1102
+ if (changing !== undefined) {
1103
+ editing.set(id, changing);
1104
+ options.onFileEdit?.(str(turn.id) ?? '', changing, 'before');
1105
+ }
1106
+ if (open === undefined) {
1107
+ emit('chat', {
1108
+ type: 'chat/toolCallStart',
1109
+ turnId: turn.id,
1110
+ toolCallId: id,
1111
+ toolName: name,
1112
+ displayName: name,
1113
+ ...(contributor ? { contributor } : {}),
1114
+ });
1115
+ }
1116
+ emit('chat', {
1117
+ type: 'chat/toolCallReady',
1118
+ turnId: turn.id,
1119
+ toolCallId: id,
1120
+ ...(contributor ? { contributor } : {}),
1121
+ // The tool's name, never its input. A client draws the intention
1122
+ // above the input, so the same string in both is the command
1123
+ // printed twice on every row.
1124
+ invocationMessage: name,
1125
+ // Nothing is being asked here - `canUseTool` is what asks. Without
1126
+ // this the reducer moves every tool call in the transcript into
1127
+ // `pending-confirmation` and draws it as a question nobody put.
1128
+ confirmed: 'not-needed',
1129
+ ...(command ? { toolInput: command } : {}),
1130
+ });
1131
+ }
1132
+ }
1133
+ };
1134
+
1135
+ const results = (message: Bag): void => {
1136
+ for (const raw of list(message.content)) {
1137
+ const block = bag(raw);
1138
+ if (str(block.type) !== 'tool_result') continue;
1139
+ const id = str(block.tool_use_id);
1140
+ const part = id ? parts.get(id) : undefined;
1141
+ if (!part) continue;
1142
+ const call = bag(part.toolCall);
1143
+ /*
1144
+ * A tool that failed is `completed`, and says so in its result.
1145
+ *
1146
+ * `ToolCallStatus` has no `failed`: the seven are `streaming`,
1147
+ * `pending-confirmation`, `running`, `auth-required`,
1148
+ * `pending-result-confirmation`, `completed` and `cancelled`. A tool that
1149
+ * ran and went wrong ran - what went wrong is `result.success` and
1150
+ * `result.error`, which is also the only place a client looks for it.
1151
+ */
1152
+ const ok = block.is_error !== true;
1153
+ call.status = 'completed';
1154
+ // Finished, so it is no longer waiting on anything - including a
1155
+ // sign-in nobody ever did.
1156
+ if (id !== undefined) onServer.delete(id);
1157
+ // Back to thinking. Leaving the last tool's name up makes a session look
1158
+ // busy with something that finished.
1159
+ doing('Thinking');
1160
+ const text = resultText(block.content);
1161
+ /*
1162
+ * The result, as one object, because that is the only part of the action
1163
+ * a client reads.
1164
+ *
1165
+ * `ToolCallCompletedState` extends `ToolCallResult`, and the reducer
1166
+ * builds it by spreading `action.result` over the call - so `status` and
1167
+ * `content` sent beside the action rather than inside it are dropped
1168
+ * without a word, and every tool's output stopped at this host. `success`
1169
+ * and `pastTenseMessage` are required; `content` blocks are MCP's, and
1170
+ * carry a `type`.
1171
+ *
1172
+ * The past-tense sentence is the CLI's own invocation message, which is
1173
+ * the best text there is: the alternative is a sentence rebuilt here out
1174
+ * of a tool name, and the CLI knows what it asked for.
1175
+ */
1176
+ const said = str(call.invocationMessage) ?? str(call.displayName) ?? str(call.toolName) ?? 'the tool';
1177
+ const result = {
1178
+ success: ok,
1179
+ pastTenseMessage: said,
1180
+ ...(text !== undefined ? { content: [{ type: 'text', text }] } : {}),
1181
+ ...(ok ? {} : { error: { message: text ?? 'The tool failed' } }),
1182
+ } satisfies Partial<OnWire<ToolCallCompletedState>>;
1183
+ /*
1184
+ * Onto the call *and* into the action, from one object.
1185
+ *
1186
+ * `ToolCallCompletedState` extends `ToolCallResult`, and the reducer
1187
+ * builds the state by spreading the action's `result` over the call - so
1188
+ * the two have to say the same thing. Written out twice they drifted,
1189
+ * which is how a transcript's tool calls came to be missing fields the
1190
+ * action had been carrying all along. One literal cannot drift from
1191
+ * itself, and it is checked against the state it completes.
1192
+ */
1193
+ Object.assign(call, result);
1194
+ // And as it is now the tool has run. Paired with the `before` above by
1195
+ // the call's own id, which is the only thing that survives the gap.
1196
+ const changed = id === undefined ? undefined : editing.get(id);
1197
+ if (id !== undefined && changed !== undefined) {
1198
+ editing.delete(id);
1199
+ options.onFileEdit?.(str(active?.id) ?? '', changed, 'after');
1200
+ }
1201
+ emit('chat', {
1202
+ type: 'chat/toolCallComplete',
1203
+ turnId: active?.id,
1204
+ toolCallId: id,
1205
+ result,
1206
+ });
1207
+ }
1208
+ };
1209
+
1210
+ // ------------------------------------------------------ asking a person
1211
+
1212
+ /**
1213
+ * Which tools a person has already answered for, for this session.
1214
+ *
1215
+ * Deny wins over allow, because the two lists are answers to different
1216
+ * questions: allow says "stop asking me", deny says "never do this", and a
1217
+ * tool in both is one somebody has forbidden and also once approved.
1218
+ */
1219
+ const settled = (toolName: string): 'allow' | 'deny' | undefined => {
1220
+ if (allowed.deny.includes(toolName)) return 'deny';
1221
+ if (allowed.allow.includes(toolName)) return 'allow';
1222
+ return undefined;
1223
+ };
1224
+
1225
+ const canUseTool = async (toolName: string, raw: Bag, asked?: Bag): Promise<unknown> => {
1226
+ /*
1227
+ * Answered from the lists, before anybody is asked.
1228
+ *
1229
+ * The SDK was handed the same lists when the query was built, so in the
1230
+ * ordinary case it never calls this at all. This is what makes a list set
1231
+ * *during* a session take effect: the query cannot be told, and this can.
1232
+ * Nothing is announced either way - a tool nobody was asked about is not
1233
+ * a question that was answered, and drawing one would put a row on screen
1234
+ * for a decision made before the turn began.
1235
+ */
1236
+ const already = settled(toolName);
1237
+ if (already === 'allow') return { behavior: 'allow', updatedInput: raw };
1238
+ if (already === 'deny') return { behavior: 'deny', message: `${toolName} is denied for this session` };
1239
+ return await new Promise((settle) => {
1240
+ const turn = openTurn();
1241
+ const about = bag(asked);
1242
+ /*
1243
+ * The agent's own id for this call.
1244
+ *
1245
+ * Not one of this host's making. The assistant message opens the call
1246
+ * under this id, and a confirmation that invented its own put a second
1247
+ * row beside it for the same command - and answered under a name the
1248
+ * client had never been given, so approving did nothing.
1249
+ */
1250
+ const id = str(about.toolUseID) ?? `req-${Date.now()}`;
1251
+
1252
+ if (toolName === 'AskUserQuestion') {
1253
+ const asked = new Map<string, string>();
1254
+ const questions = list(raw.questions).map((entry, index) => {
1255
+ const question = bag(entry);
1256
+ const key = `q${index + 1}`;
1257
+ asked.set(key, str(question.question) ?? '');
1258
+ return {
1259
+ id: key,
1260
+ kind: question.multiSelect === true ? 'multi-select' : 'single-select',
1261
+ message: str(question.question) ?? '',
1262
+ required: true,
1263
+ // The label is the id, because the label is what the SDK wants
1264
+ // back: answers are valued by the option's own label, not by an id.
1265
+ options: list(question.options).map((option) => ({
1266
+ id: str(bag(option).label) ?? '',
1267
+ label: str(bag(option).label) ?? '',
1268
+ })),
1269
+ allowFreeformInput: true,
1270
+ };
1271
+ });
1272
+ const request = { id, message: str(raw.header) ?? 'The agent has a question', questions };
1273
+ // `chat` is required on every input request and was never sent.
1274
+ const entry: Bag = { id, chat: chatUri, kind: 'chatInput', request };
1275
+ pending.set(id, { id, entry, questions: list(raw.questions), asked, answers: new Map(), settle });
1276
+ emit('chat', { type: 'chat/inputRequested', turnId: turn.id, request });
1277
+ inputNeededSet(entry);
1278
+ touch();
1279
+ return;
1280
+ }
1281
+
1282
+ const command = summarize(toolName, raw);
1283
+ const displayName = str(about.displayName) ?? toolName;
1284
+ /*
1285
+ * The sentence a person reads, which is not the input.
1286
+ *
1287
+ * The CLI renders one - "Claude wants to run …" - and it is better than
1288
+ * anything rebuilt here. Its subtitle is sometimes the input itself
1289
+ * though, and a client draws the intention *above* the input, so a
1290
+ * sentence that is the input is the command printed twice.
1291
+ */
1292
+ const said = str(about.title) ?? str(about.description);
1293
+ const invocationMessage = said !== undefined && said !== command ? said : displayName;
1294
+ const confirmationTitle = str(about.title) ?? `Run ${displayName}?`;
1295
+
1296
+ // The call the assistant message opened, if it arrived first. Which of
1297
+ // the two comes first is the CLI's business; either order is one call.
1298
+ const held = parts.get(id);
1299
+ const call = held ? bag(held.toolCall) : {
1300
+ toolCallId: id,
1301
+ toolName,
1302
+ displayName,
1303
+ ...(command ? { toolInput: command } : {}),
1304
+ } as Bag;
1305
+ call.status = 'pending-confirmation';
1306
+ call.confirmationTitle = confirmationTitle;
1307
+ // The same sentence the action carries, so a client reading the snapshot
1308
+ // has one too. See the call built in `assistant`.
1309
+ call.invocationMessage = invocationMessage;
1310
+ delete call.confirmed;
1311
+ if (!held) {
1312
+ const part: Bag = { id, kind: 'toolCall', toolCall: call };
1313
+ parts.set(id, part);
1314
+ holdPart(turn, part);
1315
+ emit('chat', { type: 'chat/toolCallStart', turnId: turn.id, toolCallId: id, toolName, displayName });
1316
+ }
1317
+ emit('chat', {
1318
+ type: 'chat/toolCallReady',
1319
+ turnId: turn.id,
1320
+ toolCallId: id,
1321
+ invocationMessage,
1322
+ confirmationTitle,
1323
+ ...(command ? { toolInput: command } : {}),
1324
+ });
1325
+
1326
+ doing(`Waiting on you: ${displayName}`);
1327
+ // `chat` and `turnId` are both required on a tool confirmation and
1328
+ // neither was sent.
1329
+ const entry: Bag = { id, chat: chatUri, kind: 'toolConfirmation', turnId: str(turn.id) ?? '', toolCall: call };
1330
+ pending.set(id, {
1331
+ id,
1332
+ entry,
1333
+ asked: new Map(),
1334
+ answers: new Map(),
1335
+ settle: (result) => settle(result.behavior === 'allow'
1336
+ ? { behavior: 'allow', updatedInput: raw }
1337
+ : result),
1338
+ });
1339
+ inputNeededSet(entry);
1340
+ touch();
1341
+ });
1342
+ };
1343
+
1344
+ // ------------------------------------------------------------------ the run
1345
+
1346
+ const handle = query({
1347
+ prompt: input(),
1348
+ options: {
1349
+ cwd,
1350
+ // The peers of `cwd`, which the SDK takes at startup. The first entry is
1351
+ // the process root and is not one of these.
1352
+ ...(peers.length > 0 ? { additionalDirectories: [...peers] } : {}),
1353
+ /*
1354
+ * The MCP servers, declared here rather than found by the CLI.
1355
+ *
1356
+ * The CLI reads the same files either way; what changes is ownership. A
1357
+ * server the SDK was *given* is one `setMcpServers` can re-declare, and
1358
+ * that is the only way a token a client signed in with can be applied -
1359
+ * `setMcpServers` does not touch servers that came from a settings file.
1360
+ */
1361
+ ...(Object.keys(declared).length > 0 ? { mcpServers: declared as never } : {}),
1362
+ includePartialMessages: true,
1363
+ /*
1364
+ * Over the daemon's own environment, never instead of it.
1365
+ *
1366
+ * The SDK's `env` *replaces* the subprocess environment rather than
1367
+ * merging with it, so handing it a lone credential is a subprocess with
1368
+ * no `PATH` and no `HOME` - which fails as something that has nothing to
1369
+ * do with authentication. Absent when nobody pushed a token, and then
1370
+ * the subprocess simply inherits, which is how every session worked
1371
+ * before this and how an automation's still does.
1372
+ */
1373
+ ...(options.env ? { env: { ...process.env, ...options.env } } : {}),
1374
+ // From the settings, which is where it lives: it is a config key like
1375
+ // the others, and a second way in was a second thing to keep in step.
1376
+ ...(typeof settings.permissionMode === 'string' ? { permissionMode: settings.permissionMode } : {}),
1377
+ /*
1378
+ * The lists, at the moment the query is built.
1379
+ *
1380
+ * The SDK takes them natively, which is what makes this the smallest
1381
+ * thing that works - and it is only half of it: the SDK has nowhere to
1382
+ * put a later change, so `canUseTool` reads the same lists on every
1383
+ * call and that is what makes one set mid-session take effect.
1384
+ */
1385
+ ...(allowed.allow.length > 0 ? { allowedTools: [...allowed.allow] } : {}),
1386
+ ...(allowed.deny.length > 0 ? { disallowedTools: [...allowed.deny] } : {}),
1387
+ // Resumed, not replayed: the agent picks up the context it built - the
1388
+ // files it read, the decisions it made - rather than being handed a
1389
+ // transcript of them and asked to infer the rest.
1390
+ ...(options.resume ? { resume: options.resume } : {}),
1391
+ /*
1392
+ * A fork, which the SDK spells as a resume that does not keep the id.
1393
+ *
1394
+ * `resumeSessionAt` is the prompt to continue from and `forkSession`
1395
+ * makes the continuation a session of its own, so the conversation this
1396
+ * was cut from carries on untouched.
1397
+ */
1398
+ ...(options.resume && options.forkAt
1399
+ ? { forkSession: true, resumeSessionAt: options.forkAt }
1400
+ : {}),
1401
+ /*
1402
+ * A rewind, which is the same resume without the new id.
1403
+ *
1404
+ * `chat/truncated` drops the turns after a named one and carries on in
1405
+ * the conversation it dropped them from - so the id has to survive it,
1406
+ * or every later resume would reach the transcript that still has them.
1407
+ * That is the whole difference from a fork, and it is one word.
1408
+ */
1409
+ ...(options.resume && options.rewindAt && !options.forkAt
1410
+ ? { resumeSessionAt: options.rewindAt }
1411
+ : {}),
1412
+ /*
1413
+ * On disk under the name the client gave it.
1414
+ *
1415
+ * The SDK invents an id and writes the transcript under that, so a
1416
+ * session a client created lived on disk under a name the client had
1417
+ * never heard of. While the daemon ran it answered to both, because it
1418
+ * held the pair in memory; once it restarted, the catalogue listed the
1419
+ * SDK's name and the URI the client created the session under answered
1420
+ * `No agent for session` for ever - the session was still there and its
1421
+ * only name for it was dead.
1422
+ *
1423
+ * Only where the client named a UUID, because that is what the SDK will
1424
+ * take. A client that names a session something else keeps what it had.
1425
+ */
1426
+ ...(options.resume === undefined && UUID.test(idOf(uri)) ? { sessionId: idOf(uri) } : {}),
1427
+ // Set once, at creation, and that is why the schema marks it immutable:
1428
+ // the CLI takes `thinking` when the query is built and has nowhere to
1429
+ // put a later change, so offering it as a live control would be a
1430
+ // switch that flips back.
1431
+ ...(settings.thinking === 'disabled' ? { thinking: { type: 'disabled' } } : {}),
1432
+ ...(settings.thinking === 'adaptive' ? { thinking: { type: 'adaptive' } } : {}),
1433
+ canUseTool,
1434
+ },
1435
+ } as Parameters<typeof query>[0]);
1436
+
1437
+ /**
1438
+ * Start a turn, whoever asked for it.
1439
+ *
1440
+ * `queuedMessageId` names the waiting message this turn came from, and the
1441
+ * client's reducer takes it out of the queue on that word - which is what
1442
+ * makes the queue empty as its turns start rather than needing a second
1443
+ * action to say so.
1444
+ */
1445
+ /** Context for the first prompt only, which never reaches the wire. */
1446
+ let carried = options.context;
1447
+
1448
+ /** The backend's id for the prompt that began each turn, by this host's turn id. */
1449
+ const cuts = new Map<string, string>();
1450
+
1451
+ /**
1452
+ * The backend's id for the *last* thing in each turn, by this host's turn id.
1453
+ *
1454
+ * Where a rewind that keeps the turn has to cut. The SDK's rule for
1455
+ * `resumeSessionAt` is the kept turn's last chain entry, whatever it is -
1456
+ * cutting at the prompt instead keeps the question and drops the answer to
1457
+ * it, which is a turn a client can still see and the agent no longer
1458
+ * remembers giving.
1459
+ */
1460
+ const ends = new Map<string, string>();
1461
+
1462
+ const beginTurn = (turnId: string, text: string, model?: Chosen, queuedMessageId?: string): void => {
1463
+ if (model !== undefined && model.id !== chosen) {
1464
+ chosen = model.id;
1465
+ void handle.setModel(model.id === 'default' ? undefined : model.id).catch(() => {});
1466
+ }
1467
+ /*
1468
+ * The form the model came with, which is one key here.
1469
+ *
1470
+ * `thinkingLevel` is what a client writes into `ModelSelection.config`,
1471
+ * and the CLI holds one effort setting for the whole query rather than one
1472
+ * per turn - so a turn that names a level sets it from here on, and the
1473
+ * session-wide `effortLevel` is told so the two controls do not describe
1474
+ * different futures.
1475
+ */
1476
+ const level = EFFORTS.find((one) => one === (model?.config ?? {}).thinkingLevel);
1477
+ if (level !== undefined && level !== settings.effortLevel) {
1478
+ settings.effortLevel = level;
1479
+ void handle.applyFlagSettings({ effortLevel: level }).catch(() => {});
1480
+ emit('session', { type: 'session/configChanged', config: { effortLevel: level } });
1481
+ }
1482
+ active = {
1483
+ id: turnId,
1484
+ startedAt: new Date().toISOString(),
1485
+ message: {
1486
+ text,
1487
+ origin: { kind: 'user' },
1488
+ ...(chosen ? { model: { id: chosen, ...(model?.config ? { config: model.config } : {}) } } : {}),
1489
+ },
1490
+ responseParts: [],
1491
+ usage: undefined,
1492
+ } satisfies WireTurn<ActiveTurn> as Bag;
1493
+ startedAt = Date.now();
1494
+ failed = undefined;
1495
+ // Said back, including to the client that started it. A host that only
1496
+ // reduced this privately would go on to emit `chat/responsePart` for a
1497
+ // turn no client has - so the parts land nowhere and the conversation
1498
+ // appears only when somebody reopens it and gets a fresh snapshot.
1499
+ emit('chat', {
1500
+ type: 'chat/turnStarted',
1501
+ turnId: active.id,
1502
+ startedAt: active.startedAt,
1503
+ message: active.message,
1504
+ ...(queuedMessageId !== undefined ? { queuedMessageId } : {}),
1505
+ });
1506
+ if (title === 'New session' && text) retitle(text.slice(0, 60));
1507
+ doing('Thinking');
1508
+ /*
1509
+ * What the model is given, which is not always what the transcript shows.
1510
+ *
1511
+ * A side chat is started from a turn somewhere else and has to know what
1512
+ * that turn said, and the protocol is explicit that the source is *not*
1513
+ * copied into this chat's visible history. So it rides on the first prompt
1514
+ * and nowhere else: the wire message stays what the person typed.
1515
+ */
1516
+ const sent = carried === undefined ? text : `${carried}\n\n${text}`;
1517
+ carried = undefined;
1518
+ waiting.push({ type: 'user', message: { role: 'user', content: sent }, parent_tool_use_id: null });
1519
+ wake?.();
1520
+ wake = undefined;
1521
+ touch();
1522
+ };
1523
+
1524
+ /**
1525
+ * The head of the queue, once there is nothing running.
1526
+ *
1527
+ * Called wherever a turn ends, which is the only place it can be: a queue
1528
+ * that waited for a client to notice would be a list, and every client
1529
+ * watching this chat would have to agree about which of them sends it.
1530
+ */
1531
+ const startNext = (): void => {
1532
+ if (active || closed)
1533
+ return;
1534
+ const next = queued.shift();
1535
+ if (!next)
1536
+ return;
1537
+ const message = bag(next.message);
1538
+ // Read back, not re-parsed: `queue` wrote this entry from a `Chosen` and
1539
+ // the values in it are the ones it kept.
1540
+ const named = bag(message.model);
1541
+ const id = str(named.id);
1542
+ let model: Chosen | undefined;
1543
+ if (id !== undefined)
1544
+ model = named.config ? { id, config: named.config as NonNullable<Chosen['config']> } : { id };
1545
+ beginTurn(crypto.randomUUID(), str(message.text) ?? '', model, str(next.id));
1546
+ };
1547
+
1548
+ /**
1549
+ * Ask the CLI what it can do, without asking it to do anything.
1550
+ *
1551
+ * Fired as soon as the query exists. Best effort: a CLI that will not answer
1552
+ * yet leaves the lists empty, which is a real answer - the same one a host
1553
+ * gives for a harness nobody has signed into - rather than a session that
1554
+ * refuses to open.
1555
+ */
1556
+ /**
1557
+ * Re-read the MCP servers and say what changed.
1558
+ *
1559
+ * Asked of the CLI rather than assumed from what was just requested: a
1560
+ * server told to start can come back `ready`, still `authRequired`, or
1561
+ * `error`, and reporting the state that was *asked for* would show a green
1562
+ * row against a server nobody has signed into.
1563
+ */
1564
+ const refreshMcp = async (): Promise<void> => {
1565
+ const found = await handle.mcpServerStatus().then((r) => (Array.isArray(r) ? r : [])).catch(() => [] as unknown[]);
1566
+ await discover(found);
1567
+ for (const raw of found) {
1568
+ const server = bag(raw);
1569
+ const name = str(server.name);
1570
+ if (!name) continue;
1571
+ const id = `mcp:${name}`;
1572
+ const held = customizations.find((entry) => str(entry.id) === id);
1573
+ const fresh = bag(customizationsOf({}, [server], [], wanted)[0]);
1574
+ if (!held) {
1575
+ customizations.push(fresh);
1576
+ emit('session', { type: 'session/customizationUpdated', customization: fresh });
1577
+ continue;
1578
+ }
1579
+ const moved = JSON.stringify(held.state) !== JSON.stringify(fresh.state);
1580
+ const switched = JSON.stringify(held.enablement) !== JSON.stringify(fresh.enablement);
1581
+ if (!moved && !switched)
1582
+ continue;
1583
+ /*
1584
+ * Which running tool calls this moved, before the row itself.
1585
+ *
1586
+ * The CLI reports a *server's* status and never a call's, so a call
1587
+ * blocked on a sign-in is only tellable by joining the two: every call
1588
+ * running against this server is blocked when it starts asking, and
1589
+ * unblocked when it is ready again. `chat/toolCallAuthRequired` is a
1590
+ * no-op in the reducer unless the call carries an MCP contributor,
1591
+ * which is why one is put on every `mcp__…` call.
1592
+ */
1593
+ const asking = str(fresh.state === undefined ? undefined : bag(fresh.state).kind) === 'authRequired';
1594
+ for (const [callId, running] of onServer) {
1595
+ if (running.server !== name || running.blocked === asking) continue;
1596
+ running.blocked = asking;
1597
+ const at = parts.get(callId);
1598
+ const call = bag(at?.toolCall);
1599
+ if (asking) {
1600
+ const { kind: _kind, ...auth } = bag(fresh.state);
1601
+ call.status = 'auth-required';
1602
+ call.auth = auth;
1603
+ emit('chat', { type: 'chat/toolCallAuthRequired', turnId: running.turnId, toolCallId: callId, auth });
1604
+ // The same block at the session level, which is where a client
1605
+ // looking at a list rather than at a conversation sees it.
1606
+ inputNeededSet({
1607
+ id: `auth:${callId}`,
1608
+ chat: chatUri,
1609
+ kind: 'toolAuthentication',
1610
+ turnId: running.turnId,
1611
+ toolCall: { ...call },
1612
+ });
1613
+ }
1614
+ else {
1615
+ call.status = 'running';
1616
+ delete call.auth;
1617
+ emit('chat', { type: 'chat/toolCallAuthResolved', turnId: running.turnId, toolCallId: callId });
1618
+ inputNeededRemoved(`auth:${callId}`);
1619
+ }
1620
+ }
1621
+ held.state = fresh.state;
1622
+ held.enablement = fresh.enablement;
1623
+ // `mcpServerStateChanged` carries the state and nothing else, so a
1624
+ // server that came back on would arrive `ready` with the switch still
1625
+ // drawn off. The whole row when both moved, the narrow action when only
1626
+ // the state did.
1627
+ if (switched)
1628
+ emit('session', { type: 'session/customizationUpdated', customization: { ...held } });
1629
+ else
1630
+ emit('session', { type: 'session/mcpServerStateChanged', id, state: fresh.state });
1631
+ }
1632
+ /*
1633
+ * And the ones that are no longer there.
1634
+ *
1635
+ * A server taken out of the configuration stops being reported, and a
1636
+ * customization list that only ever grew left it drawn for as long as the
1637
+ * session ran. Said one at a time rather than by re-sending the list: the
1638
+ * removal is the change, and a list re-sent on every refresh is the row
1639
+ * redrawn whether or not anything moved.
1640
+ */
1641
+ const still = new Set(found.map((raw) => `mcp:${str(bag(raw).name) ?? ''}`));
1642
+ for (const entry of [...customizations]) {
1643
+ const id = str(entry.id) ?? '';
1644
+ if (!id.startsWith('mcp:') || still.has(id)) continue;
1645
+ customizations.splice(customizations.indexOf(entry), 1);
1646
+ emit('session', { type: 'session/customizationRemoved', id });
1647
+ }
1648
+ };
1649
+
1650
+ /*
1651
+ * Output styles this CLI has, learned at the handshake.
1652
+ *
1653
+ * Empty until then, which is why `setOutputStyle` does not refuse on an
1654
+ * empty list: not knowing the styles and knowing there are none are
1655
+ * different answers and only one of them is a reason to say no.
1656
+ */
1657
+ let styles: string[] = [];
1658
+
1659
+ /** Which file each running edit tool is changing, by its call id. */
1660
+ const editing = new Map<string, string>();
1661
+
1662
+ /** The server name behind an `mcp:` customization id, if it is one. */
1663
+ const serverNamed = (id: string): string | undefined =>
1664
+ (id.startsWith('mcp:') ? id.slice(4) : undefined);
1665
+
1666
+ const describe = async (): Promise<void> => {
1667
+ const [init, mcp, skills] = await Promise.all([
1668
+ handle.initializationResult().then((r) => bag(r as unknown)).catch(() => ({} as Bag)),
1669
+ handle.mcpServerStatus().then((r) => (Array.isArray(r) ? r : [])).catch(() => [] as unknown[]),
1670
+ // The only way to know which commands are skills. It re-reads them from
1671
+ // disk, which at the start of a session is what one wants anyway.
1672
+ handle.reloadSkills().then((r) => list(bag(r as unknown).skills)).catch(() => [] as unknown[]),
1673
+ ]);
1674
+ offered = list(init.models)
1675
+ .map((raw) => {
1676
+ const model = bag(raw);
1677
+ // `value`, not `id`. Reading the wrong name costs every model there
1678
+ // is and leaves a picker that offers nothing.
1679
+ return { id: str(model.value) ?? '', name: str(model.displayName) ?? str(model.value) ?? '' };
1680
+ })
1681
+ .filter((model) => model.id !== '');
1682
+ styles = list(init.available_output_styles).filter((s): s is string => typeof s === 'string');
1683
+ /*
1684
+ * The style, settled both ways.
1685
+ *
1686
+ * A style chosen at creation is only a *setting* until the CLI is told,
1687
+ * and the CLI is not there to be told until now. One that was not chosen
1688
+ * is whatever the CLI already runs on, and reporting anything else would
1689
+ * draw a control sitting on a value that is not in force.
1690
+ */
1691
+ const asked = str(settings.outputStyle);
1692
+ const running = str(init.output_style);
1693
+ if (asked !== undefined && asked !== running) {
1694
+ await handle.applyFlagSettings({ outputStyle: asked }).catch(() => {});
1695
+ }
1696
+ else if (asked === undefined && running !== undefined) {
1697
+ settings.outputStyle = running;
1698
+ }
1699
+ await discover(mcp);
1700
+ customizations = customizationsOf(init, mcp, skills, wanted);
1701
+ if (customizations.length > 0) {
1702
+ emit('session', { type: 'session/customizationsChanged', customizations });
1703
+ }
1704
+ options.onHandshake?.();
1705
+ };
1706
+ void describe().catch(() => {});
1707
+
1708
+ void (async () => {
1709
+ try {
1710
+ for await (const raw of handle) {
1711
+ const message = bag(raw as unknown);
1712
+ const type = str(message.type);
1713
+ // Every message carries it, so this needs no particular one to arrive.
1714
+ const said = str(message.session_id);
1715
+ if (said) agentId = said;
1716
+
1717
+ // The message stream's own init. Capabilities come from the control
1718
+ // protocol instead (see `describe`), because those are needed before
1719
+ // a turn; what this adds is the model the turn actually ran on.
1720
+ if (type === 'system' && str(message.subtype) === 'init') { handshake = message; continue; }
1721
+
1722
+ /*
1723
+ * The harness compacted its context.
1724
+ *
1725
+ * Deliberately *not* `chat/truncated`: that means "drop the turns
1726
+ * after this one", and every one of them is still in the transcript
1727
+ * and still readable. What was compacted is the model's context, not
1728
+ * the conversation, and a host that conflated the two would delete
1729
+ * from every client's screen a history it can still serve.
1730
+ *
1731
+ * Said as a notice in the running turn instead, because somebody
1732
+ * watching an answer change character halfway through deserves to
1733
+ * know why.
1734
+ */
1735
+ if (type === 'system' && str(message.subtype) === 'compact_boundary') {
1736
+ const turn = active;
1737
+ if (turn) {
1738
+ const about = bag(message.compact_metadata);
1739
+ const was = typeof about.pre_tokens === 'number' ? about.pre_tokens : undefined;
1740
+ const now = typeof about.post_tokens === 'number' ? about.post_tokens : undefined;
1741
+ const how = str(about.trigger) === 'manual' ? 'Context compacted' : 'Context compacted automatically';
1742
+ addPart(turn, {
1743
+ id: `${String(turn.id)}:compact:${String(turns.length)}`,
1744
+ kind: 'systemNotification',
1745
+ content: was !== undefined && now !== undefined
1746
+ ? `${how}: ${String(was)} tokens to ${String(now)}.`
1747
+ : `${how}.`,
1748
+ });
1749
+ }
1750
+ continue;
1751
+ }
1752
+
1753
+ /*
1754
+ * How far this turn has got, in the backend's own names for things.
1755
+ *
1756
+ * `user` and `assistant` are the frames that become entries in the
1757
+ * transcript chain; a `stream_event` is a piece of one that is not
1758
+ * written down separately, and a `result` closes a turn without being
1759
+ * part of it. So the last of these two seen while a turn is active is
1760
+ * that turn's last chain entry, which is where a rewind cuts.
1761
+ */
1762
+ if (active !== undefined && (type === 'user' || type === 'assistant')) {
1763
+ const entry = str(message.uuid);
1764
+ if (entry !== undefined) ends.set(String(active.id), entry);
1765
+ }
1766
+
1767
+ if (type === 'stream_event') { streamed(bag(message.event)); continue; }
1768
+ if (type === 'assistant') { assistant(bag(message.message)); continue; }
1769
+ if (type === 'user') {
1770
+ // The prompt's own id, which is what a fork is cut at. Recorded on
1771
+ // the first echo of a turn and not after: later `user` frames in one
1772
+ // turn are tool results, and cutting at one of those would resume
1773
+ // halfway through work the agent had already started.
1774
+ const said = str(message.uuid);
1775
+ if (active && said !== undefined && !cuts.has(String(active.id))) cuts.set(String(active.id), said);
1776
+ results(bag(message.message));
1777
+ continue;
1778
+ }
1779
+
1780
+ if (type === 'result') {
1781
+ const turn = active;
1782
+ /*
1783
+ * Read before the turn is pushed, because the reason goes inside it.
1784
+ * `is_error` carries the words; a subtype that is not `success` is a
1785
+ * turn that ended badly with none, and saying which is better than
1786
+ * an error part that says only that there was one.
1787
+ */
1788
+ const wrong = message.is_error === true
1789
+ ? (list(message.errors).map(String).join('\n') || 'The turn failed')
1790
+ : str(message.subtype) !== 'success'
1791
+ ? `The turn ended ${str(message.subtype) ?? 'without succeeding'}`
1792
+ : undefined;
1793
+ if (turn) {
1794
+ /*
1795
+ * Every turn that ends says how it ended.
1796
+ *
1797
+ * `Turn.state` is required and this only ever set it when
1798
+ * something went wrong, so a turn that simply worked went into
1799
+ * the history with no state at all. A client driven by actions
1800
+ * never saw it - its reducer fills the state in on
1801
+ * `chat/turnComplete` - but a client that subscribes afterwards
1802
+ * reads the snapshot, and the snapshot is this.
1803
+ */
1804
+ turn.state = str(message.subtype) !== 'success' ? 'error' : 'complete';
1805
+ turn.duration = typeof message.duration_ms === 'number' ? message.duration_ms : Date.now() - startedAt;
1806
+ // Before the turn completes, not after: the reducer hangs usage on
1807
+ // `activeTurn`, and `chat/turnComplete` is what moves that into
1808
+ // `turns` - so the other order reports it about nothing.
1809
+ const used = usageOf(message.usage, ran);
1810
+ if (used) {
1811
+ turn.usage = used;
1812
+ emit('chat', { type: 'chat/usage', turnId: turn.id, usage: used });
1813
+ }
1814
+ const part = wrong === undefined ? undefined : addFailure(turn, wrong);
1815
+ turns.push(turn);
1816
+ active = undefined;
1817
+ ran = undefined;
1818
+ parts.clear();
1819
+ calling.clear();
1820
+ streaming = undefined;
1821
+ /*
1822
+ * One action ends a turn, and which one says how it went.
1823
+ *
1824
+ * `chat/error` is not a message beside a completed turn - it *is*
1825
+ * the ending, with `turnId`, a required `duration` and the error
1826
+ * part it appends. This sent `chat/turnComplete` and then a
1827
+ * `chat/error` carrying only `message`: the turn landed in the
1828
+ * history as a success, and the second action reached a reducer
1829
+ * with no open turn left to end and did nothing at all. So a turn
1830
+ * that failed was drawn as one that worked, and the reason was in
1831
+ * the snapshot and nowhere in the stream.
1832
+ */
1833
+ if (part !== undefined) {
1834
+ emit('chat', { type: 'chat/error', turnId: turn.id, duration: turn.duration, part });
1835
+ }
1836
+ else {
1837
+ emit('chat', { type: 'chat/turnComplete', turnId: turn.id, duration: turn.duration });
1838
+ }
1839
+ }
1840
+ // About the session rather than the turn: it reads into
1841
+ // `Status.Error` and into the summary, and the next turn clears it.
1842
+ if (message.is_error === true) failed = wrong ?? 'The turn failed';
1843
+ doing(undefined);
1844
+ touch();
1845
+ startNext();
1846
+ }
1847
+ }
1848
+ } catch (error) {
1849
+ failed = error instanceof Error ? error.message : String(error);
1850
+ const turn = active;
1851
+ if (turn) {
1852
+ turn.state = 'error';
1853
+ turn.duration = Date.now() - startedAt;
1854
+ const part = addFailure(turn, failed);
1855
+ turns.push(turn);
1856
+ active = undefined;
1857
+ emit('chat', { type: 'chat/error', turnId: turn.id, duration: turn.duration, part });
1858
+ }
1859
+ doing(undefined);
1860
+ touch();
1861
+ }
1862
+ })();
1863
+
1864
+ return {
1865
+ uri,
1866
+ chatUri,
1867
+ status,
1868
+
1869
+ models: () => offered,
1870
+ agentId: () => agentId,
1871
+ forkPoint: (turnId) => cuts.get(turnId),
1872
+ endPoint: (turnId) => ends.get(turnId),
1873
+
1874
+ customizations: () => customizations,
1875
+ allTurns: () => turns,
1876
+ activity: () => activity,
1877
+ title: () => title,
1878
+ modifiedAt: () => modified,
1879
+ workingDirectories: () => [`file://${cwd}`, ...peers.map((one) => `file://${one}`)],
1880
+
1881
+ sessionState: () => ({
1882
+ // No `resource`: it is declared on `SessionSummary` and not on
1883
+ // `SessionState`, and a client subscribed to this channel named it.
1884
+ provider: 'claude',
1885
+ title,
1886
+ status: status(),
1887
+ lifecycle: 'ready',
1888
+ defaultChat: chatUri,
1889
+ chats: [{ resource: chatUri, title }],
1890
+ workingDirectories: [`file://${cwd}`, ...peers.map((one) => `file://${one}`)],
1891
+ customizations,
1892
+ // What it is doing, only while it is doing something. The protocol has
1893
+ // a session mirror its default chat's, which is where this is set.
1894
+ ...(activity !== undefined ? { activity } : {}),
1895
+ /*
1896
+ * The schema *and* what is in force.
1897
+ *
1898
+ * A client reads `config.schema.properties` to know which controls to
1899
+ * draw and `config.values` to know where each one sits - so a session
1900
+ * without this has no permission control, no model picker and no
1901
+ * effort control, which is what it had.
1902
+ */
1903
+ config: {
1904
+ schema: options.schema?.() ?? { type: 'object', properties: {} },
1905
+ values: { ...settings, ...(chosen ? { model: chosen } : {}) },
1906
+ },
1907
+ /*
1908
+ * The model this session is on, under `_meta` because the protocol has
1909
+ * no field for it.
1910
+ *
1911
+ * `SessionState` declares none: `UsageInfo.model` says what some past
1912
+ * turn ran on and `ModelSelection` says what a client asked for, and
1913
+ * neither answers "what is this session on now" before a turn exists.
1914
+ * `_meta` is the protocol's own escape hatch, and a client reading
1915
+ * `_meta.model` knows it is reading an extension - where a bare `model`
1916
+ * beside `title` and `provider` reads like a declared field, which is a
1917
+ * mistake somebody has already made with this one.
1918
+ */
1919
+ ...(chosen ?? str(bag(handshake).model)
1920
+ ? { _meta: { model: (chosen ?? str(bag(handshake).model)) as string } }
1921
+ : {}),
1922
+ // Set only while something is wanted. A key that is always present and
1923
+ // sometimes empty is a client that has to guess which it is.
1924
+ ...(pending.size > 0 ? { inputNeeded: [...pending.values()].map((one) => one.entry) } : {}),
1925
+ ...(failed ? { error: failed } : {}),
1926
+ }),
1927
+
1928
+ chatState: () => ({
1929
+ resource: chatUri,
1930
+ title,
1931
+ status: status(),
1932
+ modifiedAt: modified,
1933
+ // A chat's own set, which may be narrower than its session's: the
1934
+ // process is rooted at the same place, and which peers it was given is
1935
+ // this chat's to say.
1936
+ workingDirectories: [`file://${cwd}`, ...peers.map((one) => `file://${one}`)],
1937
+ // The newest page. A resumed session can be seeded with hundreds of
1938
+ // turns, and the snapshot is what a client waits on before it draws.
1939
+ ...tail(turns),
1940
+ ...(active ? { activeTurn: active } : {}),
1941
+ ...(activity !== undefined ? { activity } : {}),
1942
+ ...(draft !== undefined ? { draft } : {}),
1943
+ // Said rather than left to a default: `Full` is what a client assumes
1944
+ // when the field is absent, and assuming it is not the same as being
1945
+ // told. Every chat here is one somebody can type into.
1946
+ interactivity: 'full',
1947
+ ...(steering !== undefined ? { steeringMessage: steering } : {}),
1948
+ queuedMessages: [...queued],
1949
+ }),
1950
+
1951
+ /**
1952
+ * The client said the turn has begun, so reduce it and get to work.
1953
+ *
1954
+ * Write-ahead: the turn is real the moment the client says so, and the
1955
+ * host's job is to make it true rather than to decide whether it may.
1956
+ */
1957
+
1958
+ /**
1959
+ * A key this backend does not advertise, taken anyway when it means one.
1960
+ *
1961
+ * `autoApprove` and `mode` are conventional names a client sends whatever
1962
+ * a host advertises, and both mean something this harness can do. Mapped
1963
+ * onto the mode the CLI takes and recorded there, so the control this
1964
+ * backend *does* advertise shows what actually happened.
1965
+ *
1966
+ * False for anything else, and false is a real answer: a setter that
1967
+ * reported success and changed nothing would leave a client showing a
1968
+ * session in a state it is not in.
1969
+ */
1970
+ setConfig: async (key, value) => {
1971
+ /*
1972
+ * The lists, which really do move on a running session.
1973
+ *
1974
+ * The SDK takes `allowedTools` / `disallowedTools` when the query is
1975
+ * built and has nowhere to put a later change, so a list set halfway
1976
+ * through would be a control that reported success and did nothing.
1977
+ * `canUseTool` is the other half and reads `allowed` on every call -
1978
+ * which is where a change made now takes effect.
1979
+ */
1980
+ if (key === 'permissions') {
1981
+ const held = listsOf(value);
1982
+ if (!held) return `${key} takes an object with allow and deny, not ${typeof value}`;
1983
+ allowed = held;
1984
+ settings.permissions = held;
1985
+ return true;
1986
+ }
1987
+ const said = typeof value === 'string' ? value : '';
1988
+ if (key === 'model') {
1989
+ try {
1990
+ await handle.setModel(said === 'default' ? undefined : said);
1991
+ chosen = said;
1992
+ settings.model = said;
1993
+ return true;
1994
+ }
1995
+ catch { return `The harness would not take model ${said}`; }
1996
+ }
1997
+ if (key === 'effortLevel') {
1998
+ const found = EFFORTS.find((one) => one === said);
1999
+ if (!found) return `The harness has no effort level called ${said}`;
2000
+ settings.effortLevel = found;
2001
+ void handle.applyFlagSettings({ effortLevel: found }).catch(() => {});
2002
+ return true;
2003
+ }
2004
+ /*
2005
+ * Taken unvalidated until the CLI has said what it has.
2006
+ *
2007
+ * Before the handshake the list is not known, and refusing then would
2008
+ * refuse every style there is - so it is taken and the CLI is left to
2009
+ * disagree. The only wrong answer is a control that reports success and
2010
+ * changes nothing.
2011
+ */
2012
+ if (key === 'outputStyle') {
2013
+ if (styles.length > 0 && !styles.includes(said)) return `The harness has no output style called ${said}`;
2014
+ settings.outputStyle = said;
2015
+ void handle.applyFlagSettings({ outputStyle: said }).catch(() => {});
2016
+ return true;
2017
+ }
2018
+ /*
2019
+ * The mode this backend advertises, and the two conventional names for
2020
+ * the same axis.
2021
+ *
2022
+ * `permissionMode` is the schema's own property and its five values are
2023
+ * the CLI's. `autoApprove` and `mode` are what a client sends whatever a
2024
+ * host advertises, and `permissionFor` maps them onto the same axis.
2025
+ */
2026
+ const modes = ['default', 'acceptEdits', 'plan', 'bypassPermissions', 'dontAsk', 'auto'] as const;
2027
+ const found = key === 'permissionMode'
2028
+ ? modes.find((one) => one === said)
2029
+ : permissionFor(key, said);
2030
+ if (!found) {
2031
+ return key === 'permissionMode' || key === 'autoApprove' || key === 'mode'
2032
+ ? `The harness has no permission mode called ${said}`
2033
+ : `${key} is not a config key this backend takes`;
2034
+ }
2035
+ settings.permissionMode = found;
2036
+ void handle.setPermissionMode(found).catch(() => {});
2037
+ return true;
2038
+ },
2039
+
2040
+
2041
+
2042
+ settings: () => ({ ...settings, ...(chosen ? { model: chosen } : {}) }),
2043
+
2044
+ /**
2045
+ * Turn one on or off.
2046
+ *
2047
+ * Only MCP servers: the CLI has `toggleMcpServer` and nothing equivalent
2048
+ * for a skill, a prompt or a subagent. Those are refused rather than
2049
+ * accepted and dropped - a switch that reports success and changes
2050
+ * nothing is worse than one that says it cannot.
2051
+ */
2052
+ setCustomizationEnabled: async (id, enabled) => {
2053
+ const server = serverNamed(id);
2054
+ if (!server)
2055
+ return false;
2056
+ const held = customizations.find((entry) => str(entry.id) === id);
2057
+ const was = str(bag(held?.state).kind);
2058
+ try {
2059
+ if (!enabled) {
2060
+ await handle.toggleMcpServer(server, false);
2061
+ }
2062
+ /*
2063
+ * Switching on a server that is not ready is how somebody signs into
2064
+ * one.
2065
+ *
2066
+ * `toggleMcpServer` only lifts the disabled flag - a server that was
2067
+ * off *because* nobody had signed in comes straight back needing a
2068
+ * sign-in, which reads as a switch that flips itself off.
2069
+ * `reconnectMcpServer` is the one that makes the CLI run its own
2070
+ * sign-in.
2071
+ */
2072
+ else if (was === 'ready') {
2073
+ await handle.toggleMcpServer(server, true);
2074
+ }
2075
+ else {
2076
+ await handle.toggleMcpServer(server, true).catch(() => {});
2077
+ emit('session', { type: 'session/mcpServerStartRequested', id });
2078
+ await handle.reconnectMcpServer(server);
2079
+ }
2080
+ }
2081
+ catch {
2082
+ // What it actually is now, which after a failed sign-in is still the
2083
+ // CLI's own `needs-auth` rather than anything this host invented.
2084
+ await refreshMcp();
2085
+ return true;
2086
+ }
2087
+ await refreshMcp();
2088
+ return true;
2089
+ },
2090
+
2091
+ /**
2092
+ * Start one, which is also how a server that needs signing into is signed
2093
+ * into.
2094
+ *
2095
+ * `reconnectMcpServer` makes the CLI run its own sign-in, on the machine
2096
+ * the CLI is on. AHP's `authenticate` is the other model - the client
2097
+ * fetches a token and pushes it - and the SDK has nowhere to put one, so
2098
+ * this host serves the gesture and not the token.
2099
+ */
2100
+ startMcpServer: async (id) => {
2101
+ const server = serverNamed(id);
2102
+ if (!server)
2103
+ return false;
2104
+ emit('session', { type: 'session/mcpServerStartRequested', id });
2105
+ try {
2106
+ await handle.reconnectMcpServer(server);
2107
+ }
2108
+ catch {
2109
+ await refreshMcp();
2110
+ return false;
2111
+ }
2112
+ await refreshMcp();
2113
+ return true;
2114
+ },
2115
+
2116
+ /*
2117
+ * A token a client signed in with, put where the server will use it.
2118
+ *
2119
+ * The whole set is re-declared, not the one server: `setMcpServers`
2120
+ * replaces the SDK's dynamic servers with what it is given, so sending one
2121
+ * would take the others away. Then the server is asked to connect again,
2122
+ * which is when the CLI tries the header.
2123
+ */
2124
+ authenticated: async (resource, token) => {
2125
+ const named = [...wanted.entries()].find(([, published]) => published.resource === resource)?.[0];
2126
+ if (named === undefined) return false;
2127
+ const config = declared[named];
2128
+ if (config === undefined) return false;
2129
+ const headers = typeof config.headers === 'object' && config.headers !== null
2130
+ ? config.headers as Record<string, string>
2131
+ : {};
2132
+ declared[named] = { ...config, headers: { ...headers, Authorization: `Bearer ${token}` } };
2133
+ try {
2134
+ await handle.setMcpServers(declared as never);
2135
+ // Discovered again next time: a server that connects is no longer one
2136
+ // anybody needs to sign into.
2137
+ wanted.delete(named);
2138
+ await handle.reconnectMcpServer(named);
2139
+ }
2140
+ catch { return false; }
2141
+ await refreshMcp();
2142
+ return true;
2143
+ },
2144
+
2145
+ awaiting: () => [...wanted.values()].map((published) => published.resource),
2146
+
2147
+ stopMcpServer: async (id) => {
2148
+ const server = serverNamed(id);
2149
+ if (!server)
2150
+ return false;
2151
+ emit('session', { type: 'session/mcpServerStopRequested', id });
2152
+ try {
2153
+ await handle.toggleMcpServer(server, false);
2154
+ }
2155
+ catch {
2156
+ await refreshMcp();
2157
+ return false;
2158
+ }
2159
+ await refreshMcp();
2160
+ return true;
2161
+ },
2162
+
2163
+
2164
+ /**
2165
+ * A model named on the turn takes effect and **stays** in effect.
2166
+ *
2167
+ * The SDK has no per-turn model, so honouring `message.model` means
2168
+ * `setModel` before the prompt - and setting it back afterwards would
2169
+ * race the next turn onto whichever call landed last. Leaving it is the
2170
+ * behaviour that can be explained; silently ignoring the field is the one
2171
+ * that cannot, because the transcript would then credit a turn to a model
2172
+ * that never ran it.
2173
+ */
2174
+ begin: (turnId, text, model) => beginTurn(turnId, text, model),
2175
+
2176
+ /**
2177
+ * A turn this host answered itself, with a shell rather than the agent.
2178
+ *
2179
+ * The same shape as any other turn - it opens, carries one tool call, and
2180
+ * completes - because that is what makes it readable afterwards: the
2181
+ * command and its output are in the transcript beside the conversation
2182
+ * they interrupted, rather than in a panel that closed. Nothing is pushed
2183
+ * to the CLI, which is the whole difference from `begin`.
2184
+ */
2185
+ ran: (turnId, command, run) => {
2186
+ // Queued behind whatever is running, like anything else a person types.
2187
+ // A shell command that jumped the queue would run against a tree the
2188
+ // turn in front of it is still editing.
2189
+ if (active) {
2190
+ queued.push({ id: turnId, message: { text: `!${command}`, origin: { kind: 'user' } } });
2191
+ emit('chat', { type: 'chat/pendingMessageSet', message: queued[queued.length - 1] });
2192
+ touch();
2193
+ return;
2194
+ }
2195
+ const turn: Bag = {
2196
+ id: turnId,
2197
+ startedAt: new Date().toISOString(),
2198
+ message: { text: `!${command}`, origin: { kind: 'user' } },
2199
+ responseParts: [],
2200
+ usage: undefined,
2201
+ } satisfies WireTurn<ActiveTurn> as Bag;
2202
+ active = turn;
2203
+ startedAt = Date.now();
2204
+ failed = undefined;
2205
+ emit('chat', {
2206
+ type: 'chat/turnStarted', turnId, startedAt: turn.startedAt, message: turn.message,
2207
+ });
2208
+ if (title === 'New session') retitle(command.slice(0, 60));
2209
+ doing('Running');
2210
+ const toolCallId = `${turnId}:command`;
2211
+ /*
2212
+ * `terminal` as the name, which is what the reference host calls it.
2213
+ *
2214
+ * A client draws a tool call by its name, and one called anything else
2215
+ * would be drawn as an unknown tool rather than as the shell it is.
2216
+ */
2217
+ const call = {
2218
+ toolCallId,
2219
+ toolName: 'terminal',
2220
+ displayName: 'Terminal',
2221
+ intention: command,
2222
+ invocationMessage: command,
2223
+ toolInput: command,
2224
+ // The person typed it themselves, so there is nobody left to ask.
2225
+ confirmed: 'not-needed',
2226
+ status: 'running',
2227
+ } satisfies OnWire<ToolCallRunningState> as Bag;
2228
+ holdPart(turn, call);
2229
+ emit('chat', {
2230
+ type: 'chat/toolCallStart', turnId, toolCallId, toolName: 'terminal',
2231
+ displayName: 'Terminal', intention: command,
2232
+ });
2233
+ emit('chat', {
2234
+ type: 'chat/toolCallReady', turnId, toolCallId,
2235
+ invocationMessage: command, toolInput: command, confirmed: 'not-needed',
2236
+ });
2237
+ void run(toolCallId).then((done) => {
2238
+ if (active !== turn) return;
2239
+ /*
2240
+ * The terminal first, so a client can watch the output arrive.
2241
+ *
2242
+ * `content` is replaced rather than appended to, so the terminal
2243
+ * reference and the text it produced go out together at the end -
2244
+ * and the reference alone goes out as soon as there is one, which is
2245
+ * what a client needs to start streaming.
2246
+ */
2247
+ const watched = done.terminal === undefined ? [] : [{
2248
+ type: 'terminal',
2249
+ resource: done.terminal,
2250
+ title: 'Terminal',
2251
+ // Pipes, not a pseudoterminal, which is what the field is for: a
2252
+ // client reads it to decide whether the preview needs VT parsing.
2253
+ isPty: false,
2254
+ result: {
2255
+ ...(done.code !== undefined ? { exitCode: done.code } : {}),
2256
+ ...(done.output === '' ? {} : { preview: done.output }),
2257
+ },
2258
+ } satisfies OnWire<ToolResultTerminalContent>];
2259
+ const said = done.output === ''
2260
+ ? []
2261
+ : [{ type: 'text', text: done.output } satisfies OnWire<ToolResultTextContent>];
2262
+ const shown = [...watched, ...said];
2263
+ const result = {
2264
+ success: done.success,
2265
+ pastTenseMessage: done.said,
2266
+ content: shown,
2267
+ ...(done.success ? {} : { error: { message: done.said } }),
2268
+ } satisfies Partial<OnWire<ToolCallCompletedState>>;
2269
+ Object.assign(call, result, { status: 'completed', confirmed: 'not-needed' });
2270
+ emit('chat', { type: 'chat/toolCallComplete', turnId, toolCallId, result });
2271
+ turn.state = done.success ? 'complete' : 'error';
2272
+ turn.duration = Date.now() - startedAt;
2273
+ turns.push(turn);
2274
+ active = undefined;
2275
+ if (!done.success) failed = done.said;
2276
+ emit('chat', { type: 'chat/turnComplete', turnId, duration: turn.duration });
2277
+ doing(undefined);
2278
+ touch();
2279
+ startNext();
2280
+ });
2281
+ },
2282
+
2283
+ /**
2284
+ * Into the turn that is already running, rather than after it.
2285
+ *
2286
+ * The whole of it is `waiting.push` and a wake, which is the same door
2287
+ * `begin` and the queue go through: the prompt handed to the CLI is a
2288
+ * generator that stays open for the life of the session, so a message
2289
+ * pushed while a turn runs is delivered to that turn. This was refused on
2290
+ * the grounds that "the SDK has nowhere to put one", which was a claim
2291
+ * about the harness nobody had tested and is not true of this one.
2292
+ *
2293
+ * Set and removed in the same breath, because it is consumed the instant
2294
+ * it arrives: `steeringMessage` describes a message *waiting* to be
2295
+ * injected, and nothing waits here. The protocol says the server emits
2296
+ * the removal when it consumes one, so both go out and the state field
2297
+ * stays empty - which is the honest description of what happened.
2298
+ */
2299
+ steer: (id, text) => {
2300
+ if (!active) return false;
2301
+ const message = { text, origin: { kind: 'user' } };
2302
+ // Held in the state as well as announced, and taken out again where the
2303
+ // CLI reads it rather than here: a client that only read the state saw
2304
+ // nothing waiting, because the announcement and its removal used to
2305
+ // happen in one tick.
2306
+ steering = { id, message };
2307
+ emit('chat', { type: 'chat/pendingMessageSet', kind: 'steering', id, message });
2308
+ waiting.push({ type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null });
2309
+ wake?.();
2310
+ wake = undefined;
2311
+ touch();
2312
+ return true;
2313
+ },
2314
+
2315
+ /**
2316
+ * Wait, then be the next turn.
2317
+ *
2318
+ * Idle *now* means this is not a queue at all, and the protocol says the
2319
+ * host starts the head as soon as it can - so it is announced and then
2320
+ * immediately started, which is a queue entry a client sees appear and
2321
+ * leave rather than one that was never there.
2322
+ */
2323
+ queue: (id, text, model) => {
2324
+ const entry: Bag = {
2325
+ id,
2326
+ message: {
2327
+ text,
2328
+ origin: { kind: 'user' },
2329
+ ...(model ? { model: { id: model.id, ...(model.config ? { config: model.config } : {}) } } : {}),
2330
+ },
2331
+ };
2332
+ const at = queued.findIndex((held) => str(held.id) === id);
2333
+ // The same id again edits what is waiting; a fresh one appends. That is
2334
+ // the client's spelling for "change my mind" and it costs nothing here.
2335
+ if (at >= 0) queued[at] = entry;
2336
+ else queued.push(entry);
2337
+ emit('chat', { type: 'chat/pendingMessageSet', kind: 'queued', id, message: entry.message });
2338
+ touch();
2339
+ startNext();
2340
+ },
2341
+
2342
+ setDraft: (next) => {
2343
+ if (JSON.stringify(next) === JSON.stringify(draft))
2344
+ return;
2345
+ draft = next;
2346
+ // Not `touch()`: typing is not a change to the conversation, and a
2347
+ // catalogue that reordered itself on every keystroke would be unusable.
2348
+ // The key is left off to clear it, which is what the action's
2349
+ // `undefined` means and the only way JSON can say it.
2350
+ emit('chat', { type: 'chat/draftChanged', ...(next !== undefined ? { draft: next } : {}) });
2351
+ },
2352
+
2353
+ unqueue: (id) => {
2354
+ const at = queued.findIndex((held) => str(held.id) === id);
2355
+ if (at < 0) return;
2356
+ queued.splice(at, 1);
2357
+ emit('chat', { type: 'chat/pendingMessageRemoved', kind: 'queued', id });
2358
+ touch();
2359
+ },
2360
+
2361
+ reorder: (order) => {
2362
+ const byId = new Map(queued.map((held) => [str(held.id) ?? '', held]));
2363
+ const moved: Bag[] = [];
2364
+ const seen = new Set<string>();
2365
+ for (const id of order) {
2366
+ const held = byId.get(id);
2367
+ if (!held || seen.has(id)) continue;
2368
+ seen.add(id);
2369
+ moved.push(held);
2370
+ }
2371
+ // Anything the order did not mention keeps its place behind what did,
2372
+ // rather than being dropped for not having been named.
2373
+ for (const held of queued) {
2374
+ if (!seen.has(str(held.id) ?? '')) moved.push(held);
2375
+ }
2376
+ queued.length = 0;
2377
+ queued.push(...moved);
2378
+ emit('chat', { type: 'chat/queuedMessagesReordered', order: moved.map((held) => str(held.id) ?? '') });
2379
+ touch();
2380
+ },
2381
+
2382
+ /*
2383
+ * The same turn, run again.
2384
+ *
2385
+ * The protocol is precise about this: the latest turn, in `error`, reopened
2386
+ * with its message and parts intact rather than replaced by a new one. So
2387
+ * the turn moves back to `active` as it was and its text goes to the CLI
2388
+ * again - which is what makes a failed turn retryable without somebody
2389
+ * having to type it a second time.
2390
+ */
2391
+ resume: (turnId) => {
2392
+ if (active !== undefined) return false;
2393
+ const last = turns.at(-1);
2394
+ if (last === undefined || String(last.id ?? '') !== turnId || last.state !== 'error') return false;
2395
+ turns.pop();
2396
+ const again = { ...last } as Bag;
2397
+ // `state` and `duration` are what made it a finished turn; an active one
2398
+ // has neither, and the protocol says the reducer reopens *this* turn
2399
+ // rather than replacing it.
2400
+ delete again.state;
2401
+ delete again.duration;
2402
+ active = again as unknown as NonNullable<typeof active>;
2403
+ startedAt = Date.now();
2404
+ failed = undefined;
2405
+ doing('Thinking');
2406
+ const message = bag((active as Bag).message);
2407
+ waiting.push({
2408
+ type: 'user',
2409
+ message: { role: 'user', content: str(message.text) ?? '' },
2410
+ parent_tool_use_id: null,
2411
+ });
2412
+ wake?.();
2413
+ wake = undefined;
2414
+ touch();
2415
+ return true;
2416
+ },
2417
+
2418
+ cancel: (turnId) => {
2419
+ // A turn blocked on a person is stopped by answering no, not by leaving
2420
+ // a promise nobody will settle - the subprocess would sit there for ever.
2421
+ // All of them, not the last one: a turn stopped while two questions
2422
+ // were open used to leave the other tool waiting for ever.
2423
+ for (const one of [...pending.values()]) {
2424
+ pending.delete(one.id);
2425
+ one.settle({ behavior: 'deny', message: 'The turn was stopped' });
2426
+ inputNeededRemoved(one.id);
2427
+ }
2428
+ // And the calls a client is running for us, for the same reason: a
2429
+ // promise settled by somebody else is one a stopped turn still waits on.
2430
+ releaseCalls('The turn was stopped');
2431
+ void handle.interrupt().catch(() => {});
2432
+ const turn = active;
2433
+ if (turn) {
2434
+ turn.state = 'cancelled';
2435
+ turn.duration = Date.now() - startedAt;
2436
+ turns.push(turn);
2437
+ active = undefined;
2438
+ emit('chat', { type: 'chat/turnCancelled', turnId: turnId || turn.id, duration: turn.duration });
2439
+ }
2440
+ doing(undefined);
2441
+ touch();
2442
+ // Deliberately not `startNext`: somebody stopping a turn is stopping
2443
+ // this conversation, and starting the one behind it is the opposite of
2444
+ // what they asked for.
2445
+ },
2446
+
2447
+ confirm: (toolCallId, approved) => {
2448
+ // Found by id rather than assumed to be the only one. This used to
2449
+ // compare against whichever question happened to be held and return
2450
+ // silently when it did not match - which, with two tool calls open, is
2451
+ // a person pressing Approve and nothing at all happening.
2452
+ const held = [...pending.values()].find((one) => one.entry.kind === 'toolConfirmation'
2453
+ && str(bag(one.entry.toolCall).toolCallId) === toolCallId);
2454
+ if (!held) return;
2455
+ const settle = held.settle;
2456
+ pending.delete(held.id);
2457
+ inputNeededRemoved(held.id);
2458
+ const part = parts.get(toolCallId);
2459
+ if (part) {
2460
+ bag(part.toolCall).status = approved ? 'running' : 'cancelled';
2461
+ // And how it was approved, which is required on the call and was only
2462
+ // ever said in the action.
2463
+ if (approved) bag(part.toolCall).confirmed = 'user-action';
2464
+ }
2465
+ doing(approved ? busyWith(str(bag(part?.toolCall).toolName) ?? 'tool', {}) : 'Thinking');
2466
+ // Said back, like every other action a client originates. Nothing in a
2467
+ // client applies its own dispatch, so a row approved here stayed
2468
+ // `pending-confirmation` on every screen watching it - including the
2469
+ // one that had just answered it.
2470
+ emit('chat', {
2471
+ type: 'chat/toolCallConfirmed',
2472
+ turnId: active?.id,
2473
+ toolCallId,
2474
+ approved,
2475
+ ...(approved ? { confirmed: 'user-action' } : {}),
2476
+ });
2477
+ settle(approved
2478
+ ? { behavior: 'allow', updatedInput: {} }
2479
+ : { behavior: 'deny', message: 'The person declined this action' });
2480
+ touch();
2481
+ },
2482
+
2483
+ /**
2484
+ * The tools on offer, replaced whole.
2485
+ *
2486
+ * Whole because that is what the SDK takes: `setMcpServers` replaces the
2487
+ * set it is given, so a server rebuilt from one tool would take the others
2488
+ * away. Called when a client announces what it provides or stops being
2489
+ * active, which is the only thing that moves this list after a session is
2490
+ * built.
2491
+ */
2492
+ setTools: async (next) => {
2493
+ const before = offering.map((one) => `${one.definition.name}${one.owner ?? ''}`).join('\n');
2494
+ const after = next.map((one) => `${one.definition.name}${one.owner ?? ''}`).join('\n');
2495
+ if (before === after) return true;
2496
+ offering = [...next];
2497
+ if (offering.length > 0) declared.ahp = contributed(offering, ranByClient) as Bag;
2498
+ else delete declared.ahp;
2499
+ try { await handle.setMcpServers(declared as never); }
2500
+ catch { return false; }
2501
+ return true;
2502
+ },
2503
+
2504
+ toolCallOwner: (toolCallId) => byClient.get(toolCallId)?.owner,
2505
+
2506
+ /**
2507
+ * What a client says its own tool did.
2508
+ *
2509
+ * Only from the client the call was reported against: the protocol makes
2510
+ * that one responsible for the call, and a result from anybody else is a
2511
+ * client answering for work it did not do. Answered `false` either way -
2512
+ * for a call nobody is waiting on and for a client that does not own it -
2513
+ * because both are a client out of step, and the caller says which.
2514
+ *
2515
+ * Nothing is emitted here. The answer goes back to the CLI, the CLI writes
2516
+ * the tool result, and `results` reports the completion to everybody from
2517
+ * that - which is the same path every other tool call takes. A completion
2518
+ * announced here as well would be the same row finished twice.
2519
+ */
2520
+ completeToolCall: (toolCallId, clientId, result) => {
2521
+ const held = byClient.get(toolCallId);
2522
+ if (!held || held.owner !== clientId) return false;
2523
+ byClient.delete(toolCallId);
2524
+ held.settle(result);
2525
+ return true;
2526
+ },
2527
+
2528
+ clientGone: (clientId) => {
2529
+ // A tool call whose client has gone is a turn waiting on a promise
2530
+ // nothing will settle. The agent is told it failed, which is true, and
2531
+ // is left to decide what to do about it.
2532
+ releaseCalls('The client that provides this tool is no longer here', clientId);
2533
+ },
2534
+
2535
+ /**
2536
+ * One question of a request, part-way answered.
2537
+ *
2538
+ * The same thing `setDraft` is for a message: held here so that two people
2539
+ * looking at one elicitation see the form being filled in rather than each
2540
+ * filling in their own. Kept on the request itself as well as emitted,
2541
+ * because a client that arrives while the question is open reads
2542
+ * `session.inputNeeded` and would otherwise see an empty form somebody has
2543
+ * already answered.
2544
+ *
2545
+ * False when the request is not one this session is waiting on, which is
2546
+ * the caller's to report - answering a question nobody asked is a client
2547
+ * out of step, not a no-op.
2548
+ */
2549
+ setAnswer: (requestId, questionId, answer) => {
2550
+ const held = pending.get(requestId);
2551
+ // Only a question has answers. A tool confirmation is the other kind of
2552
+ // pending input and is answered by approving it, so a draft answer to
2553
+ // one names a field it does not have.
2554
+ if (!held || held.entry.kind !== 'chatInput') return false;
2555
+ if (answer === undefined) held.answers.delete(questionId);
2556
+ else held.answers.set(questionId, answer);
2557
+ const request = bag(held.entry.request);
2558
+ if (held.answers.size > 0) request.answers = Object.fromEntries(held.answers);
2559
+ else delete request.answers;
2560
+ // Not `touch()`: typing is not a change to the conversation, and a
2561
+ // catalogue that reordered itself on every keystroke would be unusable.
2562
+ emit('chat', {
2563
+ type: 'chat/inputAnswerChanged',
2564
+ requestId,
2565
+ questionId,
2566
+ ...(answer !== undefined ? { answer } : {}),
2567
+ });
2568
+ return true;
2569
+ },
2570
+
2571
+ /**
2572
+ * Answer the question, in the shape the tool wants it back.
2573
+ *
2574
+ * Keyed by each question's own *text* and valued by the option's own
2575
+ * label - not by any id. Sending ids, or dropping `questions`, is a call
2576
+ * the tool cannot process and a turn that stalls rather than errors.
2577
+ */
2578
+ answer: (requestId, accepted, answers) => {
2579
+ const held = pending.get(requestId);
2580
+ if (!held) return;
2581
+ pending.delete(requestId);
2582
+ inputNeededRemoved(requestId);
2583
+
2584
+ if (!accepted) {
2585
+ held.settle({ behavior: 'deny', message: 'The person declined to answer' });
2586
+ touch();
2587
+ return;
2588
+ }
2589
+ const said: Record<string, unknown> = {};
2590
+ /*
2591
+ * What was typed, under what was sent.
2592
+ *
2593
+ * The protocol has `chat/inputCompleted` use the request's synced answer
2594
+ * state *plus* whatever the completion carries, and the completion is
2595
+ * allowed to carry nothing at all - a client that has been syncing each
2596
+ * answer as it went has already said everything. Reading only the action
2597
+ * threw that away and submitted an empty form.
2598
+ */
2599
+ const whole = { ...Object.fromEntries(held.answers), ...answers };
2600
+ for (const [key, value] of Object.entries(whole)) {
2601
+ const question = held.asked.get(key);
2602
+ if (!question) continue;
2603
+ const answer = bag(value);
2604
+ /*
2605
+ * Two levels in, which is where the protocol puts it.
2606
+ *
2607
+ * `ChatInputAnswer` is `{ state, value }` and that value is itself
2608
+ * `{ kind, value }` - so an answer synced through
2609
+ * `chat/inputAnswerChanged`, which is protocol-shaped, holds the word
2610
+ * the tool wants one level below where a completion's own `answers`
2611
+ * carried it. Read at one level a selection arrived as the object
2612
+ * around it, and the tool was handed a shape it cannot read.
2613
+ *
2614
+ * Freeform is the person's own words as the value, not the word they
2615
+ * typed it under - the tool reads the value as the answer itself.
2616
+ */
2617
+ const inner = bag(answer.value);
2618
+ said[question] = inner.value ?? answer.value ?? value;
2619
+ }
2620
+ held.settle({ behavior: 'allow', updatedInput: { questions: held.questions ?? [], answers: said } });
2621
+ touch();
2622
+ },
2623
+
2624
+ close: () => {
2625
+ closed = true;
2626
+ wake?.();
2627
+ for (const one of [...pending.values()]) {
2628
+ pending.delete(one.id);
2629
+ one.settle({ behavior: 'deny', message: 'The session was disposed' });
2630
+ }
2631
+ releaseCalls('The session was disposed');
2632
+ handle.close();
2633
+ },
2634
+ };
2635
+ }