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