@softov/ahpc 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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/dist/src/ahp/channels.d.ts +146 -0
  4. package/dist/src/ahp/channels.js +423 -0
  5. package/dist/src/ahp/connection.d.ts +656 -0
  6. package/dist/src/ahp/connection.js +1 -0
  7. package/dist/src/ahp/fake.d.ts +69 -0
  8. package/dist/src/ahp/fake.js +1996 -0
  9. package/dist/src/ahp/live.d.ts +160 -0
  10. package/dist/src/ahp/live.js +2641 -0
  11. package/dist/src/ahp/operate.d.ts +36 -0
  12. package/dist/src/ahp/operate.js +56 -0
  13. package/dist/src/ahp/publish.d.ts +39 -0
  14. package/dist/src/ahp/publish.js +370 -0
  15. package/dist/src/ahp/status.d.ts +23 -0
  16. package/dist/src/ahp/status.js +23 -0
  17. package/dist/src/ahp/types.d.ts +745 -0
  18. package/dist/src/ahp/types.js +27 -0
  19. package/dist/src/app.d.ts +54 -0
  20. package/dist/src/app.js +369 -0
  21. package/dist/src/blocks.d.ts +88 -0
  22. package/dist/src/blocks.js +102 -0
  23. package/dist/src/cli/main.d.ts +13 -0
  24. package/dist/src/cli/main.js +1537 -0
  25. package/dist/src/cli/render.d.ts +43 -0
  26. package/dist/src/cli/render.js +91 -0
  27. package/dist/src/config.d.ts +63 -0
  28. package/dist/src/config.js +45 -0
  29. package/dist/src/connect.d.ts +53 -0
  30. package/dist/src/connect.js +69 -0
  31. package/dist/src/control.d.ts +189 -0
  32. package/dist/src/control.js +2241 -0
  33. package/dist/src/diff.d.ts +44 -0
  34. package/dist/src/diff.js +111 -0
  35. package/dist/src/flags.d.ts +28 -0
  36. package/dist/src/flags.js +76 -0
  37. package/dist/src/main.d.ts +10 -0
  38. package/dist/src/main.js +54 -0
  39. package/dist/src/schedule.d.ts +46 -0
  40. package/dist/src/schedule.js +140 -0
  41. package/dist/src/screens.d.ts +79 -0
  42. package/dist/src/screens.js +1322 -0
  43. package/dist/src/state.d.ts +355 -0
  44. package/dist/src/state.js +531 -0
  45. package/dist/src/terminal.d.ts +44 -0
  46. package/dist/src/terminal.js +101 -0
  47. package/dist/src/tui.d.ts +92 -0
  48. package/dist/src/tui.js +372 -0
  49. package/dist/src/view/automations.d.ts +31 -0
  50. package/dist/src/view/automations.js +76 -0
  51. package/dist/src/view/bood/art.d.ts +90 -0
  52. package/dist/src/view/bood/art.js +152 -0
  53. package/dist/src/view/bood/beetle.d.ts +7 -0
  54. package/dist/src/view/bood/beetle.js +123 -0
  55. package/dist/src/view/bood/bunny.d.ts +8 -0
  56. package/dist/src/view/bood/bunny.js +228 -0
  57. package/dist/src/view/bood/cat.d.ts +9 -0
  58. package/dist/src/view/bood/cat.js +358 -0
  59. package/dist/src/view/bood/crab.d.ts +7 -0
  60. package/dist/src/view/bood/crab.js +121 -0
  61. package/dist/src/view/bood/index.d.ts +29 -0
  62. package/dist/src/view/bood/index.js +25 -0
  63. package/dist/src/view/bood/motion.d.ts +178 -0
  64. package/dist/src/view/bood/motion.js +298 -0
  65. package/dist/src/view/bood/owl.d.ts +9 -0
  66. package/dist/src/view/bood/owl.js +285 -0
  67. package/dist/src/view/bood/registry.d.ts +36 -0
  68. package/dist/src/view/bood/registry.js +218 -0
  69. package/dist/src/view/bood/render.d.ts +31 -0
  70. package/dist/src/view/bood/render.js +196 -0
  71. package/dist/src/view/bood/sprite.d.ts +18 -0
  72. package/dist/src/view/bood/sprite.js +130 -0
  73. package/dist/src/view/bood/sprout.d.ts +8 -0
  74. package/dist/src/view/bood/sprout.js +121 -0
  75. package/dist/src/view/bood/types.d.ts +158 -0
  76. package/dist/src/view/bood/types.js +57 -0
  77. package/dist/src/view/bubble.d.ts +75 -0
  78. package/dist/src/view/bubble.js +86 -0
  79. package/dist/src/view/changes.d.ts +35 -0
  80. package/dist/src/view/changes.js +22 -0
  81. package/dist/src/view/composer.d.ts +64 -0
  82. package/dist/src/view/composer.js +192 -0
  83. package/dist/src/view/controls.d.ts +44 -0
  84. package/dist/src/view/controls.js +49 -0
  85. package/dist/src/view/creature.d.ts +69 -0
  86. package/dist/src/view/creature.js +83 -0
  87. package/dist/src/view/customizations.d.ts +28 -0
  88. package/dist/src/view/customizations.js +53 -0
  89. package/dist/src/view/details.d.ts +65 -0
  90. package/dist/src/view/details.js +65 -0
  91. package/dist/src/view/filediff.d.ts +29 -0
  92. package/dist/src/view/filediff.js +24 -0
  93. package/dist/src/view/files.d.ts +31 -0
  94. package/dist/src/view/files.js +39 -0
  95. package/dist/src/view/hitl.d.ts +43 -0
  96. package/dist/src/view/hitl.js +171 -0
  97. package/dist/src/view/icons.d.ts +13 -0
  98. package/dist/src/view/icons.js +71 -0
  99. package/dist/src/view/picker.d.ts +42 -0
  100. package/dist/src/view/picker.js +71 -0
  101. package/dist/src/view/sessionhead.d.ts +41 -0
  102. package/dist/src/view/sessionhead.js +60 -0
  103. package/dist/src/view/sessions.d.ts +34 -0
  104. package/dist/src/view/sessions.js +61 -0
  105. package/dist/src/view/terminal.d.ts +30 -0
  106. package/dist/src/view/terminal.js +32 -0
  107. package/dist/src/view/toolcall.d.ts +27 -0
  108. package/dist/src/view/toolcall.js +48 -0
  109. package/dist/src/view/transcript.d.ts +50 -0
  110. package/dist/src/view/transcript.js +60 -0
  111. package/package.json +72 -0
