@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.
- package/LICENSE +21 -0
- package/README.md +296 -0
- package/dist/src/ahp/channels.d.ts +146 -0
- package/dist/src/ahp/channels.js +423 -0
- package/dist/src/ahp/connection.d.ts +656 -0
- package/dist/src/ahp/connection.js +1 -0
- package/dist/src/ahp/fake.d.ts +69 -0
- package/dist/src/ahp/fake.js +1996 -0
- package/dist/src/ahp/live.d.ts +160 -0
- package/dist/src/ahp/live.js +2641 -0
- package/dist/src/ahp/operate.d.ts +36 -0
- package/dist/src/ahp/operate.js +56 -0
- package/dist/src/ahp/publish.d.ts +39 -0
- package/dist/src/ahp/publish.js +370 -0
- package/dist/src/ahp/status.d.ts +23 -0
- package/dist/src/ahp/status.js +23 -0
- package/dist/src/ahp/types.d.ts +745 -0
- package/dist/src/ahp/types.js +27 -0
- package/dist/src/app.d.ts +54 -0
- package/dist/src/app.js +369 -0
- package/dist/src/blocks.d.ts +88 -0
- package/dist/src/blocks.js +102 -0
- package/dist/src/cli/main.d.ts +13 -0
- package/dist/src/cli/main.js +1537 -0
- package/dist/src/cli/render.d.ts +43 -0
- package/dist/src/cli/render.js +91 -0
- package/dist/src/config.d.ts +63 -0
- package/dist/src/config.js +45 -0
- package/dist/src/connect.d.ts +53 -0
- package/dist/src/connect.js +69 -0
- package/dist/src/control.d.ts +189 -0
- package/dist/src/control.js +2241 -0
- package/dist/src/diff.d.ts +44 -0
- package/dist/src/diff.js +111 -0
- package/dist/src/flags.d.ts +28 -0
- package/dist/src/flags.js +76 -0
- package/dist/src/main.d.ts +10 -0
- package/dist/src/main.js +54 -0
- package/dist/src/schedule.d.ts +46 -0
- package/dist/src/schedule.js +140 -0
- package/dist/src/screens.d.ts +79 -0
- package/dist/src/screens.js +1322 -0
- package/dist/src/state.d.ts +355 -0
- package/dist/src/state.js +531 -0
- package/dist/src/terminal.d.ts +44 -0
- package/dist/src/terminal.js +101 -0
- package/dist/src/tui.d.ts +92 -0
- package/dist/src/tui.js +372 -0
- package/dist/src/view/automations.d.ts +31 -0
- package/dist/src/view/automations.js +76 -0
- package/dist/src/view/bood/art.d.ts +90 -0
- package/dist/src/view/bood/art.js +152 -0
- package/dist/src/view/bood/beetle.d.ts +7 -0
- package/dist/src/view/bood/beetle.js +123 -0
- package/dist/src/view/bood/bunny.d.ts +8 -0
- package/dist/src/view/bood/bunny.js +228 -0
- package/dist/src/view/bood/cat.d.ts +9 -0
- package/dist/src/view/bood/cat.js +358 -0
- package/dist/src/view/bood/crab.d.ts +7 -0
- package/dist/src/view/bood/crab.js +121 -0
- package/dist/src/view/bood/index.d.ts +29 -0
- package/dist/src/view/bood/index.js +25 -0
- package/dist/src/view/bood/motion.d.ts +178 -0
- package/dist/src/view/bood/motion.js +298 -0
- package/dist/src/view/bood/owl.d.ts +9 -0
- package/dist/src/view/bood/owl.js +285 -0
- package/dist/src/view/bood/registry.d.ts +36 -0
- package/dist/src/view/bood/registry.js +218 -0
- package/dist/src/view/bood/render.d.ts +31 -0
- package/dist/src/view/bood/render.js +196 -0
- package/dist/src/view/bood/sprite.d.ts +18 -0
- package/dist/src/view/bood/sprite.js +130 -0
- package/dist/src/view/bood/sprout.d.ts +8 -0
- package/dist/src/view/bood/sprout.js +121 -0
- package/dist/src/view/bood/types.d.ts +158 -0
- package/dist/src/view/bood/types.js +57 -0
- package/dist/src/view/bubble.d.ts +75 -0
- package/dist/src/view/bubble.js +86 -0
- package/dist/src/view/changes.d.ts +35 -0
- package/dist/src/view/changes.js +22 -0
- package/dist/src/view/composer.d.ts +64 -0
- package/dist/src/view/composer.js +192 -0
- package/dist/src/view/controls.d.ts +44 -0
- package/dist/src/view/controls.js +49 -0
- package/dist/src/view/creature.d.ts +69 -0
- package/dist/src/view/creature.js +83 -0
- package/dist/src/view/customizations.d.ts +28 -0
- package/dist/src/view/customizations.js +53 -0
- package/dist/src/view/details.d.ts +65 -0
- package/dist/src/view/details.js +65 -0
- package/dist/src/view/filediff.d.ts +29 -0
- package/dist/src/view/filediff.js +24 -0
- package/dist/src/view/files.d.ts +31 -0
- package/dist/src/view/files.js +39 -0
- package/dist/src/view/hitl.d.ts +43 -0
- package/dist/src/view/hitl.js +171 -0
- package/dist/src/view/icons.d.ts +13 -0
- package/dist/src/view/icons.js +71 -0
- package/dist/src/view/picker.d.ts +42 -0
- package/dist/src/view/picker.js +71 -0
- package/dist/src/view/sessionhead.d.ts +41 -0
- package/dist/src/view/sessionhead.js +60 -0
- package/dist/src/view/sessions.d.ts +34 -0
- package/dist/src/view/sessions.js +61 -0
- package/dist/src/view/terminal.d.ts +30 -0
- package/dist/src/view/terminal.js +32 -0
- package/dist/src/view/toolcall.d.ts +27 -0
- package/dist/src/view/toolcall.js +48 -0
- package/dist/src/view/transcript.d.ts +50 -0
- package/dist/src/view/transcript.js +60 -0
- 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
|
+
}
|