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