@@ -0,0 +1,2641 @@
1
+ import { appendFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { openChannels } from './channels.js';
4
+ import { publish } from './publish.js';
5
+ import { PublishRefusal } from './publish.js';
6
+ import { SessionFlag } from './types.js';
7
+ /**
8
+ * What to wait before the next attempt, when a host has gone.
9
+ *
10
+ * Doubling to half a minute, which is short enough that a daemon restarted by
11
+ * hand is picked up while the person is still looking at the screen, and long
12
+ * enough that a host which is gone for the afternoon is not asked about it
13
+ * eight thousand times.
14
+ */
15
+ const BACKOFF = [250, 500, 1000, 2000, 5000, 10_000, 30_000];
16
+ /** How long a connection may say nothing before this client checks it is there. */
17
+ const KEEPALIVE_MS = 30_000;
18
+ /**
19
+ * How long a channel nobody is reading is kept before it is given up.
20
+ *
21
+ * Long enough to cover reading a snapshot and then opening the view on the
22
+ * same session, and closing a screen and going back to it - which are the two
23
+ * places an immediate release put an `unsubscribe` in the middle of what a
24
+ * person experienced as one thing.
25
+ */
26
+ const LINGER_MS = 5_000;
27
+ /**
28
+ * How many pages of the catalogue to walk before stopping and saying so.
29
+ *
30
+ * A bound rather than a page size: the host picks how big a page is, and this
31
+ * picks how many of them are worth walking to draw a list somebody is going to
32
+ * scroll. Twenty is past any catalogue either implementation has produced, and
33
+ * reaching it is reported rather than passed over in silence.
34
+ */
35
+ const PAGES = 20;
36
+ /**
37
+ * Whether a host answered, or was not there to answer.
38
+ *
39
+ * The difference decides whether coming back is worth trying differently or
40
+ * only worth trying again: a refusal is a host with an opinion about this
41
+ * client, and a transport failure is no host at all. JSON-RPC gives a numeric
42
+ * `code` and a transport error does not, which is the only thing separating
43
+ * them that does not depend on the library's own class names.
44
+ */
45
+ function isRpcRefusal(error) {
46
+ return typeof error?.code === 'number';
47
+ }
48
+ /**
49
+ * Versions to offer at `initialize`, most preferred first.
50
+ *
51
+ * A host picks the first entry it also speaks, so this is a preference rather
52
+ * than a floor. Offering one the installed library has no types for is safe:
53
+ * every command used here is stable across all of them.
54
+ *
55
+ * `1.0.0` is not published - VS Code's host vendors the protocol from its
56
+ * repository and runs ahead of npm - and it accepts `^1.0.0` and nothing 0.x.
57
+ * Leaving it out is therefore not the conservative choice: it is every entry
58
+ * refused with `-32005`, which arrives here looking like a host that is not
59
+ * there. `0.9.0` is the newest published, and the version the package below
60
+ * is built from.
61
+ *
62
+ * This list is load-bearing, because there is no fallback behind it.
63
+ */
64
+ const VERSIONS = ['1.0.0', '0.9.0', '0.8.0', '0.7.0'];
65
+ const ROOT = 'ahp-root://';
66
+ const AUTOMATIONS = 'ahp-automations://';
67
+ export class MissingProtocolPackage extends Error {
68
+ constructor() {
69
+ super('A live host needs @microsoft/agent-host-protocol. Install it:\n'
70
+ + ' npm install @microsoft/agent-host-protocol\n'
71
+ + 'Or leave --host off and drive the scripted one.');
72
+ this.name = 'MissingProtocolPackage';
73
+ }
74
+ }
75
+ /**
76
+ * The client's preferred language, as a BCP 47 tag.
77
+ *
78
+ * `lifecycle.md` says a server SHOULD use this to localise the strings a
79
+ * person reads - confirmation option labels among them. POSIX spells a locale
80
+ * `en_US.UTF-8`; BCP 47 wants `en-US`, so the encoding is dropped and the
81
+ * underscore becomes a hyphen. `C` and `POSIX` name no language and are sent
82
+ * as nothing rather than as a tag no server can read.
83
+ */
84
+ function locale() {
85
+ const found = process.env.LC_ALL ?? process.env.LC_MESSAGES ?? process.env.LANG;
86
+ if (found === undefined || found === '')
87
+ return undefined;
88
+ const tag = found.split('.')[0]?.replace(/_/g, '-');
89
+ if (tag === undefined || tag === '' || tag === 'C' || tag === 'POSIX')
90
+ return undefined;
91
+ return tag;
92
+ }
93
+ function tee(inner, heard) {
94
+ /*
95
+ * Every frame, to a file, when `AHPC_RECORD` names one.
96
+ *
97
+ * Both directions: `tools/validate.mjs` checks what a host sent *and* what
98
+ * this client sent, and until this existed the only captures to check were
99
+ * another client's traffic. Appended synchronously and deliberately - a
100
+ * recording that lost the frame a crash happened on would be a recording of
101
+ * everything except the interesting part.
102
+ */
103
+ const recording = process.env.AHPC_RECORD;
104
+ const write = (from, frame) => {
105
+ if (recording === undefined || recording === '')
106
+ return;
107
+ try {
108
+ appendFileSync(recording, `${JSON.stringify({ at: new Date().toISOString(), from, frame })}\n`);
109
+ }
110
+ catch { /* a recording is a convenience, never a reason to fail a call */ }
111
+ };
112
+ return {
113
+ send: (message) => {
114
+ write('client', typeof message === 'string' ? message : JSON.stringify(message));
115
+ return inner.send(message);
116
+ },
117
+ close: () => inner.close(),
118
+ recv: async () => {
119
+ const frame = await inner.recv();
120
+ if (frame === null)
121
+ return null;
122
+ write('host', frame.kind === 'text' ? frame.text : JSON.stringify(frame.message));
123
+ try {
124
+ const message = frame.kind === 'parsed'
125
+ ? bag(frame.message)
126
+ : frame.kind === 'text' ? bag(JSON.parse(frame.text ?? 'null')) : null;
127
+ // A notification is a message with a method and no id.
128
+ if (message === null)
129
+ return frame;
130
+ const method = str(message.method);
131
+ if (method !== undefined && message.id === undefined)
132
+ heard(method, bag(message.params));
133
+ }
134
+ catch { /* the client below reports a frame it cannot read */ }
135
+ return frame;
136
+ },
137
+ };
138
+ }
139
+ /**
140
+ * An OTLP value, as a string.
141
+ *
142
+ * OTLP wraps every attribute in a one-key object naming its type -
143
+ * `{ stringValue: "x" }`, `{ intValue: "3" }` - so a reader that took
144
+ * `value` would print `[object Object]` for all of them.
145
+ */
146
+ function otlpValue(value) {
147
+ const found = bag(value);
148
+ for (const key of ['stringValue', 'intValue', 'doubleValue', 'boolValue']) {
149
+ if (found[key] !== undefined)
150
+ return String(found[key]);
151
+ }
152
+ return '';
153
+ }
154
+ /** Every log record in one OTLP batch, flattened out of resource and scope. */
155
+ function logsOf(payload) {
156
+ const out = [];
157
+ for (const resource of list(bag(payload).resourceLogs)) {
158
+ const shared = {};
159
+ for (const attribute of list(bag(bag(resource).resource).attributes)) {
160
+ const one = bag(attribute);
161
+ if (str(one.key) !== undefined)
162
+ shared[str(one.key)] = otlpValue(one.value);
163
+ }
164
+ for (const scope of list(bag(resource).scopeLogs)) {
165
+ for (const raw of list(bag(scope).logRecords)) {
166
+ const record = bag(raw);
167
+ const attributes = { ...shared };
168
+ for (const attribute of list(record.attributes)) {
169
+ const one = bag(attribute);
170
+ if (str(one.key) !== undefined)
171
+ attributes[str(one.key)] = otlpValue(one.value);
172
+ }
173
+ // Nanoseconds since the epoch, as a string because it does not fit in
174
+ // a double. Divided down rather than parsed as a number.
175
+ const nanos = str(record.timeUnixNano);
176
+ out.push({
177
+ ...(nanos !== undefined ? { at: new Date(Number(nanos.slice(0, -6))).toISOString() } : {}),
178
+ ...(str(record.severityText) ? { severity: str(record.severityText) } : {}),
179
+ body: otlpValue(record.body),
180
+ attributes,
181
+ });
182
+ }
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+ /**
188
+ * The advertised telemetry URI, with `{level}` filled in.
189
+ *
190
+ * RFC 6570 in the general case; `{level}` is the only variable this
191
+ * specification defines, so only that one is expanded - `telemetry-channel.md`
192
+ * says the URI is otherwise opaque, and a client that started rewriting the
193
+ * rest of it would be parsing something it was told not to. The form-style
194
+ * `{?level}` expands to a query and the bare `{level}` to the value itself;
195
+ * with no level, both expand to nothing, which is what RFC 6570 says of an
196
+ * undefined variable.
197
+ */
198
+ function expandLevel(template, level) {
199
+ const value = level === undefined ? '' : encodeURIComponent(level);
200
+ return template
201
+ .replace(/\{\?level\}/g, value === '' ? '' : `?level=${value}`)
202
+ .replace(/\{level\}/g, value);
203
+ }
204
+ /**
205
+ * The published directory's refusals, as codes the host receives.
206
+ *
207
+ * `publish.ts` raises a `PublishRefusal` carrying the code the specification
208
+ * declares - `-32009` for what this client will not serve, `-32008` for what
209
+ * is not there. The package reads a code only off its own `RpcError` and
210
+ * answers `-32603 InternalError` for everything else, so without this the
211
+ * message arrives and the code does not, and a host cannot tell a read-only
212
+ * refusal from a path that does not exist.
213
+ *
214
+ * Mapped here rather than in `publish.ts`, which imports nothing from the
215
+ * protocol package and is tested without it.
216
+ */
217
+ function coded(ahp, handlers) {
218
+ const out = {};
219
+ for (const [method, handler] of Object.entries(handlers)) {
220
+ out[method] = async (params) => {
221
+ try {
222
+ return await handler(params);
223
+ }
224
+ catch (error) {
225
+ if (error instanceof PublishRefusal) {
226
+ const refusal = new ahp.RpcError(error.code, error.message);
227
+ /*
228
+ * The message back to what was written.
229
+ *
230
+ * `RpcError`'s constructor formats it - `RPC error <code>: <text>` -
231
+ * and the client puts `err.message` straight into the JSON-RPC
232
+ * `message` field, so the prefix travels. The receiver then builds
233
+ * its own `RpcError` from what arrived and prefixes again, and a
234
+ * refusal reads `RPC error -32009: RPC error -32009: ...`. The type
235
+ * is what carries the code, so this keeps the type and sends the
236
+ * sentence somebody wrote.
237
+ */
238
+ refusal.message = error.message;
239
+ throw refusal;
240
+ }
241
+ throw error;
242
+ }
243
+ };
244
+ }
245
+ return out;
246
+ }
247
+ async function load() {
248
+ const base = '@microsoft/agent-host-protocol';
249
+ try {
250
+ const core = await import(base);
251
+ const client = await import(`${base}/client`);
252
+ const ws = await import(`${base}/ws`);
253
+ const transport = ws.WebSocketTransport;
254
+ return {
255
+ Client: client.AhpClient,
256
+ createResourceRequestHandler: client.createResourceRequestHandler,
257
+ RpcError: client.RpcError,
258
+ Mirror: client.AhpStateMirror,
259
+ connect: (url) => transport.connect(url),
260
+ automationReducer: core.automationReducer,
261
+ chatReducer: core.chatReducer,
262
+ sessionReducer: core.sessionReducer,
263
+ terminalReducer: core.terminalReducer,
264
+ };
265
+ }
266
+ catch (error) {
267
+ // Node says `ERR_MODULE_NOT_FOUND` for a missing package and a missing
268
+ // file alike, and the message differs between the two ("Cannot find
269
+ // package", "Cannot find module"), so the code is what is tested.
270
+ const code = error?.code;
271
+ if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') {
272
+ throw new MissingProtocolPackage();
273
+ }
274
+ throw error;
275
+ }
276
+ }
277
+ const bag = (value) => (typeof value === 'object' && value !== null ? value : {});
278
+ const list = (value) => (Array.isArray(value) ? value : []);
279
+ const str = (value) => (typeof value === 'string' ? value : undefined);
280
+ /** `StringOrMarkdown` is a string or `{ markdown }`, and a reader wants neither. */
281
+ function plain(value) {
282
+ if (typeof value === 'string')
283
+ return value === '' ? undefined : value;
284
+ const found = str(bag(value).markdown) ?? str(bag(value).value);
285
+ return found === '' ? undefined : found;
286
+ }
287
+ /**
288
+ * One tool call, flattened out of an eight-state union.
289
+ *
290
+ * Which fields exist depends on the state - `toolInput` arrives when the
291
+ * parameters are complete, `pastTenseMessage` and `content` only after it ran -
292
+ * so everything is read defensively and what is not there yet is left out.
293
+ */
294
+ function toolCall(value) {
295
+ const call = bag(value);
296
+ const input = call.toolInput;
297
+ const content = list(call.content);
298
+ const text = content
299
+ .map((entry) => plain(bag(entry).text) ?? plain(bag(entry).preview))
300
+ .filter((entry) => entry !== undefined)
301
+ .join('\n');
302
+ const files = content
303
+ .map((entry) => str(bag(bag(entry).file).uri) ?? str(bag(entry).uri))
304
+ .filter((entry) => entry !== undefined);
305
+ return {
306
+ id: str(call.toolCallId) ?? randomUUID(),
307
+ name: str(call.displayName) ?? str(call.toolName) ?? 'tool',
308
+ toolName: str(call.toolName) ?? 'tool',
309
+ status: (str(call.status) ?? 'running'),
310
+ // A `ContentRef` is a promise of content rather than content: reporting
311
+ // nothing is better than reporting the reference as if it were the command.
312
+ ...(typeof input === 'string' ? { input } : {}),
313
+ ...(plain(call.intention) ?? plain(call.invocationMessage)
314
+ ? { intention: (plain(call.intention) ?? plain(call.invocationMessage)) }
315
+ : {}),
316
+ ...(plain(call.pastTenseMessage) ? { outcome: plain(call.pastTenseMessage) } : {}),
317
+ ...(text ? { output: text } : {}),
318
+ ...(files.length > 0 ? { files } : {}),
319
+ ...(plain(call.confirmationTitle) ? { confirmationTitle: plain(call.confirmationTitle) } : {}),
320
+ ...(list(call.options).length > 0
321
+ ? {
322
+ options: list(call.options).map((option) => ({
323
+ id: str(bag(option).id) ?? '',
324
+ label: str(bag(option).label) ?? str(bag(option).id) ?? '',
325
+ })),
326
+ }
327
+ : {}),
328
+ };
329
+ }
330
+ /** `responseParts` is one ordered stream, and the order is the reasoning. */
331
+ function parts(value) {
332
+ const out = [];
333
+ for (const entry of list(value)) {
334
+ const part = bag(entry);
335
+ const id = str(part.id) ?? randomUUID();
336
+ switch (str(part.kind)) {
337
+ // The prose is in `content`. Not `markdown`, not `text` - reading the
338
+ // wrong name costs every word the agent said.
339
+ case 'markdown':
340
+ out.push({ kind: 'markdown', id, content: str(part.content) ?? '' });
341
+ break;
342
+ case 'reasoning':
343
+ out.push({ kind: 'reasoning', id, content: str(part.content) ?? '' });
344
+ break;
345
+ case 'systemNotification':
346
+ out.push({ kind: 'systemNotification', id, content: plain(part.content) ?? '' });
347
+ break;
348
+ case 'toolCall': {
349
+ const call = toolCall(part.toolCall);
350
+ out.push({ kind: 'toolCall', id: call.id, call });
351
+ break;
352
+ }
353
+ // A turn that failed mid-stream, new in 0.9.0. It is a part rather than
354
+ // a turn state because what came before it still stands: the agent said
355
+ // three things and then hit this, and dropping it leaves a turn that
356
+ // simply stops.
357
+ case 'error': {
358
+ const error = bag(part.error);
359
+ out.push({
360
+ kind: 'error',
361
+ id,
362
+ message: str(error.message) ?? str(error.errorType) ?? 'The agent failed.',
363
+ resumable: part.resumable === true,
364
+ });
365
+ break;
366
+ }
367
+ default:
368
+ break;
369
+ }
370
+ }
371
+ return out;
372
+ }
373
+ function turn(value, running) {
374
+ const found = bag(value);
375
+ const message = bag(found.message);
376
+ const state = str(found.state);
377
+ return {
378
+ id: str(found.id) ?? randomUUID(),
379
+ role: 'agent',
380
+ ...(str(message.text) ? { message: str(message.text) } : {}),
381
+ parts: parts(found.responseParts),
382
+ state: running ? 'running'
383
+ : state === 'cancelled' ? 'cancelled'
384
+ : state === 'error' ? 'failed' : 'complete',
385
+ ...(selection(message.model, found.usage) ? { model: selection(message.model, found.usage) } : {}),
386
+ at: str(found.startedAt) ?? new Date(0).toISOString(),
387
+ ...(typeof found.duration === 'number' ? { elapsedMs: found.duration } : {}),
388
+ };
389
+ }
390
+ /**
391
+ * The activity bits, from what this client can see rather than what it was told.
392
+ *
393
+ * There is no `session/statusChanged` in the protocol. A subscribed client is
394
+ * told `session/activityChanged` - a word - and the reducer keeps that word in
395
+ * `activity` and deliberately leaves `status` alone; the only actions that
396
+ * move `status` are the input-needed pair and the read and archived flags. So
397
+ * on a live session the activity bits only ever go up. Answering a question
398
+ * clears `InputNeeded` and leaves `InProgress` set, which the reducer's own
399
+ * comment calls falling back to in-progress, and nothing afterwards takes it
400
+ * off - a session goes on saying it is working through every turn that
401
+ * follows, until something re-reads the catalogue.
402
+ *
403
+ * Everything the bits are about is already here: whether a turn is running,
404
+ * whether something is waiting on a person, and whether the last turn failed.
405
+ * The order is the host's own - what is wanted, then what is happening, then
406
+ * what went wrong - and the session's own flags are carried through untouched,
407
+ * because read and archived are not about activity at all.
408
+ */
409
+ export function activityOf(status, asked, running, failed) {
410
+ const flags = status & (SessionFlag.IsRead | SessionFlag.IsArchived);
411
+ if (asked)
412
+ return flags | SessionFlag.InputNeeded;
413
+ if (running)
414
+ return flags | SessionFlag.InProgress;
415
+ if (failed)
416
+ return flags | SessionFlag.Error;
417
+ return flags | SessionFlag.Idle;
418
+ }
419
+ /**
420
+ * The conversation, as the transcript reads it.
421
+ *
422
+ * A turn on the wire carries both what the person said and what the agent
423
+ * answered; a transcript wants them as two blocks, so the message becomes a
424
+ * user turn ahead of the agent's. And the running turn is `activeTurn`, not in
425
+ * `turns` - a client that reads only the history shows an empty conversation
426
+ * for exactly as long as somebody is watching one happen.
427
+ */
428
+ function transcript(chat) {
429
+ const out = [];
430
+ const add = (value, running) => {
431
+ const found = bag(value);
432
+ const said = str(bag(found.message).text);
433
+ if (said) {
434
+ out.push({
435
+ id: `${str(found.id) ?? ''}:said`,
436
+ role: 'user',
437
+ message: said,
438
+ parts: [],
439
+ state: 'complete',
440
+ at: str(found.startedAt) ?? new Date(0).toISOString(),
441
+ });
442
+ }
443
+ const agent = turn(found, running);
444
+ delete agent.message;
445
+ out.push(agent);
446
+ };
447
+ for (const entry of list(chat.turns))
448
+ add(entry, false);
449
+ if (chat.activeTurn)
450
+ add(chat.activeTurn, true);
451
+ return out;
452
+ }
453
+ const KINDS = {
454
+ text: 'text', number: 'number', integer: 'integer', boolean: 'boolean',
455
+ 'single-select': 'single-select', 'multi-select': 'multi-select',
456
+ };
457
+ function question(value) {
458
+ const found = bag(value);
459
+ return {
460
+ id: str(found.id) ?? randomUUID(),
461
+ kind: KINDS[str(found.kind) ?? 'text'] ?? 'text',
462
+ message: str(found.message) ?? str(found.title) ?? '',
463
+ ...(found.required === true ? { required: true } : {}),
464
+ ...(list(found.options).length > 0
465
+ ? {
466
+ options: list(found.options).map((option) => ({
467
+ id: str(bag(option).id) ?? '',
468
+ label: str(bag(option).label) ?? str(bag(option).id) ?? '',
469
+ })),
470
+ }
471
+ : {}),
472
+ ...(found.allowFreeformInput === true ? { allowFreeformInput: true } : {}),
473
+ };
474
+ }
475
+ /**
476
+ * Reduce one action, or say so and go on listening.
477
+ *
478
+ * A reducer is handed whatever the host sent, and a host that sends a
479
+ * malformed action throws inside it - `session/inputNeededSet` without its
480
+ * `request` is `action.request.id` on undefined, which is a `TypeError` and
481
+ * not an RPC error. Thrown out of a `for await` it rejects the whole loop and
482
+ * takes the subscription with it, so the session goes deaf: the block waiting
483
+ * on a person never clears, the status never moves, and the only sign of any
484
+ * of it is a sentence about a property of undefined on the status bar.
485
+ *
486
+ * One word this client cannot read is not a reason to stop reading the rest
487
+ * of them. The action is dropped, the state it would have changed is left as
488
+ * it was, and it is said out loud - because a client quietly ignoring what a
489
+ * host tells it is the other way to be wrong here.
490
+ */
491
+ export function applyAction(reduce, state, action, onBad) {
492
+ try {
493
+ return reduce(state, action);
494
+ }
495
+ catch (error) {
496
+ const said = error instanceof Error ? error.message : String(error);
497
+ const type = typeof action?.type === 'string'
498
+ ? action.type
499
+ : 'an action';
500
+ onBad(`The host sent ${type} in a shape this client cannot read: ${said}`);
501
+ return state;
502
+ }
503
+ }
504
+ /**
505
+ * What the agent is waiting for, if anything.
506
+ *
507
+ * `SessionState.inputNeeded` is the session-level summary and **not every host
508
+ * fills it in**: VS Code 1.132 shows its own dialog while the session reports
509
+ * status 40 and no `inputNeeded` key at all. The tool call is unambiguous
510
+ * though - `pending-confirmation` carries the title, the input and the options -
511
+ * so the chat is scanned when the field is empty.
512
+ */
513
+ function pendingInput(session, chat) {
514
+ for (const entry of list(session.inputNeeded)) {
515
+ const found = bag(entry);
516
+ const id = str(found.id) ?? randomUUID();
517
+ if (str(found.kind) === 'toolConfirmation') {
518
+ return { kind: 'toolConfirmation', id, call: toolCall(found.toolCall) };
519
+ }
520
+ if (str(found.kind) === 'chatInput') {
521
+ const request = bag(found.request);
522
+ return {
523
+ kind: 'chatInput',
524
+ // The request's own id answers it, not the entry's.
525
+ id: str(request.id) ?? id,
526
+ message: str(request.message) ?? '',
527
+ questions: list(request.questions).map(question),
528
+ };
529
+ }
530
+ }
531
+ const active = bag(chat.activeTurn);
532
+ for (const part of list(active.responseParts)) {
533
+ const found = bag(part);
534
+ if (str(found.kind) !== 'toolCall')
535
+ continue;
536
+ const call = bag(found.toolCall);
537
+ if (str(call.status) !== 'pending-confirmation')
538
+ continue;
539
+ const flat = toolCall(call);
540
+ return { kind: 'toolConfirmation', id: flat.id, call: flat };
541
+ }
542
+ return null;
543
+ }
544
+ function summary(value) {
545
+ const found = bag(value);
546
+ const changes = bag(found.changes);
547
+ return {
548
+ resource: str(found.resource) ?? '',
549
+ provider: str(found.provider) ?? 'unknown',
550
+ title: str(found.title) ?? 'Untitled session',
551
+ status: typeof found.status === 'number' ? found.status : 1,
552
+ createdAt: str(found.createdAt) ?? '',
553
+ modifiedAt: str(found.modifiedAt) ?? str(found.createdAt) ?? '',
554
+ workingDirectories: list(found.workingDirectories).filter((dir) => typeof dir === 'string'),
555
+ ...(str(found.activity) ? { activity: str(found.activity) } : {}),
556
+ // What started it, when it was not a person. Only `automation` exists in
557
+ // 0.9.0, and an origin of some later kind is left off rather than drawn
558
+ // as one - a catalogue that called an unknown origin an automation would
559
+ // be making something up.
560
+ ...(str(bag(found.origin).automation)
561
+ ? {
562
+ origin: {
563
+ kind: 'automation',
564
+ automation: str(bag(found.origin).automation),
565
+ run: str(bag(found.origin).run) ?? '',
566
+ },
567
+ }
568
+ : {}),
569
+ // Both only when the host said them: a project with an empty name would
570
+ // draw a blank where the directory used to be, which is worse than the
571
+ // fallback it replaced.
572
+ ...(str(bag(found.project).displayName)
573
+ ? {
574
+ project: {
575
+ uri: str(bag(found.project).uri) ?? '',
576
+ displayName: str(bag(found.project).displayName),
577
+ },
578
+ }
579
+ : {}),
580
+ ...(found._meta && typeof found._meta === 'object'
581
+ ? { _meta: found._meta }
582
+ : {}),
583
+ ...(found.changes
584
+ ? {
585
+ changes: {
586
+ ...(typeof changes.files === 'number' ? { files: changes.files } : {}),
587
+ ...(typeof changes.additions === 'number' ? { additions: changes.additions } : {}),
588
+ ...(typeof changes.deletions === 'number' ? { deletions: changes.deletions } : {}),
589
+ },
590
+ }
591
+ : {}),
592
+ };
593
+ }
594
+ /** One run, flattened out of its lifecycle and its origin. */
595
+ function automationRun(value) {
596
+ const found = bag(value);
597
+ return {
598
+ resource: str(found.resource) ?? '',
599
+ status: str(bag(found.lifecycle).status) ?? 'pending',
600
+ ...(str(found.primarySession) ? { session: str(found.primarySession) } : {}),
601
+ triggered: str(bag(found.origin).kind) === 'trigger',
602
+ };
603
+ }
604
+ /**
605
+ * One automation, flattened.
606
+ *
607
+ * The schedule is read out of the *definition*, which is the client's own
608
+ * writing given back - so this reads the first schedule trigger and shows what
609
+ * it says. `nextRunAt` is the host's, and the two disagreeing is the useful
610
+ * case rather than a contradiction: an expression that is written down and is
611
+ * never going to fire is exactly what a reader needs to see.
612
+ */
613
+ function automation(value) {
614
+ const found = bag(value);
615
+ const definition = bag(found.definition);
616
+ const schedule = list(definition.triggers)
617
+ .map(bag)
618
+ .find((trigger) => trigger.kind === 'schedule');
619
+ const timing = bag(schedule?.schedule);
620
+ return {
621
+ resource: str(found.resource) ?? '',
622
+ title: str(definition.title) ?? 'Untitled automation',
623
+ // Absent means on. It is the definition's own default, and a client that
624
+ // read a missing key as off would switch off everything it was shown.
625
+ enabled: definition.enabled !== false,
626
+ ...(str(timing.expression)
627
+ ? {
628
+ schedule: {
629
+ expression: str(timing.expression),
630
+ timeZone: str(timing.timeZone) ?? 'UTC',
631
+ },
632
+ }
633
+ : {}),
634
+ ...(str(found.nextRunAt) ? { nextRunAt: str(found.nextRunAt) } : {}),
635
+ runs: list(found.runs).map(automationRun),
636
+ operations: list(found.operations).filter((one) => typeof one === 'string'),
637
+ };
638
+ }
639
+ /**
640
+ * The config schema, flattened to what a form needs.
641
+ *
642
+ * `enumLabels` and `enumDescriptions` are arrays parallel to `enum`, so they
643
+ * are read by index rather than looked up by name. `sessionMutable` decides
644
+ * whether a control is offered at all: absent means not changeable while the
645
+ * session runs, and the cautious reading is the correct one.
646
+ */
647
+ function config(value) {
648
+ const found = bag(value);
649
+ const schema = bag(found.schema);
650
+ const properties = bag(schema.properties);
651
+ const values = bag(found.values);
652
+ return {
653
+ properties: Object.entries(properties).map(([key, raw]) => {
654
+ const property = bag(raw);
655
+ const labels = list(property.enumLabels);
656
+ const descriptions = list(property.enumDescriptions);
657
+ return {
658
+ key,
659
+ title: str(property.title) ?? key,
660
+ ...(str(property.description) ? { description: str(property.description) } : {}),
661
+ values: list(property.enum).map((entry, index) => ({
662
+ value: String(entry),
663
+ label: str(labels[index]) ?? String(entry),
664
+ ...(str(descriptions[index]) ? { description: str(descriptions[index]) } : {}),
665
+ })),
666
+ sessionMutable: property.sessionMutable === true,
667
+ ...(property.enumDynamic === true ? { enumDynamic: true } : {}),
668
+ ...(str(property.default) ? { default: str(property.default) } : {}),
669
+ };
670
+ }),
671
+ values: Object.fromEntries(Object.entries(values).map(([key, entry]) => [key, String(entry)])),
672
+ };
673
+ }
674
+ /**
675
+ * What a turn was asked for, and failing that what it was reported as using.
676
+ *
677
+ * `Message.model` is where the protocol says a turn's model is recorded, and
678
+ * a host may leave it empty - one captured conversation has it absent on every
679
+ * turn with the model in `usage.model` instead, which is declared as "model
680
+ * used" and is a plain string rather than a `ModelSelection`. So both are
681
+ * read, asked for first: one says what was requested and the other what
682
+ * answered, and a client that read only the first showed no model at all.
683
+ *
684
+ * The settings are dropped by that second path, because usage does not carry
685
+ * any - which is honest. A thinking level takes effect from the turn that
686
+ * names it, so two answers from one model are two different questions, and a
687
+ * host that does not record which cannot be made to have done.
688
+ *
689
+ * Values are flattened to strings because that is what a form returns and what
690
+ * every reader here shows; a host that sends a number sends one this can
691
+ * print.
692
+ */
693
+ function selection(value, usage) {
694
+ const found = bag(value);
695
+ const id = str(found.id) ?? str(bag(usage).model);
696
+ if (id === undefined)
697
+ return undefined;
698
+ const config = Object.entries(bag(found.config))
699
+ .filter(([, one]) => one !== null && typeof one !== 'object')
700
+ .map(([key, one]) => [key, String(one)]);
701
+ return {
702
+ id,
703
+ ...(config.length > 0 ? { config: Object.fromEntries(config) } : {}),
704
+ };
705
+ }
706
+ /** A selection as it goes out: the id, and the answers it was given. */
707
+ function selectionOf(model) {
708
+ return {
709
+ id: model.id,
710
+ ...(model.config && Object.keys(model.config).length > 0 ? { config: model.config } : {}),
711
+ };
712
+ }
713
+ /**
714
+ * One model, wherever it appears.
715
+ *
716
+ * `configSchema` is a schema on its own rather than the `{ schema, values }`
717
+ * a session's config arrives as, so it is wrapped rather than read a second
718
+ * way - it is the same document, and a second decoder for it would be a
719
+ * second set of rules about `enumLabels`.
720
+ */
721
+ function model(value) {
722
+ const found = bag(value);
723
+ const options = config({ schema: found.configSchema }).properties;
724
+ return {
725
+ id: str(found.id) ?? '',
726
+ // `name`, which is what `SessionModelInfo` calls the readable one -
727
+ // reading `displayName` here (the *agent's* field) meant every model fell
728
+ // through to its id, and a host's ids are things like
729
+ // `claude-sonnet-4-5-20250929`.
730
+ displayName: str(found.name) ?? str(found.displayName) ?? str(found.id) ?? '',
731
+ provider: str(found.provider) ?? '',
732
+ ...(options.length > 0 ? { options } : {}),
733
+ };
734
+ }
735
+ /** What an invocation said for itself. Failure is the rejection, not a field. */
736
+ function decodeInvoked(value) {
737
+ const found = bag(value);
738
+ // `message` may be a string or a `{ markdown }`, because the protocol's
739
+ // `StringOrMarkdown` is either.
740
+ const said = str(found.message) ?? str(bag(found.message).markdown);
741
+ return said !== undefined ? { message: said } : {};
742
+ }
743
+ function changeset(value) {
744
+ const found = bag(value);
745
+ return {
746
+ status: str(found.status) === 'computing' ? 'computing' : 'complete',
747
+ files: list(found.files).map((entry) => {
748
+ const edit = bag(bag(entry).edit);
749
+ const before = str(bag(edit.before).uri);
750
+ const after = str(bag(edit.after).uri);
751
+ const diff = bag(edit.diff);
752
+ // The pointers, not the bytes. Kept so a row that is opened has
753
+ // somewhere to fetch from, and nothing is fetched until one is.
754
+ const refs = {
755
+ ...(contentRef(bag(edit.before).content) ? { before: contentRef(bag(edit.before).content) } : {}),
756
+ ...(contentRef(bag(edit.after).content) ? { after: contentRef(bag(edit.after).content) } : {}),
757
+ };
758
+ return {
759
+ uri: after ?? before ?? '',
760
+ ...(bag(entry).reviewed === true ? { reviewed: true } : {}),
761
+ ...(before ? { before } : {}),
762
+ ...(after ? { after } : {}),
763
+ diff: {
764
+ added: typeof diff.added === 'number' ? diff.added : 0,
765
+ removed: typeof diff.removed === 'number' ? diff.removed : 0,
766
+ },
767
+ ...(Object.keys(refs).length > 0 ? { content: refs } : {}),
768
+ };
769
+ }),
770
+ ...(list(found.operations).length > 0 ? { operations: operations(found.operations) } : {}),
771
+ };
772
+ }
773
+ /**
774
+ * The verbs, as the host advertises them.
775
+ *
776
+ * `status` defaults to `idle` rather than being dropped when a host leaves it
777
+ * out: the protocol says an absent status means ready, and a control drawn
778
+ * with no state at all is one nobody can tell from a disabled one.
779
+ */
780
+ function operations(value) {
781
+ return list(value).map((entry) => {
782
+ const one = bag(entry);
783
+ const status = str(one.status);
784
+ return {
785
+ id: str(one.id) ?? '',
786
+ label: str(one.label) ?? str(one.id) ?? '',
787
+ ...(str(one.description) ? { description: str(one.description) } : {}),
788
+ scopes: list(one.scopes)
789
+ .filter((scope) => typeof scope === 'string')
790
+ .filter((scope) => scope === 'changeset' || scope === 'resource' || scope === 'range'),
791
+ // Carried whatever it is: the protocol says a client MUST show it before
792
+ // invoking, so dropping it would be deleting somebody's work unasked.
793
+ ...(str(one.confirmation) ? { confirmation: str(one.confirmation) } : {}),
794
+ ...(str(one.icon) ? { icon: str(one.icon) } : {}),
795
+ ...(str(one.group) ? { group: str(one.group) } : {}),
796
+ status: status === 'running' || status === 'error' || status === 'disabled' ? status : 'idle',
797
+ ...(str(bag(one.error).message) ? { error: { message: str(bag(one.error).message) } } : {}),
798
+ };
799
+ });
800
+ }
801
+ function contentRef(value) {
802
+ const found = bag(value);
803
+ const uri = str(found.uri);
804
+ if (!uri)
805
+ return undefined;
806
+ return {
807
+ uri,
808
+ ...(typeof found.sizeHint === 'number' ? { sizeHint: found.sizeHint } : {}),
809
+ ...(str(found.contentType) ? { contentType: str(found.contentType) } : {}),
810
+ };
811
+ }
812
+ /**
813
+ * The customization tree, flattened.
814
+ *
815
+ * Containers come first and then what each one brought, so the list reads in
816
+ * the order somebody would draw it: the plugin, then its skills. An MCP server
817
+ * is the one kind that turns up at both levels - contributed by a plugin, or
818
+ * by the host directly - and it is the same shape either way, so it is decoded
819
+ * once and placed twice.
820
+ *
821
+ * `enablement[0]` is the decisive decision. The protocol requires producers to
822
+ * sort by descending specificity, so the session's answer is first when there
823
+ * is one and the global one is all there is otherwise. Reading past the head
824
+ * of that array would be preferring a broader scope to a narrower one.
825
+ */
826
+ function customizations(value) {
827
+ const out = [];
828
+ const decide = (entry, within) => {
829
+ const explicit = list(entry.enablement).map(bag)[0];
830
+ const own = explicit !== undefined
831
+ ? explicit.enabled !== false
832
+ : typeof entry.enabled === 'boolean' ? entry.enabled : true;
833
+ // Both, never one: a child's own flag says nothing about whether the
834
+ // plugin that brought it is switched on.
835
+ return within && own;
836
+ };
837
+ const one = (entry, from, within) => {
838
+ const kind = str(entry.type);
839
+ const id = str(entry.id);
840
+ if (!kind || !id)
841
+ return null;
842
+ const state = bag(entry.state);
843
+ return {
844
+ id,
845
+ kind,
846
+ name: str(entry.name) ?? id,
847
+ uri: str(entry.uri) ?? '',
848
+ enabled: decide(entry, within),
849
+ ...(from ? { from } : {}),
850
+ ...(plain(entry.description) ? { description: plain(entry.description) } : {}),
851
+ ...(kind === 'skill' || kind === 'agent'
852
+ ? { userInvocable: entry.disableUserInvocation !== true }
853
+ : {}),
854
+ ...(kind === 'mcpServer' && str(state.kind)
855
+ ? { state: str(state.kind) }
856
+ : {}),
857
+ // `message` on the degraded and error states, and the auth reason when a
858
+ // server is only waiting to be signed into. Either way it is the host
859
+ // saying why, which is the whole value of showing the row at all.
860
+ ...(plain(state.message) ?? str(state.reason)
861
+ ? { problem: (plain(state.message) ?? str(state.reason)) }
862
+ : {}),
863
+ };
864
+ };
865
+ for (const raw of list(value)) {
866
+ const entry = bag(raw);
867
+ const container = one(entry, undefined, true);
868
+ if (!container)
869
+ continue;
870
+ // A container's own load failure is worth carrying: a plugin that did not
871
+ // parse contributes nothing, and a panel that showed it as merely empty
872
+ // would be hiding the reason.
873
+ const load = bag(entry.load);
874
+ if (plain(load.message))
875
+ container.problem = plain(load.message);
876
+ out.push(container);
877
+ for (const rawChild of list(entry.children)) {
878
+ const child = one(bag(rawChild), container.name, container.enabled);
879
+ if (child)
880
+ out.push(child);
881
+ }
882
+ }
883
+ return out;
884
+ }
885
+ /**
886
+ * The queue, as the host has it.
887
+ *
888
+ * `queuedMessages` is on the chat, so it arrives through the same reducer as
889
+ * everything else and needs no bookkeeping here - which is the point of
890
+ * queueing through the protocol rather than in the client: a message queued
891
+ * from an editor shows up in this list too.
892
+ */
893
+ function queued(chat) {
894
+ return list(chat.queuedMessages).map((entry) => {
895
+ const found = bag(entry);
896
+ return { id: str(found.id) ?? '', text: str(bag(found.message).text) ?? '' };
897
+ }).filter((message) => message.id !== '');
898
+ }
899
+ /** The typed answer the protocol wants, built from the question that was asked. */
900
+ function answerValue(answer) {
901
+ return { state: 'submitted', value: { kind: answer.kind, value: answer.value } };
902
+ }
903
+ // ------------------------------------------------------------- the connection
904
+ export async function liveHost(options) {
905
+ const ahp = await load();
906
+ const endpoint = options.token
907
+ ? `${options.url}${options.url.includes('?') ? '&' : '?'}tkn=${encodeURIComponent(options.token)}`
908
+ : options.url;
909
+ let state = 'connecting';
910
+ const moveTo = (next) => { state = next; options.onState?.(next); };
911
+ /**
912
+ * This client's name to the host, for the life of the process.
913
+ *
914
+ * `reconnect` is addressed by it: a fresh one each time the socket comes
915
+ * back is a host asked to resume a client it has never heard of, which it
916
+ * answers by refusing - so the identity has to outlive the connection it
917
+ * was first used on.
918
+ */
919
+ const clientId = options.clientId ?? `ahpc-${randomUUID().slice(0, 8)}`;
920
+ const openTransport = options.connect ?? (() => ahp.connect(endpoint));
921
+ const backoff = options.backoff ?? BACKOFF;
922
+ const keepaliveMs = options.keepaliveMs ?? KEEPALIVE_MS;
923
+ /**
924
+ * Work a host is doing that has been given a token to report against.
925
+ *
926
+ * `root-channel.md`: `progress` is monotonically non-decreasing for a token,
927
+ * the operation is complete when `progress === total`, and the host MUST
928
+ * send a final frame satisfying that - so a token is forgotten on the frame
929
+ * that closes it rather than on a timer. `total` absent means the magnitude
930
+ * is not known and a client SHOULD show an indeterminate indicator.
931
+ */
932
+ const working = new Map();
933
+ /** Whoever is following the host's log, which arrives past the protocol client. */
934
+ const logReaders = new Set();
935
+ const notified = (method, params) => {
936
+ if (method === 'auth/required') {
937
+ const one = bag(params.resource);
938
+ const resource = str(one.resource);
939
+ if (resource === undefined)
940
+ return;
941
+ options.onAuthRequired?.([{ resource, ...(str(one.description) ? { description: str(one.description) } : {}) }], str(params.reason));
942
+ return;
943
+ }
944
+ if (method === 'otlp/exportLogs') {
945
+ if (options.onLog === undefined && logReaders.size === 0)
946
+ return;
947
+ for (const one of logsOf(params.payload)) {
948
+ options.onLog?.(one);
949
+ for (const reader of logReaders)
950
+ reader(one);
951
+ }
952
+ return;
953
+ }
954
+ if (method !== 'root/progress')
955
+ return;
956
+ const token = str(params.progressToken);
957
+ if (token === undefined)
958
+ return;
959
+ const done = typeof params.total === 'number' && params.progress === params.total;
960
+ if (done) {
961
+ working.delete(token);
962
+ options.onProgress?.(token, null);
963
+ return;
964
+ }
965
+ // The host's own words, which `root-channel.md` says a generic client MAY
966
+ // show verbatim - this client has no label of its own for work it did not
967
+ // name. The share is left out where no total was given rather than
968
+ // guessed at.
969
+ const said = str(params.message) ?? 'Working';
970
+ const share = typeof params.total === 'number' && params.total > 0
971
+ ? ` ${Math.round((Number(params.progress) / params.total) * 100)}%`
972
+ : '';
973
+ working.set(token, `${said}${share}`);
974
+ options.onProgress?.(token, `${said}${share}`);
975
+ };
976
+ const transport = tee(await openTransport(), notified);
977
+ let client = new ahp.Client(transport, {});
978
+ /*
979
+ * What a host may ask this client for.
980
+ *
981
+ * The protocol is symmetrical and the package answers `-32601` to every
982
+ * server-initiated method until a handler is installed. Installing one is
983
+ * what makes this client an implementation of the reverse direction rather
984
+ * than a client that happens not to crash: a published directory answers,
985
+ * and everything else is refused with the code the specification declares
986
+ * for a refusal instead of the one for a method that does not exist.
987
+ */
988
+ // Under this connection's own id, which is the authority a host routes on.
989
+ const serving = (options.publish ?? publish()).as(clientId);
990
+ const answering = ahp.createResourceRequestHandler(coded(ahp, serving.handlers()));
991
+ const mirror = new ahp.Mirror();
992
+ client.setServerRequestHandler(answering);
993
+ client.connect();
994
+ /*
995
+ * The root channel, asked for in the handshake rather than after it.
996
+ *
997
+ * `lifecycle.md` gives `initialSubscriptions` as part of `initialize` and
998
+ * the root channel's own page says a client SHOULD subscribe to it that way.
999
+ * It saves a round trip on every connection, and the reconnect path already
1000
+ * had to do this on its `initialize` fallback - only the first connection
1001
+ * was still asking twice.
1002
+ */
1003
+ /** Channels the handshake opened, with the state it answered. */
1004
+ const adopted = new Map();
1005
+ const hello = bag(await client.initialize({
1006
+ clientId,
1007
+ protocolVersions: VERSIONS,
1008
+ clientInfo: { name: 'ahpc' },
1009
+ initialSubscriptions: [ROOT],
1010
+ ...(locale() !== undefined ? { locale: locale() } : {}),
1011
+ }));
1012
+ for (const snapshot of list(hello.snapshots))
1013
+ mirror.applySnapshot(snapshot);
1014
+ moveTo('connected');
1015
+ /** True once `close` has been called, so a deliberate hang-up is not retried. */
1016
+ let finished = false;
1017
+ /**
1018
+ * The reason a host gave, in the words it used.
1019
+ *
1020
+ * `-32001` is "no agent for this session", `-32007` is "authentication is
1021
+ * required to use Claude". They want opposite things from a person - forget
1022
+ * this session, or go and sign in on the host - so the message is carried
1023
+ * through rather than replaced with one of ours.
1024
+ */
1025
+ const reason = (error) => {
1026
+ const rpc = error;
1027
+ const message = rpc?.message ?? String(error);
1028
+ /*
1029
+ * A `-32007` says which resources need signing into, in its `data`.
1030
+ *
1031
+ * `authentication.md` puts an `AuthRequiredErrorData` there and says the
1032
+ * error MAY come back from **any** command, not only `authenticate` - so
1033
+ * this is read wherever a refusal is turned into words rather than at one
1034
+ * call site. Dropping it left a person told that authentication was
1035
+ * required and not told to what.
1036
+ */
1037
+ if (rpc?.code === -32007) {
1038
+ const resources = list(bag(rpc.data).resources).map((raw) => {
1039
+ const one = bag(raw);
1040
+ return {
1041
+ resource: str(one.resource) ?? '',
1042
+ ...(str(one.description) ? { description: str(one.description) } : {}),
1043
+ };
1044
+ }).filter((one) => one.resource !== '');
1045
+ if (resources.length > 0)
1046
+ options.onAuthRequired?.(resources);
1047
+ }
1048
+ return typeof rpc?.code === 'number' ? `${message} (${rpc.code})` : message;
1049
+ };
1050
+ /**
1051
+ * Every channel this client is holding open, and who is reading each one.
1052
+ *
1053
+ * Between the protocol client and the screens: it counts the readers of a
1054
+ * channel so the last one leaving is what sends `unsubscribe`, and it holds
1055
+ * the set that has to be named to `reconnect` when the socket comes back.
1056
+ */
1057
+ // What the handshake already answered for, so the first reader of the root
1058
+ // channel is handed that snapshot instead of asking for a second one.
1059
+ for (const snapshot of list(hello.snapshots)) {
1060
+ const one = bag(snapshot);
1061
+ const uri = str(one.resource);
1062
+ if (uri !== undefined)
1063
+ adopted.set(uri, bag(one.state));
1064
+ }
1065
+ const channels = openChannels({
1066
+ client,
1067
+ reason,
1068
+ lingerMs: options.lingerMs ?? LINGER_MS,
1069
+ onRefusal: (uri, message) => options.onRefusal?.(uri, message),
1070
+ clientId,
1071
+ // A refused action reaches a person the same way a refused channel does.
1072
+ // Both are the host saying no in its own words, and neither is a fault
1073
+ // here to be dressed up as one.
1074
+ onRejection: (uri, message) => options.onRefusal?.(uri, message),
1075
+ });
1076
+ for (const [uri, state] of adopted)
1077
+ channels.adopt(uri, state);
1078
+ channels.drain(client);
1079
+ /**
1080
+ * Watchers of the catalogue.
1081
+ *
1082
+ * The root channel is already being drained for the agents it advertises,
1083
+ * and every session that appears, finishes or starts waiting arrives on it
1084
+ * as an action. Nothing was told: the catalogue only got fresh when somebody
1085
+ * navigated away and back, which is a reader doing by hand what the host had
1086
+ * already said.
1087
+ */
1088
+ const catalogue = new Set();
1089
+ channels.open(ROOT, {
1090
+ opened: (root) => { if (root)
1091
+ mirror.applySnapshot({ resource: ROOT, state: root }); },
1092
+ event: (event) => {
1093
+ // Actions carry root state and the notifications carry the catalogue.
1094
+ // Both mean "something over there moved, read it again", which is true
1095
+ // of every kind of them and is one request either way.
1096
+ if (event.type === 'action')
1097
+ mirror.apply(event.params);
1098
+ else if (event.type !== 'sessionAdded' && event.type !== 'sessionRemoved'
1099
+ && event.type !== 'sessionSummaryChanged')
1100
+ return;
1101
+ for (const listener of catalogue)
1102
+ listener();
1103
+ },
1104
+ });
1105
+ /**
1106
+ * The automations channel, watched for as long as this connection lives.
1107
+ *
1108
+ * Subscribed once at connect rather than when the screen opens, for the same
1109
+ * reason the root channel is: the change worth hearing about is the one
1110
+ * nobody made, and an automation that fires at nine in the morning has to
1111
+ * reach a client that was not looking at the time.
1112
+ *
1113
+ * A host that serves none refuses this, and the refusal is *kept* rather
1114
+ * than retried - it is an answer about what this host is, and it will not
1115
+ * become a different answer on the next keystroke.
1116
+ */
1117
+ /**
1118
+ * The automations catalogue, under whichever name the host's version gives it.
1119
+ *
1120
+ * This is the whole of what changed between protocol 0.9.0 and 1.0.0 in
1121
+ * anything this client reads. 0.9.0 calls the catalogue `AutomationState`
1122
+ * and puts the automations in `entries`; 1.0.0 renames the catalogue to
1123
+ * `AutomationCatalogState` and the field to `automations`, and moves the
1124
+ * name `AutomationState` onto a single automation. The automations
1125
+ * themselves did not move - 0.9.0's `AutomationEntry` and 1.0.0's
1126
+ * `AutomationState` have the same fields, and every action on the channel
1127
+ * kept its name and its shape.
1128
+ *
1129
+ * So one field is normalised here, at the edge, and everything past this
1130
+ * point - the reducer included, which is the 0.9.0 one and reads `entries` -
1131
+ * carries on unaware there was ever a second spelling.
1132
+ */
1133
+ const automationCatalogue = (state) => {
1134
+ if (state === null)
1135
+ return null;
1136
+ if (state.entries !== undefined || state.automations === undefined)
1137
+ return state;
1138
+ const { automations, ...rest } = state;
1139
+ return { ...rest, entries: automations };
1140
+ };
1141
+ const automationWatchers = new Set();
1142
+ let automationState = null;
1143
+ let noAutomations;
1144
+ /*
1145
+ * Asked for only where the host said it had them.
1146
+ *
1147
+ * `InitializeResult.automations` is what *permits* a client to use the
1148
+ * channel and the three commands, so its absence is the answer and asking
1149
+ * anyway is a request with a known reply. It is not a harmless one either:
1150
+ * a host that routes an unknown channel to its session table answers
1151
+ * `-32001` about a session nobody named, which is a refusal a person then
1152
+ * has to be told to ignore.
1153
+ */
1154
+ if (hello.automations === undefined || hello.automations === null) {
1155
+ noAutomations = 'This host serves no automations.';
1156
+ }
1157
+ else {
1158
+ channels.open(AUTOMATIONS, {
1159
+ opened: (state) => { automationState = automationCatalogue(state); },
1160
+ event: (event) => {
1161
+ if (event.type !== 'action')
1162
+ return;
1163
+ // The host's own reducer. Two mutations is not eighty, but a second
1164
+ // answer to "what is the state now" is a second answer at any size.
1165
+ automationState = bag(ahp.automationReducer(automationState, bag(event.params).action));
1166
+ for (const listener of automationWatchers)
1167
+ listener();
1168
+ },
1169
+ refused: (message) => { noAutomations = message; },
1170
+ });
1171
+ }
1172
+ /**
1173
+ * A model id, resolved against what the root channel advertises.
1174
+ *
1175
+ * A turn names an id and nothing else, and the name, the harness and the
1176
+ * model's own options live on the catalogue row. Unresolved is a real
1177
+ * answer rather than a failure - a host whose harness nobody has signed
1178
+ * into advertises no models at all - and an id that matches nothing stands
1179
+ * in for itself rather than disappearing.
1180
+ */
1181
+ const known = (id) => {
1182
+ for (const entry of list(mirror.root.agents)) {
1183
+ for (const raw of list(bag(entry).models)) {
1184
+ const row = model(raw);
1185
+ if (row.id !== id)
1186
+ continue;
1187
+ return row.provider === ''
1188
+ ? { ...row, provider: str(bag(entry).provider) ?? str(bag(entry).id) ?? '' }
1189
+ : row;
1190
+ }
1191
+ }
1192
+ return { id, displayName: id, provider: '' };
1193
+ };
1194
+ /**
1195
+ * What the host says it protects, across every agent it advertises.
1196
+ *
1197
+ * `AgentInfo.protectedResources` is the static half of where a `resource`
1198
+ * may come from; the other half is a live MCP challenge, which arrives as
1199
+ * `auth/required` rather than being listable.
1200
+ */
1201
+ const advertised = async () => {
1202
+ const found = new Map();
1203
+ for (const entry of list(mirror.root.agents)) {
1204
+ for (const raw of list(bag(entry).protectedResources)) {
1205
+ const one = bag(raw);
1206
+ const resource = str(one.resource);
1207
+ if (resource === undefined)
1208
+ continue;
1209
+ found.set(resource, {
1210
+ resource,
1211
+ ...(str(one.resource_name) ? { name: str(one.resource_name) } : {}),
1212
+ ...(list(one.scopes_supported).length > 0
1213
+ ? { scopes: list(one.scopes_supported).filter((x) => typeof x === 'string') }
1214
+ : {}),
1215
+ });
1216
+ }
1217
+ }
1218
+ return [...found.values()];
1219
+ };
1220
+ /** The chat a session dispatches to, remembered so it is asked for once. */
1221
+ const chats = new Map();
1222
+ /**
1223
+ * Wait, unless the client is being closed while waiting.
1224
+ *
1225
+ * A backoff of half a minute is half a minute a person can spend quitting,
1226
+ * and a timer nobody cancels holds the process open after they have.
1227
+ */
1228
+ const timers = new Set();
1229
+ const pause = (ms) => new Promise((resolve) => {
1230
+ const timer = setTimeout(() => { timers.delete(timer); resolve(); }, ms);
1231
+ timers.add(timer);
1232
+ });
1233
+ /**
1234
+ * Take the connection back after it drops, and say so while it is gone.
1235
+ *
1236
+ * A dropped socket is a pause rather than an ending: the host holds the
1237
+ * sessions, so what is lost is this client's view of them and not the work.
1238
+ * The identity and the held channels are carried across, `reconnect` asks
1239
+ * for what was missed, and the catalogue is re-read because protocol
1240
+ * notifications are never replayed - a session created while this client
1241
+ * was away is announced once, to a client that was not there to hear it.
1242
+ */
1243
+ void (async () => {
1244
+ for (;;) {
1245
+ let dropped = false;
1246
+ try {
1247
+ for await (const change of client.stateChanges()) {
1248
+ if (change.status !== 'closed')
1249
+ continue;
1250
+ dropped = change.reason?.type !== 'shutdown';
1251
+ break;
1252
+ }
1253
+ }
1254
+ catch {
1255
+ dropped = true;
1256
+ }
1257
+ if (finished || !dropped)
1258
+ return;
1259
+ channels.detach();
1260
+ moveTo('connecting');
1261
+ for (let attempt = 0; !finished; attempt += 1) {
1262
+ await pause(backoff[Math.min(attempt, backoff.length - 1)] ?? 0);
1263
+ if (finished)
1264
+ return;
1265
+ try {
1266
+ const socket = tee(await openTransport(), notified);
1267
+ const fresh = new ahp.Client(socket, {});
1268
+ fresh.setServerRequestHandler(answering);
1269
+ fresh.connect();
1270
+ const held = channels.held();
1271
+ let answer = null;
1272
+ try {
1273
+ answer = bag(await fresh.reconnect({
1274
+ clientId,
1275
+ lastSeenServerSeq: channels.seq(),
1276
+ subscriptions: held,
1277
+ }));
1278
+ }
1279
+ catch (error) {
1280
+ // A host that will not resume this client is the ordinary case
1281
+ // rather than a failure: a daemon restarted between the drop and
1282
+ // now has never heard of this `clientId`, and answers so. What
1283
+ // cannot be resumed is started again - retrying `reconnect` at a
1284
+ // host that has forgotten us is a loop with no end in it.
1285
+ if (!isRpcRefusal(error))
1286
+ throw error;
1287
+ await fresh.initialize({
1288
+ clientId,
1289
+ protocolVersions: VERSIONS,
1290
+ clientInfo: { name: 'ahpc' },
1291
+ initialSubscriptions: held,
1292
+ });
1293
+ }
1294
+ client = fresh;
1295
+ if (answer === null)
1296
+ channels.resume(fresh, {});
1297
+ else {
1298
+ channels.resume(fresh, str(answer.type) === 'replay'
1299
+ ? { replayed: list(answer.actions), missing: list(answer.missing).filter((uri) => typeof uri === 'string') }
1300
+ : { resumed: list(answer.snapshots) });
1301
+ }
1302
+ moveTo('connected');
1303
+ // Notifications are not replayed, so what the catalogue missed is
1304
+ // not in the answer above and has to be asked for again.
1305
+ for (const listener of catalogue)
1306
+ listener();
1307
+ break;
1308
+ }
1309
+ catch { /* the host is not back yet, or would not have us back */ }
1310
+ }
1311
+ }
1312
+ })();
1313
+ /**
1314
+ * Say something on an otherwise silent connection.
1315
+ *
1316
+ * A proxy between this client and its host drops a socket that has carried
1317
+ * nothing for long enough, and neither end is told - so a session left open
1318
+ * overnight is one whose next keystroke goes nowhere. `ping` is the
1319
+ * protocol's own answer to that, and a failed one is a drop the supervisor
1320
+ * above can act on rather than one nobody has noticed yet.
1321
+ */
1322
+ if (keepaliveMs > 0) {
1323
+ void (async () => {
1324
+ while (!finished) {
1325
+ await pause(keepaliveMs);
1326
+ if (finished)
1327
+ return;
1328
+ try {
1329
+ await client.ping();
1330
+ }
1331
+ catch { /* the state change is what the supervisor reads */ }
1332
+ }
1333
+ })();
1334
+ }
1335
+ /**
1336
+ * A snapshot, or nothing.
1337
+ *
1338
+ * Nothing is a real answer here: a session with no agent still has a row in
1339
+ * the catalogue, and a detail pane that shows what the summary knows is
1340
+ * better than an application that exits. This threw, and the rejection was
1341
+ * unhandled, and an unhandled rejection ends the process - from a terminal
1342
+ * in its alternate screen, which is the worst way for anything to end.
1343
+ */
1344
+ const snapshotOf = async (uri) => channels.state(uri);
1345
+ const chatOf = async (uri) => {
1346
+ const known = chats.get(uri);
1347
+ if (known)
1348
+ return known;
1349
+ const found = str((await snapshotOf(uri) ?? {}).defaultChat);
1350
+ if (found)
1351
+ chats.set(uri, found);
1352
+ return found ?? null;
1353
+ };
1354
+ /**
1355
+ * Pull the page of history before the turns already loaded.
1356
+ *
1357
+ * Shared by the seam method and by the chat consumer, which asks once on
1358
+ * opening when it was handed an empty window - so the two can never drift
1359
+ * into fetching different things.
1360
+ */
1361
+ const loadOlder = async (uri, wanted) => {
1362
+ const chatUri = wanted ?? await chatOf(uri);
1363
+ if (chatUri === null)
1364
+ return false;
1365
+ const before = bag(await snapshotOf(chatUri));
1366
+ /*
1367
+ * The cursor if there is one, and nothing if there is not.
1368
+ *
1369
+ * Omitting it is not the same as having none: the protocol says an absent
1370
+ * cursor asks the host for its next older page anyway, which is what a
1371
+ * host that sent an empty window without one still owes. A cursor this
1372
+ * client invented would be `-32602`, so the choice is between the host's
1373
+ * own and no field at all.
1374
+ */
1375
+ const cursor = str(before.turnsNextCursor);
1376
+ try {
1377
+ // The result is empty by design. What was fetched arrives as
1378
+ // `chat/turnsLoaded` on the chat channel, before this answers.
1379
+ await client.request('fetchTurns', {
1380
+ channel: chatUri,
1381
+ ...(cursor === undefined ? {} : { cursor }),
1382
+ });
1383
+ }
1384
+ catch (error) {
1385
+ options.onRefusal?.(chatUri, reason(error));
1386
+ return false;
1387
+ }
1388
+ const after = bag(await snapshotOf(chatUri));
1389
+ // Absence means the state holds every turn the host retained, which is the
1390
+ // protocol's own words for "that was the last page".
1391
+ return str(after.turnsNextCursor) !== undefined;
1392
+ };
1393
+ /**
1394
+ * Dispatch to the session's chat, and never reject.
1395
+ *
1396
+ * These are the fire-and-forget half of the protocol: nothing awaits them,
1397
+ * so a rejection here has nowhere to go but `unhandledRejection`, which ends
1398
+ * the process. What a caller gets instead is the refusal, reported.
1399
+ */
1400
+ /**
1401
+ * Dispatches that have not reached the socket yet.
1402
+ *
1403
+ * A dispatch is fire-and-forget by design - the host reduces it and says so,
1404
+ * and nothing here waits for a turn it did not start. But it is *async*
1405
+ * before it is sent, because the chat a session dispatches to has to be
1406
+ * looked up, so a caller that hangs up immediately afterwards hangs up
1407
+ * first. A screen never does that; a command that sends one thing and exits
1408
+ * does it every time.
1409
+ */
1410
+ const inFlight = new Set();
1411
+ const dispatch = (uri, action) => {
1412
+ const sending = (async () => {
1413
+ const chat = await chatOf(uri);
1414
+ if (!chat) {
1415
+ options.onRefusal?.(uri, channels.refusal(uri) ?? 'this session has no chat to speak to');
1416
+ return;
1417
+ }
1418
+ client.dispatch(chat, action);
1419
+ })().catch((error) => options.onRefusal?.(uri, reason(error)));
1420
+ inFlight.add(sending);
1421
+ void sending.finally(() => inFlight.delete(sending));
1422
+ };
1423
+ return {
1424
+ // The name the host knows this connection by, not a word meaning "real".
1425
+ // A daemon logs the `clientId` it accepted and the one that went away, so
1426
+ // reporting it here is what lets a run on this side be tied to a run on
1427
+ // that one - `ahpc status --json` prints it, and nothing else could.
1428
+ id: clientId,
1429
+ url: options.url,
1430
+ state: () => state,
1431
+ /*
1432
+ * The host's filesystem, read-only.
1433
+ *
1434
+ * Present because a live host may serve it. A host that does not answers
1435
+ * `-32601`, which arrives here as a rejected request - the caller says so
1436
+ * rather than drawing an empty directory, which would read as a directory
1437
+ * that is empty.
1438
+ */
1439
+ resourceList: async (uri) => {
1440
+ const result = bag(await client.request('resourceList', { channel: ROOT, uri }));
1441
+ const parent = uri.replace(/\/+$/, '');
1442
+ return list(result.entries).map((raw) => {
1443
+ const entry = bag(raw);
1444
+ return {
1445
+ // Derived when the host sends only a name, which is what the
1446
+ // protocol's own listing carries. A row whose URI cannot be handed
1447
+ // straight back for a read is a listing you have to assemble paths
1448
+ // out of by hand.
1449
+ uri: str(entry.uri) ?? `${parent}/${str(entry.name) ?? ''}`,
1450
+ name: str(entry.name) ?? '',
1451
+ kind: str(entry.kind) ?? str(entry.type) ?? 'file',
1452
+ ...(typeof entry.size === 'number' ? { size: entry.size } : {}),
1453
+ };
1454
+ }).filter((entry) => entry.name !== '');
1455
+ },
1456
+ resourceRead: async (uri) => {
1457
+ const result = bag(await client.request('resourceRead', { channel: ROOT, uri }));
1458
+ return {
1459
+ data: str(result.data) ?? '',
1460
+ // Assumed only when the host says nothing, and utf-8 is the assumption
1461
+ // that shows a mistake rather than hiding one.
1462
+ encoding: str(result.encoding) ?? 'utf-8',
1463
+ ...(str(result.contentType) ? { contentType: str(result.contentType) } : {}),
1464
+ };
1465
+ },
1466
+ /*
1467
+ * A token, for a resource the host said it protects.
1468
+ *
1469
+ * The `resource` is checked against what was advertised before anything is
1470
+ * sent: `authentication.md` says it MUST match, so a name this client made
1471
+ * up is a request the host is obliged to refuse - better to say which
1472
+ * names exist than to have the host say no.
1473
+ */
1474
+ authenticate: async (resource, token, opts) => {
1475
+ const known = await advertised();
1476
+ if (known.length > 0 && !known.some((one) => one.resource === resource)) {
1477
+ throw new Error(`This host protects ${known.map((one) => one.resource).join(', ')}, not ${resource}.`);
1478
+ }
1479
+ await client.request('authenticate', {
1480
+ channel: ROOT,
1481
+ resource,
1482
+ token,
1483
+ ...(opts?.scopes && opts.scopes.length > 0 ? { scopes: opts.scopes } : {}),
1484
+ // A positive integer or nothing. Zero and negatives are not "expired
1485
+ // already", they are values the protocol does not allow.
1486
+ ...(typeof opts?.expiresIn === 'number' && Number.isInteger(opts.expiresIn) && opts.expiresIn > 0
1487
+ ? { expiresIn: opts.expiresIn }
1488
+ : {}),
1489
+ });
1490
+ },
1491
+ protectedResources: async () => advertised(),
1492
+ /*
1493
+ * The values for a property the schema would not list.
1494
+ *
1495
+ * `enumDynamic` is the host saying its `enum` is not the answer - a branch
1496
+ * list on a large repository belongs in a query rather than in a schema.
1497
+ * Without this a property marked that way rendered as free text, which is
1498
+ * a person typing a branch name the host was offering to complete.
1499
+ */
1500
+ configCompletions: async ({ provider, workingDirectory, values, property, query }) => {
1501
+ const result = bag(await client.request('sessionConfigCompletions', {
1502
+ channel: ROOT,
1503
+ property,
1504
+ ...(provider ? { provider } : {}),
1505
+ ...(workingDirectory ? { workingDirectory: `file://${workingDirectory}` } : {}),
1506
+ ...(values && Object.keys(values).length > 0 ? { config: values } : {}),
1507
+ ...(query ? { query } : {}),
1508
+ }));
1509
+ return list(result.items).map((raw) => {
1510
+ const item = bag(raw);
1511
+ return {
1512
+ value: str(item.value) ?? '',
1513
+ label: str(item.label) ?? str(item.value) ?? '',
1514
+ ...(str(item.description) ? { description: str(item.description) } : {}),
1515
+ };
1516
+ }).filter((item) => item.value !== '');
1517
+ },
1518
+ resourceResolve: async (uri) => {
1519
+ const result = bag(await client.request('resourceResolve', { channel: ROOT, uri }));
1520
+ return {
1521
+ uri: str(result.uri) ?? uri,
1522
+ // `ResourceType`, the host's own word for what is there. Passed
1523
+ // through rather than narrowed to a boolean: a symlink is neither a
1524
+ // file nor a directory and this client is not the thing that decides.
1525
+ type: str(result.type) ?? 'file',
1526
+ ...(typeof result.size === 'number' ? { size: result.size } : {}),
1527
+ ...(str(result.mtime) ? { mtime: str(result.mtime) } : {}),
1528
+ ...(str(result.etag) ? { etag: str(result.etag) } : {}),
1529
+ };
1530
+ },
1531
+ /*
1532
+ * The write half, which is the same family sent the other way.
1533
+ *
1534
+ * Exactly the declared parameters and no more.
1535
+ *
1536
+ * `ifMatch` carries the `etag` a `resourceResolve` returned, and the host
1537
+ * MUST answer `-32011 Conflict` when its copy has moved on since - which
1538
+ * is what stops a read-modify-write silently losing somebody else's edit.
1539
+ * `createOnly` is the other guard and a different guarantee: it refuses a
1540
+ * file that has appeared, not one that changed underneath.
1541
+ */
1542
+ resourceWrite: async (uri, data, opts) => {
1543
+ await client.request('resourceWrite', {
1544
+ channel: ROOT,
1545
+ uri,
1546
+ data,
1547
+ encoding: opts?.encoding ?? 'utf-8',
1548
+ ...(opts?.createOnly ? { createOnly: true } : {}),
1549
+ ...(opts?.ifMatch ? { ifMatch: opts.ifMatch } : {}),
1550
+ });
1551
+ },
1552
+ resourceDelete: async (uri, opts) => {
1553
+ await client.request('resourceDelete', {
1554
+ channel: ROOT,
1555
+ uri,
1556
+ ...(opts?.recursive ? { recursive: true } : {}),
1557
+ });
1558
+ },
1559
+ resourceMkdir: async (uri) => {
1560
+ await client.request('resourceMkdir', { channel: ROOT, uri });
1561
+ },
1562
+ // `source` and `destination`, and `failIfExists` rather than an
1563
+ // `overwrite` that reads the other way round. Both spellings were guessed
1564
+ // at here before the declarations were read.
1565
+ resourceMove: async (from, to, opts) => {
1566
+ await client.request('resourceMove', {
1567
+ channel: ROOT,
1568
+ source: from,
1569
+ destination: to,
1570
+ ...(opts?.failIfExists ? { failIfExists: true } : {}),
1571
+ });
1572
+ },
1573
+ resourceCopy: async (from, to, opts) => {
1574
+ await client.request('resourceCopy', {
1575
+ channel: ROOT,
1576
+ source: from,
1577
+ destination: to,
1578
+ ...(opts?.failIfExists ? { failIfExists: true } : {}),
1579
+ });
1580
+ },
1581
+ watchResource: async (uri, observer, opts) => {
1582
+ const result = bag(await client.request('createResourceWatch', {
1583
+ channel: ROOT,
1584
+ uri,
1585
+ ...(opts?.recursive ? { recursive: true } : {}),
1586
+ }));
1587
+ // Receiver-assigned and opaque: whatever the host called it is what gets
1588
+ // subscribed to, and nothing here parses it.
1589
+ const channel = str(result.channel);
1590
+ if (channel === undefined)
1591
+ throw new Error('This host allocated no watch channel.');
1592
+ const hold = channels.open(channel, {
1593
+ opened: () => undefined,
1594
+ event: (event) => {
1595
+ if (event.type !== 'action')
1596
+ return;
1597
+ const action = bag(bag(event.params).action);
1598
+ if (str(action.type) !== 'resourceWatch/changed')
1599
+ return;
1600
+ // `changes` is wrapped in `items` for forward compatibility, so it
1601
+ // is read through rather than treated as the array itself.
1602
+ observer(list(bag(action.changes).items).map((raw) => {
1603
+ const change = bag(raw);
1604
+ // `type`, which is what `ResourceChange` declares. `kind` was a
1605
+ // guess and matched nothing either host sends.
1606
+ return { uri: str(change.uri) ?? '', kind: str(change.type) ?? 'changed' };
1607
+ }));
1608
+ },
1609
+ });
1610
+ // There is no dispose command. Releasing the last hold is what sends the
1611
+ // `unsubscribe` the host releases the watcher on.
1612
+ return { close: () => hold.release() };
1613
+ },
1614
+ /*
1615
+ * Raw, and deliberately unvalidated.
1616
+ *
1617
+ * Everything else here names the action it sends, because a control that
1618
+ * builds a malformed one is a bug. This is the opposite: what it is for is
1619
+ * sending actions this client has no control for, so the host is the only
1620
+ * thing that can say whether one is right - and it says so by refusing.
1621
+ */
1622
+ dispatch: (uri, action, chat) => {
1623
+ if (chat)
1624
+ dispatch(uri, action);
1625
+ else
1626
+ client.dispatch(uri, action);
1627
+ },
1628
+ listSessions: async () => {
1629
+ // Asking again is what a refresh is for. A refusal is remembered so that
1630
+ // moving the highlight does not re-ask a hundred times, and forgotten
1631
+ // here so that `r` is a way to try - which matters for the refusals that
1632
+ // are temporary, like a harness nobody had signed into yet.
1633
+ channels.forget();
1634
+ /*
1635
+ * The whole catalogue, in whatever pages the host chooses to give it.
1636
+ *
1637
+ * No `limit` is sent: the page size is the host's to pick, and a number
1638
+ * chosen here is a number only this client can see. What is followed is
1639
+ * `nextCursor`, which is the host saying there is more - this asked for
1640
+ * a hundred rows and dropped that sentence, so a catalogue of 123 showed
1641
+ * 100 and gave no sign the rest existed.
1642
+ */
1643
+ const rows = [];
1644
+ let cursor;
1645
+ let more = false;
1646
+ for (let page = 0; page < PAGES; page += 1) {
1647
+ const result = await client.request('listSessions', {
1648
+ channel: ROOT,
1649
+ ...(cursor === undefined ? {} : { cursor }),
1650
+ });
1651
+ rows.push(...list(result.items));
1652
+ cursor = str(result.nextCursor);
1653
+ if (cursor === undefined)
1654
+ break;
1655
+ more = page === PAGES - 1;
1656
+ }
1657
+ // Said rather than swallowed. Reaching this means a catalogue larger
1658
+ // than this client will walk in one go, and a list that stops without
1659
+ // saying so is the defect this replaced.
1660
+ if (more) {
1661
+ options.onLimit?.(`Showing the first ${rows.length} sessions; this host has more.`);
1662
+ }
1663
+ return rows.map(summary);
1664
+ },
1665
+ agents: async () => list(mirror.root.agents).map((entry) => {
1666
+ const agent = bag(entry);
1667
+ return {
1668
+ provider: str(agent.provider) ?? str(agent.id) ?? 'unknown',
1669
+ displayName: str(agent.displayName) ?? str(agent.provider) ?? 'Agent',
1670
+ ...(str(agent.description) ? { description: str(agent.description) } : {}),
1671
+ ...(list(agent.protectedResources).length > 0
1672
+ ? {
1673
+ protectedResources: list(agent.protectedResources).map((raw) => {
1674
+ const one = bag(raw);
1675
+ return {
1676
+ resource: str(one.resource) ?? '',
1677
+ // `resource_name`, in the OAuth metadata's own snake_case -
1678
+ // `ProtectedResourceMetadata` is RFC 9728's shape and carries
1679
+ // no `description`.
1680
+ ...(str(one.resource_name) ? { name: str(one.resource_name) } : {}),
1681
+ ...(list(one.scopes_supported).length > 0
1682
+ ? { scopes: list(one.scopes_supported).filter((x) => typeof x === 'string') }
1683
+ : {}),
1684
+ };
1685
+ }).filter((one) => one.resource !== ''),
1686
+ }
1687
+ : {}),
1688
+ // A gate, not a hint. Absent means `createChat` must not be called.
1689
+ ...(bag(agent.capabilities).multipleChats !== undefined ? { multipleChats: true } : {}),
1690
+ // Which of the two it can do. The reference host advertises both; a
1691
+ // host that advertises the capability as a bare presence flag says
1692
+ // neither, and neither is offered.
1693
+ ...(bag(bag(agent.capabilities).multipleChats).fork === true
1694
+ || bag(bag(agent.capabilities).multipleChats).sideChat === true
1695
+ ? {
1696
+ chatSources: {
1697
+ ...(bag(bag(agent.capabilities).multipleChats).fork === true ? { fork: true } : {}),
1698
+ ...(bag(bag(agent.capabilities).multipleChats).sideChat === true ? { sideChat: true } : {}),
1699
+ },
1700
+ }
1701
+ : {}),
1702
+ // The same decoder a session's list goes through, because it is the
1703
+ // same shape - the protocol says these entries are augmented and
1704
+ // propagated into a session's own when one is created with this agent,
1705
+ // so two decoders would be two readings of one thing.
1706
+ ...(list(agent.customizations).length > 0
1707
+ ? { customizations: customizations(agent.customizations) }
1708
+ : {}),
1709
+ // The provider a model row carries is required and is always the
1710
+ // agent's own, so a host that has not filled it in yet - it was
1711
+ // missing until recently - is read as belonging to the agent it
1712
+ // arrived under rather than dropped for being incomplete.
1713
+ models: list(agent.models).map((raw) => {
1714
+ const row = model(raw);
1715
+ return row.provider === ''
1716
+ ? { ...row, provider: str(agent.provider) ?? str(agent.id) ?? '' }
1717
+ : row;
1718
+ }),
1719
+ };
1720
+ }),
1721
+ // `workingDirectory`, singular. `createSession` takes a list and this
1722
+ // takes one, so the plural spelling was a parameter the host had no name
1723
+ // for: it answered about no directory at all, and a schema that offers a
1724
+ // worktree only when the directory is a git checkout never offered one.
1725
+ resolveConfig: async ({ provider, workingDirectory, values }) => config(await client.request('resolveSessionConfig', {
1726
+ channel: ROOT,
1727
+ provider,
1728
+ ...(workingDirectory ? { workingDirectory: `file://${workingDirectory}` } : {}),
1729
+ // Iterative: what has been answered is what decides which questions are
1730
+ // left, so the host is told rather than asked the same first question.
1731
+ ...(values && Object.keys(values).length > 0 ? { config: values } : {}),
1732
+ })),
1733
+ createSession: async ({ provider, workingDirectory, config: values }) => {
1734
+ /*
1735
+ * The client chooses the URI, which is what makes the session
1736
+ * addressable before the host has answered - and it is named after the
1737
+ * provider, because the scheme is how every other client decides which
1738
+ * provider a session belongs to.
1739
+ *
1740
+ * A session created as `ahp-session:/<uuid>` was one no other client
1741
+ * could open: the host echoes the creator's name into its catalogue, and
1742
+ * VS Code's window read the scheme, found no provider called
1743
+ * `ahp-session`, and drew the row without ever loading its conversation.
1744
+ */
1745
+ const resource = `${provider}:/${randomUUID()}`;
1746
+ // The channel *is* the new session's URI. `createSession` reads as a
1747
+ // root command and is not one: sending it to `ahp-root://` with the URI
1748
+ // beside it named a parameter the host has nothing called, so the
1749
+ // session was created - somewhere - and never at the URI we then went
1750
+ // on to subscribe to.
1751
+ /*
1752
+ * A token to report against.
1753
+ *
1754
+ * `root-channel.md` gives downloading an agent as the example, which is
1755
+ * exactly the wait this command can sit in: a harness that is not on the
1756
+ * machine yet is fetched before the session exists, and without a token
1757
+ * the host has nowhere to say so.
1758
+ */
1759
+ const progressToken = randomUUID();
1760
+ await client.request('createSession', {
1761
+ channel: resource,
1762
+ provider,
1763
+ progressToken,
1764
+ ...(workingDirectory ? { workingDirectories: [`file://${workingDirectory}`] } : {}),
1765
+ ...(values && Object.keys(values).length > 0 ? { config: values } : {}),
1766
+ });
1767
+ return resource;
1768
+ },
1769
+ automations: async () => {
1770
+ // The host's words, not ours. "Serves no automations" and "the daemon
1771
+ // has gone" want opposite things from a person.
1772
+ if (noAutomations !== undefined)
1773
+ throw new Error(noAutomations);
1774
+ return list(bag(automationState).entries).map(automation);
1775
+ },
1776
+ onAutomations: (observer) => {
1777
+ automationWatchers.add(observer);
1778
+ return { close: () => { automationWatchers.delete(observer); } };
1779
+ },
1780
+ createAutomation: async (definition) => {
1781
+ const uri = `ahp-automation:/${randomUUID()}`;
1782
+ // A *request*, in the protocol's own spelling: the client says what it
1783
+ // wants and the host decides, then says what it actually holds with
1784
+ // `automation/set`. So nothing is echoed back here - what appears on the
1785
+ // screen is the host's answer arriving on the channel.
1786
+ client.dispatch(AUTOMATIONS, {
1787
+ type: 'automation/createRequested',
1788
+ resource: uri,
1789
+ definition,
1790
+ });
1791
+ return uri;
1792
+ },
1793
+ runAutomation: async (uri) => {
1794
+ await client.request('runAutomation', {
1795
+ channel: AUTOMATIONS,
1796
+ automation: uri,
1797
+ // The protocol has this so a client can match its own request to the
1798
+ // run it gets back; this client reads the catalogue instead, and sends
1799
+ // one because the field is required.
1800
+ requestId: randomUUID(),
1801
+ });
1802
+ },
1803
+ setAutomationEnabled: async (uri, enabled) => {
1804
+ // Straight at the channel. The `dispatch` above resolves a *session's*
1805
+ // chat, which this is not.
1806
+ //
1807
+ // `changes` and not the whole definition: it is a patch, and sending
1808
+ // everything back would revert whatever another client changed
1809
+ // meanwhile. A request rather than a write - the host answers with
1810
+ // `automation/set` saying what it actually holds, which is where the
1811
+ // screen reads it from.
1812
+ client.dispatch(AUTOMATIONS, {
1813
+ type: 'automation/updateRequested',
1814
+ resource: uri,
1815
+ changes: { enabled },
1816
+ });
1817
+ },
1818
+ /*
1819
+ * The host's own log, on the channel it advertised.
1820
+ *
1821
+ * `telemetry-channel.md`: the URI is opaque apart from the well-known
1822
+ * template variables, and `{level}` is the only one defined - so this
1823
+ * expands that and nothing else, and subscribes with whatever comes out.
1824
+ * A host that emits no logs omits the field, and then there is nothing to
1825
+ * follow rather than a channel to guess at.
1826
+ */
1827
+ watchLogs: async (observer, opts) => {
1828
+ const advertisedUri = str(bag(hello.telemetry).logs);
1829
+ if (advertisedUri === undefined)
1830
+ throw new Error('This host emits no logs.');
1831
+ const uri = expandLevel(advertisedUri, opts?.level);
1832
+ logReaders.add(observer);
1833
+ const hold = channels.open(uri, { opened: () => undefined, event: () => undefined });
1834
+ return {
1835
+ close: () => {
1836
+ logReaders.delete(observer);
1837
+ hold.release();
1838
+ },
1839
+ };
1840
+ },
1841
+ automationTriggers: async () => {
1842
+ // The *root* channel: "trigger definitions are discovered from the root
1843
+ // channel", because they are a property of the host rather than of the
1844
+ // automations it happens to be holding.
1845
+ const result = bag(await client.request('listAutomationTriggerDefinitions', { channel: ROOT }));
1846
+ return list(result.items).map((raw) => {
1847
+ const one = bag(raw);
1848
+ return {
1849
+ kind: str(one.kind) ?? str(one.type) ?? '',
1850
+ ...(str(one.title) ? { title: str(one.title) } : {}),
1851
+ ...(str(one.description) ? { description: str(one.description) } : {}),
1852
+ };
1853
+ }).filter((one) => one.kind !== '');
1854
+ },
1855
+ automationRuns: async (uri, cursor) => {
1856
+ const result = bag(await client.request('fetchAutomationRuns', {
1857
+ channel: AUTOMATIONS,
1858
+ automation: uri,
1859
+ ...(cursor === undefined ? {} : { cursor }),
1860
+ }));
1861
+ return {
1862
+ runs: list(result.runs).map(automationRun),
1863
+ ...(str(result.nextCursor) ? { nextCursor: str(result.nextCursor) } : {}),
1864
+ };
1865
+ },
1866
+ removeAutomation: async (uri) => {
1867
+ // `automation/removed`, in the protocol's own spelling: the client says
1868
+ // it is gone and the host revalidates that `remove` is still offered
1869
+ // before it is.
1870
+ client.dispatch(AUTOMATIONS, { type: 'automation/removed', resource: uri });
1871
+ },
1872
+ terminals: async () => list(mirror.root.terminals).map((raw) => {
1873
+ const found = bag(raw);
1874
+ // Both forms of the exit code, because this client speaks four versions.
1875
+ // 0.9.0 moved it inside `lifecycle`, where it exists only once the
1876
+ // process has exited; before that it was flat on the terminal. Reading
1877
+ // one name leaves every exit under half the hosts reported as still
1878
+ // running.
1879
+ const exited = bag(found.lifecycle).exitCode;
1880
+ const code = typeof exited === 'number' ? exited
1881
+ : typeof found.exitCode === 'number' ? found.exitCode
1882
+ : undefined;
1883
+ return {
1884
+ resource: str(found.resource) ?? '',
1885
+ title: str(found.title) ?? 'Terminal',
1886
+ ...(code !== undefined ? { exitCode: code } : {}),
1887
+ };
1888
+ }).filter((row) => row.resource !== ''),
1889
+ createTerminal: async (options) => {
1890
+ // The client picks the URI, as it does for a session and a chat, so it
1891
+ // can be watched without a round trip in between.
1892
+ const uri = `ahp-terminal:/${randomUUID()}`;
1893
+ await client.request('createTerminal', {
1894
+ channel: uri,
1895
+ claim: { kind: 'client', clientId: 'live' },
1896
+ ...(options?.cwd ? { cwd: `file://${options.cwd}` } : {}),
1897
+ ...(options?.name ? { name: options.name } : {}),
1898
+ });
1899
+ return uri;
1900
+ },
1901
+ disposeTerminal: async (uri) => {
1902
+ await client.request('disposeTerminal', { channel: uri });
1903
+ },
1904
+ /**
1905
+ * Watch one, and report its whole state each time it changes.
1906
+ *
1907
+ * The whole state rather than the delta, for the same reason the chat
1908
+ * does: the reducer is the authority on what the terminal now contains,
1909
+ * and a second hand-written path from action to screen is a second answer
1910
+ * to the same question.
1911
+ */
1912
+ watchTerminal: (uri, observer) => {
1913
+ let live = true;
1914
+ const shape = (state) => ({
1915
+ title: str(state.title) ?? 'Terminal',
1916
+ // The protocol's typed parts, flattened: a command part carries its
1917
+ // output and an unclassified one its value, and a reader wants the
1918
+ // stream either way.
1919
+ output: list(state.content)
1920
+ .map((raw) => {
1921
+ const part = bag(raw);
1922
+ return str(part.type) === 'command' ? str(part.output) ?? '' : str(part.value) ?? '';
1923
+ })
1924
+ .join(''),
1925
+ ...(str(state.cwd) ? { cwd: str(state.cwd).replace(/^file:\/\//, '') } : {}),
1926
+ ...(typeof state.exitCode === 'number' ? { exitCode: state.exitCode } : {}),
1927
+ isPty: state.isPty === true,
1928
+ });
1929
+ let state = {};
1930
+ const hold = channels.open(uri, {
1931
+ opened: (fresh) => {
1932
+ // A reconnect too long for replay arrives here as well, which is
1933
+ // why this rebuilds rather than merges: the host's state is the
1934
+ // answer and the one held across the gap is a guess about it.
1935
+ if (fresh)
1936
+ state = fresh;
1937
+ if (live)
1938
+ observer(shape(state));
1939
+ },
1940
+ event: (event) => {
1941
+ if (event.type !== 'action')
1942
+ return;
1943
+ state = bag(ahp.terminalReducer(state, bag(event.params).action));
1944
+ if (live)
1945
+ observer(shape(state));
1946
+ },
1947
+ refused: (message) => options.onRefusal?.(uri, message),
1948
+ });
1949
+ return { close: () => { live = false; hold.release(); } };
1950
+ },
1951
+ writeTerminal: (uri, data) => {
1952
+ try {
1953
+ // Side-effect only: what comes back is `terminal/data`, once the shell
1954
+ // has actually said something. Echoing here would print every
1955
+ // keystroke twice on the client that typed it.
1956
+ client.dispatch(uri, { type: 'terminal/input', data });
1957
+ }
1958
+ catch (error) {
1959
+ options.onRefusal?.(uri, reason(error));
1960
+ }
1961
+ },
1962
+ resizeTerminal: (uri, cols, rows) => {
1963
+ try {
1964
+ client.dispatch(uri, { type: 'terminal/resized', cols, rows });
1965
+ }
1966
+ catch (error) {
1967
+ options.onRefusal?.(uri, reason(error));
1968
+ }
1969
+ },
1970
+ clearTerminal: (uri) => {
1971
+ try {
1972
+ client.dispatch(uri, { type: 'terminal/cleared' });
1973
+ }
1974
+ catch (error) {
1975
+ options.onRefusal?.(uri, reason(error));
1976
+ }
1977
+ },
1978
+ renameTerminal: (uri, title) => {
1979
+ try {
1980
+ client.dispatch(uri, { type: 'terminal/titleChanged', title });
1981
+ }
1982
+ catch (error) {
1983
+ options.onRefusal?.(uri, reason(error));
1984
+ }
1985
+ },
1986
+ claimTerminal: (uri) => {
1987
+ // The claim is required and is an object, not a name: a client claim is
1988
+ // this connection's `clientId` under `kind: 'client'`.
1989
+ try {
1990
+ client.dispatch(uri, {
1991
+ type: 'terminal/claimed',
1992
+ claim: { kind: 'client', clientId },
1993
+ });
1994
+ }
1995
+ catch (error) {
1996
+ options.onRefusal?.(uri, reason(error));
1997
+ }
1998
+ },
1999
+ completions: async ({ channel, text, offset }) => {
2000
+ try {
2001
+ const result = await client.request('completions', {
2002
+ channel,
2003
+ kind: 'userMessage',
2004
+ text,
2005
+ offset: offset ?? text.length,
2006
+ });
2007
+ return list(result.items).map((raw) => {
2008
+ const item = bag(raw);
2009
+ const attachment = bag(item.attachment);
2010
+ const insertText = str(item.insertText) ?? '';
2011
+ return {
2012
+ insertText,
2013
+ // A host that answered without a range means "replace what I was
2014
+ // asked about", and the whole draft is the safe reading of that.
2015
+ rangeStart: typeof item.rangeStart === 'number' ? item.rangeStart : 0,
2016
+ rangeEnd: typeof item.rangeEnd === 'number' ? item.rangeEnd : text.length,
2017
+ label: str(attachment.label) ?? insertText,
2018
+ ...(plain(attachment.modelRepresentation)
2019
+ ? { description: plain(attachment.modelRepresentation) }
2020
+ : {}),
2021
+ };
2022
+ }).filter((item) => item.insertText !== '');
2023
+ }
2024
+ // A host that does not serve them is one whose composer offers no menu,
2025
+ // which is what every host did before either was served.
2026
+ catch {
2027
+ return [];
2028
+ }
2029
+ },
2030
+ loadOlderTurns: loadOlder,
2031
+ createChat: async (uri, first, source) => {
2032
+ // The client picks the URI, as it does for a session, so it can be
2033
+ // subscribed to without a round trip in between.
2034
+ const chat = `ahp-chat:/${randomUUID()}`;
2035
+ await client.request('createChat', {
2036
+ channel: uri,
2037
+ chat,
2038
+ ...(first ? { initialMessage: { text: first, origin: { kind: 'user' } } } : {}),
2039
+ // Where it came from, when it came from somewhere. A fork copies the
2040
+ // source's history through a turn; a side chat carries the context
2041
+ // without copying it into what a person reads.
2042
+ ...(source ? { source } : {}),
2043
+ });
2044
+ return chat;
2045
+ },
2046
+ disposeChat: async (chat) => {
2047
+ await client.request('disposeChat', { channel: chat });
2048
+ },
2049
+ disposeSession: async (uri) => {
2050
+ await client.request('disposeSession', { channel: uri });
2051
+ chats.delete(uri);
2052
+ channels.forget(uri);
2053
+ },
2054
+ setArchived: (uri, archived) => {
2055
+ try {
2056
+ client.dispatch(uri, { type: 'session/isArchivedChanged', isArchived: archived });
2057
+ }
2058
+ catch (error) {
2059
+ options.onRefusal?.(uri, reason(error));
2060
+ }
2061
+ },
2062
+ setRead: (uri, read) => {
2063
+ try {
2064
+ client.dispatch(uri, { type: 'session/isReadChanged', isRead: read });
2065
+ }
2066
+ catch (error) {
2067
+ options.onRefusal?.(uri, reason(error));
2068
+ }
2069
+ },
2070
+ /**
2071
+ * Watch one session, and the chat it dispatches to.
2072
+ *
2073
+ * Two channels, two reducers, one observer. Every action rebuilds the
2074
+ * whole view and re-emits it as a snapshot rather than being translated
2075
+ * into a delta: the reducers are the authority on what the state is now,
2076
+ * and a second, hand-written path from action to screen is a second answer
2077
+ * to the same question. The transcript already renders from a snapshot -
2078
+ * that is what it does when it is opened - so this costs nothing but a
2079
+ * rebuild per action.
2080
+ */
2081
+ onSessions: (observer) => {
2082
+ catalogue.add(observer);
2083
+ return { close: () => { catalogue.delete(observer); } };
2084
+ },
2085
+ subscribe: (uri, observer, wanted) => {
2086
+ let live = true;
2087
+ const closers = [];
2088
+ let session = {};
2089
+ let chat = {};
2090
+ /** What was last reported, so an unchanged list is not re-sent. */
2091
+ let contributed = '';
2092
+ let listed = '';
2093
+ /** An action this client could not apply. Said once, and read on. */
2094
+ const bad = (message) => {
2095
+ options.onRefusal?.(uri, message);
2096
+ if (live)
2097
+ observer({ type: 'error', message });
2098
+ };
2099
+ /** The session's chats, when that has changed. */
2100
+ const chatsChanged = () => {
2101
+ const items = list(session.chats).map((raw) => ({
2102
+ resource: str(bag(raw).resource) ?? '',
2103
+ title: str(bag(raw).title) ?? 'Chat',
2104
+ })).filter((entry) => entry.resource !== '');
2105
+ const now = JSON.stringify(items);
2106
+ if (now === listed)
2107
+ return;
2108
+ listed = now;
2109
+ if (live)
2110
+ observer({ type: 'chats', items, defaultChat: str(session.defaultChat) ?? '' });
2111
+ };
2112
+ const emit = () => {
2113
+ if (!live)
2114
+ return;
2115
+ // Nothing is claimed about a conversation until the channel carrying
2116
+ // it has spoken. A session with no chat to follow has nothing to wait
2117
+ // for and emits at once.
2118
+ const following = wanted ?? str(session.defaultChat);
2119
+ if (following !== undefined && following !== '' && !listening)
2120
+ return;
2121
+ const all = transcript(chat);
2122
+ const active = all.find((found) => found.state === 'running');
2123
+ const asked = pendingInput(session, chat);
2124
+ const event = {
2125
+ type: 'snapshot',
2126
+ turns: all.filter((found) => found !== active),
2127
+ ...(active ? { active } : {}),
2128
+ ...(asked ? { input: asked } : {}),
2129
+ status: activityOf(typeof session.status === 'number' ? session.status : 1, Boolean(asked), active !== undefined, all[all.length - 1]?.state === 'failed'),
2130
+ queued: queued(chat),
2131
+ // What the host is holding as the message being composed. Shared
2132
+ // state: another client typing here is visible, and it outlives
2133
+ // this one being restarted.
2134
+ draft: str(bag(chat.draft).text) ?? '',
2135
+ };
2136
+ observer(event);
2137
+ };
2138
+ /** Who the host says is in this session, so an unchanged list is not re-sent. */
2139
+ let present = '';
2140
+ const here = () => {
2141
+ const clients = list(session.activeClients).map((one) => {
2142
+ const found = bag(one);
2143
+ return {
2144
+ clientId: str(found.clientId) ?? '',
2145
+ ...(str(found.displayName) ? { displayName: str(found.displayName) } : {}),
2146
+ };
2147
+ });
2148
+ const now = JSON.stringify(clients);
2149
+ if (now === present)
2150
+ return;
2151
+ present = now;
2152
+ if (live)
2153
+ observer({ type: 'present', clients });
2154
+ };
2155
+ /** The chat channel, once the session has said which one it is. */
2156
+ let talking;
2157
+ /**
2158
+ * Whether the chat this view follows has handed over its state.
2159
+ *
2160
+ * A session and its chat are two channels, and the session answers
2161
+ * first. Reporting a snapshot in between says "here is the
2162
+ * conversation" while holding none of it - which a screen survives,
2163
+ * because the chat arrives a moment later and it redraws, and a reader
2164
+ * that takes the first snapshot and stops does not. `session history`
2165
+ * is exactly that reader, and it printed nothing.
2166
+ */
2167
+ let listening = false;
2168
+ /**
2169
+ * Follow the session's chat.
2170
+ *
2171
+ * Called once the session state names one, and again after a reconnect
2172
+ * hands back a session state naming a different one - a client watching
2173
+ * a second chat is watching that chat, not the session's first.
2174
+ */
2175
+ const followChat = () => {
2176
+ const chatUri = wanted ?? str(session.defaultChat);
2177
+ if (!chatUri || !live)
2178
+ return;
2179
+ if (chats.get(uri) === chatUri && talking)
2180
+ return;
2181
+ talking?.release();
2182
+ chats.set(uri, chatUri);
2183
+ talking = channels.open(chatUri, {
2184
+ opened: (fresh) => {
2185
+ if (fresh)
2186
+ chat = fresh;
2187
+ listening = true;
2188
+ emit();
2189
+ /*
2190
+ * A window with nothing in it, and a cursor saying there is more.
2191
+ *
2192
+ * One host puts a tail of the conversation in the snapshot and one
2193
+ * puts none, and the second is not saying the chat is empty - it
2194
+ * is saying to ask. Without this, opening such a session shows a
2195
+ * blank transcript that no amount of waiting fills, and a person
2196
+ * has no reason to think scrolling up would do anything.
2197
+ *
2198
+ * Once, on opening, and only when the window is empty: a chat that
2199
+ * arrived with turns is one where reading further back is the
2200
+ * person's business rather than this client's.
2201
+ */
2202
+ if (!live)
2203
+ return;
2204
+ if (list(chat.turns).length > 0)
2205
+ return;
2206
+ if (str(chat.turnsNextCursor) === undefined)
2207
+ return;
2208
+ void loadOlder(uri, chatUri).catch(() => undefined);
2209
+ },
2210
+ event: (event) => {
2211
+ if (event.type !== 'action')
2212
+ return;
2213
+ chat = bag(applyAction(ahp.chatReducer, chat, bag(event.params).action, bad));
2214
+ emit();
2215
+ },
2216
+ refused: (message) => {
2217
+ // The conversation is not coming. Saying so is better than a
2218
+ // reader that waits for a snapshot which will never be emitted.
2219
+ listening = true;
2220
+ if (live)
2221
+ observer({ type: 'error', message });
2222
+ },
2223
+ });
2224
+ closers.push(() => talking?.release());
2225
+ };
2226
+ /*
2227
+ * Opening a session is somebody asking, so a refusal is not inherited.
2228
+ *
2229
+ * What is remembered is remembered so that *moving the highlight* does
2230
+ * not re-ask a hundred times - and that path reads snapshots rather
2231
+ * than opening views. A person pressing enter on a row has asked, and
2232
+ * some of what a host refuses is momentary: a session it was evicting
2233
+ * when the last question arrived answers the next one.
2234
+ */
2235
+ channels.forget(uri);
2236
+ const previous = chats.get(uri);
2237
+ if (previous !== undefined)
2238
+ channels.forget(previous);
2239
+ if (wanted !== undefined)
2240
+ channels.forget(wanted);
2241
+ const known = channels.refusal(uri);
2242
+ if (known !== undefined) {
2243
+ queueMicrotask(() => { if (live)
2244
+ observer({ type: 'error', message: known }); });
2245
+ }
2246
+ else {
2247
+ const held = channels.open(uri, {
2248
+ opened: (fresh) => {
2249
+ if (fresh)
2250
+ session = fresh;
2251
+ /*
2252
+ * Say this client is here.
2253
+ *
2254
+ * `SessionState.activeClients` is host-kept: a client adds or
2255
+ * refreshes itself with `session/activeClientSet` and the host
2256
+ * removes it when the last subscription goes, which this client
2257
+ * now sends. Without it two people on one session cannot see each
2258
+ * other, which is most of the reason a sessions server exists
2259
+ * rather than a local agent.
2260
+ *
2261
+ * `tools` is empty and required: this client contributes none,
2262
+ * and an absent list is not the same answer as an empty one.
2263
+ */
2264
+ client.dispatch(uri, {
2265
+ type: 'session/activeClientSet',
2266
+ activeClient: { clientId, displayName: 'ahpc', tools: [] },
2267
+ });
2268
+ here();
2269
+ chatsChanged();
2270
+ followChat();
2271
+ emit();
2272
+ },
2273
+ event: (event) => {
2274
+ if (event.type !== 'action')
2275
+ return;
2276
+ session = bag(applyAction(ahp.sessionReducer, session, bag(event.params).action, bad));
2277
+ // Separate from the snapshot below, which is the chat: these are the
2278
+ // session's, they change for reasons that have nothing to do with a
2279
+ // turn, and a panel that only re-read when it was opened showed a
2280
+ // switch that had been answered as though it had not.
2281
+ here();
2282
+ const items = customizations(session.customizations);
2283
+ const now = JSON.stringify(items);
2284
+ if (now !== contributed) {
2285
+ contributed = now;
2286
+ if (live)
2287
+ observer({ type: 'customizations', items });
2288
+ }
2289
+ chatsChanged();
2290
+ followChat();
2291
+ emit();
2292
+ },
2293
+ // The host answering "no" is not the host being gone. Marking the
2294
+ // connection offline over one dead session is how a person is sent
2295
+ // to check their network about a session whose agent simply exited.
2296
+ refused: (message) => { if (live)
2297
+ observer({ type: 'error', message }); },
2298
+ });
2299
+ closers.push(() => held.release());
2300
+ }
2301
+ return {
2302
+ close: () => {
2303
+ live = false;
2304
+ for (const close of closers)
2305
+ close();
2306
+ },
2307
+ };
2308
+ },
2309
+ /*
2310
+ * The model selection rides on the message, whole.
2311
+ *
2312
+ * `chat-channel.md` puts it there - a draft carries "its model/agent
2313
+ * selection" and `createChat`'s `initialMessage` carries "its own" - and
2314
+ * the schema says a client presents a model's `configSchema` as a form and
2315
+ * passes the resolved values in `ModelSelection.config`. Sending the id
2316
+ * alone made every one of those answers unsendable.
2317
+ */
2318
+ setDraft: (uri, text) => {
2319
+ // An empty draft is `undefined`, not an empty message: the protocol
2320
+ // clears the field rather than holding a message with nothing in it.
2321
+ dispatch(uri, {
2322
+ type: 'chat/draftChanged',
2323
+ ...(text === '' ? {} : { draft: { text, origin: { kind: 'user' } } }),
2324
+ });
2325
+ },
2326
+ say: (uri, text, model) => {
2327
+ dispatch(uri, {
2328
+ type: 'chat/turnStarted',
2329
+ turnId: randomUUID(),
2330
+ startedAt: new Date().toISOString(),
2331
+ message: {
2332
+ text,
2333
+ origin: { kind: 'user' },
2334
+ ...(model ? { model: selectionOf(model) } : {}),
2335
+ },
2336
+ });
2337
+ },
2338
+ /*
2339
+ * Appended to the host's queue, not held here.
2340
+ *
2341
+ * `chat/pendingMessageSet` with a fresh id appends; the same id again
2342
+ * would edit the one already there. The host starts a turn from the head
2343
+ * as soon as it is idle - and if it is idle *now* it consumes this
2344
+ * immediately, which is the protocol saying so, and is why this does not
2345
+ * need to know whether a turn is running.
2346
+ */
2347
+ queue: (uri, text, model) => {
2348
+ dispatch(uri, {
2349
+ type: 'chat/pendingMessageSet',
2350
+ kind: 'queued',
2351
+ id: randomUUID(),
2352
+ message: {
2353
+ text,
2354
+ origin: { kind: 'user' },
2355
+ ...(model ? { model: selectionOf(model) } : {}),
2356
+ },
2357
+ });
2358
+ },
2359
+ unqueue: (uri, id) => {
2360
+ dispatch(uri, { type: 'chat/pendingMessageRemoved', kind: 'queued', id });
2361
+ },
2362
+ stopTurn: (uri) => {
2363
+ void (async () => {
2364
+ const chatUri = await chatOf(uri);
2365
+ if (!chatUri)
2366
+ return;
2367
+ // The id is read back rather than remembered: a turn somebody started
2368
+ // in an editor is stoppable from here too, and its id is in the state.
2369
+ const state = await snapshotOf(chatUri);
2370
+ const active = bag(bag(state).activeTurn);
2371
+ const turnId = str(active.id);
2372
+ if (!turnId)
2373
+ return;
2374
+ const started = Date.parse(str(active.startedAt) ?? '');
2375
+ client.dispatch(chatUri, {
2376
+ type: 'chat/turnCancelled',
2377
+ turnId,
2378
+ duration: Number.isNaN(started) ? 0 : Math.max(0, Date.now() - started),
2379
+ });
2380
+ })().catch((error) => options.onRefusal?.(uri, reason(error)));
2381
+ },
2382
+ confirmToolCall: (uri, toolCallId, approved, optionId) => {
2383
+ dispatch(uri, approved
2384
+ ? {
2385
+ type: 'chat/toolCallConfirmed',
2386
+ toolCallId,
2387
+ approved: true,
2388
+ // A person pressed a button, and the record of why this ran should
2389
+ // say so rather than blaming a setting.
2390
+ confirmed: 'user-action',
2391
+ ...(optionId ? { selectedOptionId: optionId } : {}),
2392
+ }
2393
+ : { type: 'chat/toolCallConfirmed', toolCallId, approved: false, reason: 'denied' });
2394
+ },
2395
+ completeInput: (uri, requestId, accepted, answers) => {
2396
+ const typed = Object.fromEntries(Object.entries(answers).map(([id, answer]) => [id, answerValue(answer)]));
2397
+ dispatch(uri, {
2398
+ type: 'chat/inputCompleted',
2399
+ requestId,
2400
+ response: accepted ? 'accept' : 'decline',
2401
+ // Omitted rather than empty: an accept carrying no answers resumes the
2402
+ // agent on the ones it already had, which for a question it has just
2403
+ // asked is none.
2404
+ ...(Object.keys(typed).length > 0 ? { answers: typed } : {}),
2405
+ });
2406
+ },
2407
+ changesets: async (uri) => {
2408
+ const state = await snapshotOf(uri);
2409
+ return list(bag(state).changesets).map(bag).flatMap((found) => {
2410
+ const template = str(found.uriTemplate);
2411
+ if (!template)
2412
+ return [];
2413
+ return [{
2414
+ label: str(found.label) ?? 'Changes',
2415
+ uriTemplate: template,
2416
+ ...(str(found.changeKind) ? { changeKind: str(found.changeKind) } : {}),
2417
+ ...(str(found.description) ? { description: str(found.description) } : {}),
2418
+ // A presence flag: an empty object means supported, absence means
2419
+ // not. Sub-fields are reserved, so only its being there is read.
2420
+ ...(bag(found.capabilities).review !== undefined ? { reviewable: true } : {}),
2421
+ // RFC 6570 in the only shape this protocol defines: `{name}`, and
2422
+ // nothing else. A variable this client does not know how to fill in
2423
+ // is still worth naming, so a caller can say what it needs.
2424
+ variables: [...template.matchAll(/\{(\w+)\}/g)].map((found_) => found_[1]),
2425
+ }];
2426
+ });
2427
+ },
2428
+ /*
2429
+ * Tick a file off, or clear it.
2430
+ *
2431
+ * Dispatched on the *changeset's* channel rather than the session's, and
2432
+ * deliberately not an operation: the protocol has clients dispatch this
2433
+ * and the server keep the flag, which is why it needs no `operations`
2434
+ * entry and writes nothing to anybody's repository.
2435
+ */
2436
+ review: (changesetUri, files, isReviewed) => {
2437
+ client.dispatch(changesetUri, { type: 'changeset/filesReviewChanged', files, reviewed: isReviewed });
2438
+ },
2439
+ requestResource: async (uri, access) => {
2440
+ /*
2441
+ * The spread first, so it cannot be the thing that gets this wrong.
2442
+ *
2443
+ * `resourceRequest` declares `channel` as the literal `ahp-root://`, and
2444
+ * a spread *after* a constant is a shape where a caller's own `channel`
2445
+ * silently wins. No caller passes one today; the ordering is what stops
2446
+ * the day one does from being a wrong constant that a lenient host
2447
+ * answers happily.
2448
+ */
2449
+ await client.request('resourceRequest', { ...access, channel: ROOT, uri });
2450
+ },
2451
+ /**
2452
+ * Run one, and let the refusal through.
2453
+ *
2454
+ * Deliberately not negotiating here. A `-32009` carries the request that
2455
+ * would unlock the same call, and answering it is a *decision* - retry
2456
+ * quietly, or stop and ask the person - which belongs above the seam where
2457
+ * the screen and the shell can differ. `operate()` is where that lives, so
2458
+ * it happens the same way against this host and against the scripted one.
2459
+ */
2460
+ invoke: async (changesetUri, operationId, target) => decodeInvoked(await client.request('invokeChangesetOperation', {
2461
+ channel: changesetUri,
2462
+ operationId,
2463
+ ...(target ? { target } : {}),
2464
+ })),
2465
+ changes: async (uri, wanted) => {
2466
+ if (wanted)
2467
+ return changeset(await snapshotOf(wanted) ?? {});
2468
+ const state = await snapshotOf(uri);
2469
+ const entry = list(bag(state).changesets)
2470
+ .map(bag)
2471
+ // The first that is already a URI. One with variables left in it is a
2472
+ // turn or a pair of them, and there is nothing here to fill them from.
2473
+ .find((found) => str(found.uriTemplate) && !str(found.uriTemplate)?.includes('{'));
2474
+ const template = str(entry?.uriTemplate);
2475
+ if (!template)
2476
+ return { status: 'complete', files: [] };
2477
+ return changeset(await snapshotOf(template) ?? {});
2478
+ },
2479
+ content: async (ref) => {
2480
+ // `resourceRead` is on the root channel whatever the content belongs to:
2481
+ // a `ContentRef` uri is opaque and the host resolves it, so there is no
2482
+ // session to address this to.
2483
+ const answer = await client.request('resourceRead', {
2484
+ channel: ROOT, uri: ref.uri, encoding: 'utf-8',
2485
+ });
2486
+ const data = str(answer.data) ?? '';
2487
+ // A host may answer base64 for anything it decides is not text, and
2488
+ // decoding that into a viewer produces a screenful of mojibake. Said
2489
+ // plainly instead.
2490
+ if (str(answer.encoding) === 'base64') {
2491
+ return {
2492
+ text: '',
2493
+ binary: {
2494
+ bytes: Math.floor(data.length * 3 / 4),
2495
+ ...(str(answer.contentType) ? { contentType: str(answer.contentType) } : {}),
2496
+ },
2497
+ };
2498
+ }
2499
+ return { text: data };
2500
+ },
2501
+ customizations: async (uri) => customizations(bag(await snapshotOf(uri)).customizations),
2502
+ /**
2503
+ * Asked as `completions`, which is what the protocol has for this.
2504
+ *
2505
+ * The root channel, because there is no session: a host that serves it
2506
+ * answers with what its harness offers, and one that does not refuses -
2507
+ * which is a slash menu with the client's own commands in it, not a
2508
+ * failure worth reporting.
2509
+ *
2510
+ * A leading slash and nothing after it, so the answer is the whole list.
2511
+ * The menu filters what was typed itself, the same way it does on an open
2512
+ * session, rather than asking again per keystroke.
2513
+ */
2514
+ harnessCommands: async () => {
2515
+ try {
2516
+ const result = await client.request('completions', {
2517
+ channel: ROOT,
2518
+ kind: 'userMessage',
2519
+ text: '/',
2520
+ offset: 1,
2521
+ });
2522
+ return list(result.items).map((raw) => {
2523
+ const item = bag(raw);
2524
+ const attachment = bag(item.attachment);
2525
+ // `insertText` is what would be typed - `/name` or `/name ` - and
2526
+ // the name is what a menu row is. The label is the same thing with
2527
+ // the slash still on it.
2528
+ const name = (str(item.insertText) ?? str(attachment.label) ?? '')
2529
+ .replace(/^\//, '')
2530
+ .trim();
2531
+ return {
2532
+ id: `command:${name}`,
2533
+ kind: 'prompt',
2534
+ name,
2535
+ uri: name,
2536
+ enabled: true,
2537
+ userInvocable: true,
2538
+ ...(plain(attachment.modelRepresentation) ? { description: plain(attachment.modelRepresentation) } : {}),
2539
+ };
2540
+ }).filter((command) => command.name !== '');
2541
+ }
2542
+ catch {
2543
+ return [];
2544
+ }
2545
+ },
2546
+ setCustomizationEnabled: (uri, id, enabled) => {
2547
+ try {
2548
+ // Session scope. The other two are a decision about every session on
2549
+ // this workspace or on this machine, and a panel inside one session is
2550
+ // not where somebody means to make either.
2551
+ client.dispatch(uri, {
2552
+ type: 'session/customizationToggled',
2553
+ id,
2554
+ enablement: [{ kind: 'session', enabled }],
2555
+ });
2556
+ }
2557
+ catch (error) {
2558
+ options.onRefusal?.(uri, reason(error));
2559
+ }
2560
+ },
2561
+ detail: async (uri) => {
2562
+ const state = bag(await snapshotOf(uri));
2563
+ const chatUri = str(state.defaultChat) ?? null;
2564
+ if (chatUri)
2565
+ chats.set(uri, chatUri);
2566
+ const talking = bag(chatUri ? await snapshotOf(chatUri) : {});
2567
+ /*
2568
+ * The last turn that recorded one, and the session's own after that.
2569
+ *
2570
+ * This used to test `str(message.model)` - a string test against an
2571
+ * object - so it matched nothing a host has ever sent and the pane said
2572
+ * "nothing said yet" against every host there is. `SessionState.model`
2573
+ * is the fallback and is a *private extension*: no version of the
2574
+ * protocol declares it, and the one host known to send it means the
2575
+ * session's current model by it. Read last, and read as a string,
2576
+ * because that is all that can be assumed of a field the specification
2577
+ * does not have - including that the next host to send it means the
2578
+ * same thing.
2579
+ *
2580
+ * Both spellings, and both for good. `_meta` is where an extension
2581
+ * belongs and the host that sends this one is moving it there; the bare
2582
+ * field is what every copy of that host already deployed still sends,
2583
+ * and reading only the new name would break against all of them to save
2584
+ * one `??`.
2585
+ */
2586
+ const ran = [...list(talking.turns), talking.activeTurn]
2587
+ .map(bag)
2588
+ .reverse()
2589
+ .map((found) => selection(bag(found.message).model, found.usage))
2590
+ .find((found) => found !== undefined);
2591
+ const last = ran?.id ?? str(bag(state._meta).model) ?? str(state.model);
2592
+ return {
2593
+ resource: uri,
2594
+ chat: chatUri,
2595
+ chats: list(state.chats).map((entry) => ({
2596
+ resource: str(bag(entry).resource) ?? '',
2597
+ title: str(bag(entry).title) ?? 'Chat',
2598
+ })),
2599
+ // What the host said, when it said no. A pane reading "creating" over
2600
+ // a session whose agent is gone is worse than one that says so.
2601
+ // 0.9.0 renamed `creationFailed` to `failed`, and this client speaks
2602
+ // both sides of that rename - so the old name is translated here
2603
+ // rather than carried inland as a second word for one state.
2604
+ lifecycle: (str(state.lifecycle) === 'creationFailed'
2605
+ ? 'failed'
2606
+ : str(state.lifecycle) ?? 'creating'),
2607
+ ...(channels.refusal(uri) !== undefined ? { refusal: channels.refusal(uri) } : {}),
2608
+ config: config(state.config),
2609
+ ...(last !== undefined ? { model: known(last) } : {}),
2610
+ ...(str(state.activity) ? { activity: str(state.activity) } : {}),
2611
+ };
2612
+ },
2613
+ config: async (uri) => config(bag(await snapshotOf(uri)).config),
2614
+ setConfig: (uri, key, value) => {
2615
+ // One key. The action merges into `config.values`, so sending the object
2616
+ // writes back everything this client happened to be holding - including
2617
+ // whatever another client changed while it was on screen.
2618
+ try {
2619
+ client.dispatch(uri, { type: 'session/configChanged', config: { [key]: value } });
2620
+ }
2621
+ catch (error) {
2622
+ options.onRefusal?.(uri, reason(error));
2623
+ }
2624
+ },
2625
+ /** Everything already sent, actually sent. */
2626
+ flush: async () => { await Promise.allSettled([...inFlight]); },
2627
+ close: async () => {
2628
+ // Set, not announced. `onState` means something happened *to* the
2629
+ // connection, and hanging up on purpose is not that - reporting it
2630
+ // sends somebody to check their network over a program that simply
2631
+ // finished.
2632
+ state = 'offline';
2633
+ finished = true;
2634
+ for (const timer of timers)
2635
+ clearTimeout(timer);
2636
+ timers.clear();
2637
+ channels.detach();
2638
+ await client.shutdown();
2639
+ },
2640
+ };
2641
+ }