@softov/ahpc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/dist/src/ahp/channels.d.ts +146 -0
  4. package/dist/src/ahp/channels.js +423 -0
  5. package/dist/src/ahp/connection.d.ts +656 -0
  6. package/dist/src/ahp/connection.js +1 -0
  7. package/dist/src/ahp/fake.d.ts +69 -0
  8. package/dist/src/ahp/fake.js +1996 -0
  9. package/dist/src/ahp/live.d.ts +160 -0
  10. package/dist/src/ahp/live.js +2641 -0
  11. package/dist/src/ahp/operate.d.ts +36 -0
  12. package/dist/src/ahp/operate.js +56 -0
  13. package/dist/src/ahp/publish.d.ts +39 -0
  14. package/dist/src/ahp/publish.js +370 -0
  15. package/dist/src/ahp/status.d.ts +23 -0
  16. package/dist/src/ahp/status.js +23 -0
  17. package/dist/src/ahp/types.d.ts +745 -0
  18. package/dist/src/ahp/types.js +27 -0
  19. package/dist/src/app.d.ts +54 -0
  20. package/dist/src/app.js +369 -0
  21. package/dist/src/blocks.d.ts +88 -0
  22. package/dist/src/blocks.js +102 -0
  23. package/dist/src/cli/main.d.ts +13 -0
  24. package/dist/src/cli/main.js +1537 -0
  25. package/dist/src/cli/render.d.ts +43 -0
  26. package/dist/src/cli/render.js +91 -0
  27. package/dist/src/config.d.ts +63 -0
  28. package/dist/src/config.js +45 -0
  29. package/dist/src/connect.d.ts +53 -0
  30. package/dist/src/connect.js +69 -0
  31. package/dist/src/control.d.ts +189 -0
  32. package/dist/src/control.js +2241 -0
  33. package/dist/src/diff.d.ts +44 -0
  34. package/dist/src/diff.js +111 -0
  35. package/dist/src/flags.d.ts +28 -0
  36. package/dist/src/flags.js +76 -0
  37. package/dist/src/main.d.ts +10 -0
  38. package/dist/src/main.js +54 -0
  39. package/dist/src/schedule.d.ts +46 -0
  40. package/dist/src/schedule.js +140 -0
  41. package/dist/src/screens.d.ts +79 -0
  42. package/dist/src/screens.js +1322 -0
  43. package/dist/src/state.d.ts +355 -0
  44. package/dist/src/state.js +531 -0
  45. package/dist/src/terminal.d.ts +44 -0
  46. package/dist/src/terminal.js +101 -0
  47. package/dist/src/tui.d.ts +92 -0
  48. package/dist/src/tui.js +372 -0
  49. package/dist/src/view/automations.d.ts +31 -0
  50. package/dist/src/view/automations.js +76 -0
  51. package/dist/src/view/bood/art.d.ts +90 -0
  52. package/dist/src/view/bood/art.js +152 -0
  53. package/dist/src/view/bood/beetle.d.ts +7 -0
  54. package/dist/src/view/bood/beetle.js +123 -0
  55. package/dist/src/view/bood/bunny.d.ts +8 -0
  56. package/dist/src/view/bood/bunny.js +228 -0
  57. package/dist/src/view/bood/cat.d.ts +9 -0
  58. package/dist/src/view/bood/cat.js +358 -0
  59. package/dist/src/view/bood/crab.d.ts +7 -0
  60. package/dist/src/view/bood/crab.js +121 -0
  61. package/dist/src/view/bood/index.d.ts +29 -0
  62. package/dist/src/view/bood/index.js +25 -0
  63. package/dist/src/view/bood/motion.d.ts +178 -0
  64. package/dist/src/view/bood/motion.js +298 -0
  65. package/dist/src/view/bood/owl.d.ts +9 -0
  66. package/dist/src/view/bood/owl.js +285 -0
  67. package/dist/src/view/bood/registry.d.ts +36 -0
  68. package/dist/src/view/bood/registry.js +218 -0
  69. package/dist/src/view/bood/render.d.ts +31 -0
  70. package/dist/src/view/bood/render.js +196 -0
  71. package/dist/src/view/bood/sprite.d.ts +18 -0
  72. package/dist/src/view/bood/sprite.js +130 -0
  73. package/dist/src/view/bood/sprout.d.ts +8 -0
  74. package/dist/src/view/bood/sprout.js +121 -0
  75. package/dist/src/view/bood/types.d.ts +158 -0
  76. package/dist/src/view/bood/types.js +57 -0
  77. package/dist/src/view/bubble.d.ts +75 -0
  78. package/dist/src/view/bubble.js +86 -0
  79. package/dist/src/view/changes.d.ts +35 -0
  80. package/dist/src/view/changes.js +22 -0
  81. package/dist/src/view/composer.d.ts +64 -0
  82. package/dist/src/view/composer.js +192 -0
  83. package/dist/src/view/controls.d.ts +44 -0
  84. package/dist/src/view/controls.js +49 -0
  85. package/dist/src/view/creature.d.ts +69 -0
  86. package/dist/src/view/creature.js +83 -0
  87. package/dist/src/view/customizations.d.ts +28 -0
  88. package/dist/src/view/customizations.js +53 -0
  89. package/dist/src/view/details.d.ts +65 -0
  90. package/dist/src/view/details.js +65 -0
  91. package/dist/src/view/filediff.d.ts +29 -0
  92. package/dist/src/view/filediff.js +24 -0
  93. package/dist/src/view/files.d.ts +31 -0
  94. package/dist/src/view/files.js +39 -0
  95. package/dist/src/view/hitl.d.ts +43 -0
  96. package/dist/src/view/hitl.js +171 -0
  97. package/dist/src/view/icons.d.ts +13 -0
  98. package/dist/src/view/icons.js +71 -0
  99. package/dist/src/view/picker.d.ts +42 -0
  100. package/dist/src/view/picker.js +71 -0
  101. package/dist/src/view/sessionhead.d.ts +41 -0
  102. package/dist/src/view/sessionhead.js +60 -0
  103. package/dist/src/view/sessions.d.ts +34 -0
  104. package/dist/src/view/sessions.js +61 -0
  105. package/dist/src/view/terminal.d.ts +30 -0
  106. package/dist/src/view/terminal.js +32 -0
  107. package/dist/src/view/toolcall.d.ts +27 -0
  108. package/dist/src/view/toolcall.js +48 -0
  109. package/dist/src/view/transcript.d.ts +50 -0
  110. package/dist/src/view/transcript.js +60 -0
  111. package/package.json +72 -0
@@ -0,0 +1,1537 @@
1
+ /** Every command, and the argv reading that picks one. */
2
+ import { readFile } from 'node:fs/promises';
3
+ import { connect } from '../connect.js';
4
+ import { configPath, loadConfig } from '../config.js';
5
+ import { ago, archived, branch, json, line, mark, project, table } from './render.js';
6
+ import { operate } from '../ahp/operate.js';
7
+ import { SWITCHES } from '../flags.js';
8
+ export const HELP = `ahpc - drive an agent host from a shell
9
+
10
+ ahpc [--host ws://…] <command> [args] the screen is 'ahpc' with no command
11
+
12
+ Sessions
13
+ session list the catalogue, newest first [--archived] [--json]
14
+ session show <uri> what the host says about one [--full] [--json]
15
+ session new start one [--agent P] [--cwd DIR] [--set k=v]… [--json]
16
+ session rm <uri> dispose it
17
+ session history <uri> its turns [--all] [--full] [--json]
18
+ session config <uri> the schema and what is in force [--json]
19
+ session set <uri> <k> <v> change one config key
20
+ session read <uri> mark read [--unread]
21
+ session archive <uri> put it away [--undo]
22
+ session customizations <uri> skills, prompts, agents, servers [--json]
23
+ session export <uri> the whole session as one document
24
+ [--json] [--markdown]
25
+ session toggle <uri> <id> turn one on [--off]
26
+
27
+ Turns
28
+ prompt <uri> <text> say it and stream the answer [--model M] [--json]
29
+ exec <text> a session, one turn, and dispose it
30
+ [--agent P] [--cwd DIR] [--model M] [--json]
31
+ cancel <uri> stop the running turn
32
+ queue <uri> <text> say it after the one running [--model M]
33
+ unqueue <uri> <id> take it back
34
+
35
+ Answering
36
+ watch <uri> BLOCK until something wants a person, print, exit
37
+ [--until turn|input|idle] [--timeout S] [--json]
38
+ confirm <uri> <toolCallId> approve a tool call [--deny] [--option ID]
39
+ answer <uri> <requestId> answer a question [--field k=v]… [--reject]
40
+
41
+ Chats
42
+ chat list <uri> the conversations in a session [--json]
43
+ chat new <uri> [text] another one beside it
44
+ chat rm <chatUri> close one
45
+
46
+ The harness
47
+ agents what it serves, and each one's models [--json]
48
+ models every model, by harness [--json]
49
+ commands what a slash offers [--json]
50
+ customizations skills, prompts, agents and MCP servers,
51
+ before any session exists [--kind k] [--json]
52
+ completions <uri> <text> what the host would complete [--offset N] [--json]
53
+
54
+ Changes and files
55
+ changes <uri> the files a session touched [--json]
56
+ [--list] [--scope s] [--<variable> v]
57
+ [--reviewed f] [--unreviewed f]
58
+ [--operations] what may be done to it
59
+ [--run id] [--file f] [--yes] do one of them
60
+ [--list] every changeset it offers
61
+ [--scope <name>] one of them, e.g. turn
62
+ [--turnId <id>] what a chosen scope still needs
63
+ [--reviewed <file>] tick one off, repeatable
64
+ [--unreviewed <file>] and clear one
65
+ content <uri> <file> one of them, in full
66
+ resource list <uri> a directory the host serves [--json]
67
+ resource read <uri> a file on the host
68
+ resource stat <uri> what it is, without reading it [--json]
69
+ resource write <uri> [file] from a file, or from stdin [--create-only]
70
+ guarded by the file's etag unless [--force]
71
+ resource rm <uri> delete it [--recursive]
72
+ resource mkdir <uri> make a directory
73
+ resource mv <uri> <to> move it [--fail-if-exists]
74
+ resource cp <uri> <to> copy it [--fail-if-exists]
75
+
76
+ Automations
77
+ automation list what runs on its own [--json]
78
+ automation show <uri> one of them [--json]
79
+ automation triggers what this host can trigger on [--json]
80
+ automation runs <uri> its history, every page [--json]
81
+ automation run <uri> start it now
82
+ automation enable <uri> switch it on
83
+ automation disable <uri> switch it off
84
+ automation rm <uri> forget it
85
+
86
+ The host's own log
87
+ logs what the daemon is saying [--level L] [--follow]
88
+
89
+ Signing in
90
+ auth what this host protects [--json]
91
+ auth <resource> push a token [--token T] [--expires-in S]
92
+ or set AHPC_TOKEN_<RESOURCE>, or pipe one in
93
+
94
+ Terminals
95
+ terminal list what is running [--json]
96
+ terminal new open a shell [--cwd DIR] [--name N]
97
+ terminal rm <uri> kill it
98
+ terminal send <uri> <text> type into it
99
+ terminal watch <uri> follow its output [--timeout S]
100
+
101
+ Recording
102
+ AHPC_RECORD=<file> append every frame, both directions, for
103
+ 'npm run wire' to check against the protocol
104
+
105
+ Anything else
106
+ dispatch <uri> <type> send one action verbatim [--field k=v]… [--chat]
107
+ status what this client is connected to [--json]
108
+ help this
109
+
110
+ The host
111
+ --host <url> ws://host:port, or AHPC_HOST, or the config file
112
+ --token <tkn> a bearer token for it, or AHPC_TOKEN, or the config file
113
+ --config-file read this instead of the one below
114
+ (none) the scripted host, which needs nothing installed
115
+
116
+ Configuration
117
+ config where the file is, and what is in force [--json]
118
+
119
+ Output is for reading. --json is the same answer for a program.
120
+ `;
121
+ /**
122
+ * Flags, read the way agora reads them.
123
+ *
124
+ * A scan rather than a parser: every flag here is `--name value` or a bare
125
+ * switch, and a dependency that handles more shapes than the CLI has would be
126
+ * a dependency for its own sake.
127
+ */
128
+ class Args {
129
+ rest;
130
+ constructor(rest) {
131
+ this.rest = rest;
132
+ }
133
+ /** The nth thing that is not a flag or a flag's value. */
134
+ positional(index) {
135
+ const found = [];
136
+ for (let i = 0; i < this.rest.length; i++) {
137
+ const word = this.rest[i];
138
+ if (word.startsWith('--')) {
139
+ if (!SWITCHES.has(word))
140
+ i++;
141
+ continue;
142
+ }
143
+ found.push(word);
144
+ }
145
+ return found[index];
146
+ }
147
+ value(flag) {
148
+ const at = this.rest.indexOf(flag);
149
+ return at >= 0 ? this.rest[at + 1] : undefined;
150
+ }
151
+ /** Every `--flag value` of one name, for the flags that repeat. */
152
+ every(flag) {
153
+ const found = [];
154
+ this.rest.forEach((word, index) => {
155
+ if (word === flag && this.rest[index + 1] !== undefined)
156
+ found.push(this.rest[index + 1]);
157
+ });
158
+ return found;
159
+ }
160
+ has(flag) { return this.rest.includes(flag); }
161
+ /** `--set k=v --set k2=v2`, as the record the host wants. */
162
+ pairs(flag) {
163
+ const out = {};
164
+ for (const pair of this.every(flag)) {
165
+ const at = pair.indexOf('=');
166
+ if (at <= 0)
167
+ throw new Fault(`${flag} wants key=value, not ${pair}`);
168
+ out[pair.slice(0, at)] = pair.slice(at + 1);
169
+ }
170
+ return out;
171
+ }
172
+ }
173
+ /** A message for the person, not a stack trace. */
174
+ export class Fault extends Error {
175
+ }
176
+ /**
177
+ * Where the host is: a flag, then the environment, then the config file.
178
+ *
179
+ * In that order because each is more deliberate than the next. A flag is this
180
+ * invocation, an environment variable is this shell, and a file is every
181
+ * invocation until somebody edits it - so the narrower answer wins.
182
+ */
183
+ const where = (args) => {
184
+ const file = loadConfig('ahpc', args.value('--config-file'));
185
+ const host = args.value('--host') ?? process.env.AHPC_HOST ?? file.host;
186
+ const token = args.value('--token') ?? process.env.AHPC_TOKEN ?? file.token;
187
+ return {
188
+ ...(host ? { host } : {}),
189
+ ...(token ? { token } : {}),
190
+ ...(args.value('--cwd') ? { path: args.value('--cwd') } : {}),
191
+ // What this client serves back to the host, and whether the host may
192
+ // write into it. Both off unless asked for.
193
+ ...(args.value('--publish') ? { publish: args.value('--publish') } : {}),
194
+ ...(args.has('--publish-writable') ? { publishWritable: true } : {}),
195
+ };
196
+ };
197
+ /** A URI the command needs, said plainly when it is missing. */
198
+ /**
199
+ * The model a command was told to run on, and what it was told to run it at.
200
+ *
201
+ * `--model sonnet --model-config thinkingLevel=high`, repeatable. The second
202
+ * is `ModelSelection.config`, which is where the protocol says the answers to
203
+ * a model's own `configSchema` go - so a level chosen on the command line
204
+ * reaches the host the same way one chosen in the screen does.
205
+ */
206
+ const selected = (args) => {
207
+ const id = args.value('--model');
208
+ if (id === undefined)
209
+ return undefined;
210
+ const config = {};
211
+ for (const pair of args.every('--model-config')) {
212
+ const at = pair.indexOf('=');
213
+ if (at > 0)
214
+ config[pair.slice(0, at)] = pair.slice(at + 1);
215
+ }
216
+ return { id, ...(Object.keys(config).length > 0 ? { config } : {}) };
217
+ };
218
+ const needs = (args, index, what) => {
219
+ const found = args.positional(index);
220
+ if (!found)
221
+ throw new Fault(`This wants ${what}.`);
222
+ return found;
223
+ };
224
+ /**
225
+ * Watch one session until it does something, then stop watching.
226
+ *
227
+ * Every streaming command is this with a different stopping condition, so it
228
+ * is written once. The subscription is always closed - a CLI that left one
229
+ * open would be a process that never exits, which is the one thing a shell
230
+ * cannot work around.
231
+ */
232
+ function until(host, uri, done, options = {}) {
233
+ return new Promise((answer) => {
234
+ let closed = false;
235
+ /*
236
+ * The handle may not exist yet when this runs.
237
+ *
238
+ * A host is entitled to deliver the opening snapshot *synchronously*
239
+ * inside `subscribe` - the scripted one does, and it is the honest thing
240
+ * for a host holding the state already - so a condition satisfied by that
241
+ * first event fires before `subscribe` has returned anything to close.
242
+ * Reading the handle there threw, which made every waiting command fail
243
+ * against the scripted host and work against a socket, purely because one
244
+ * of them answers a tick later.
245
+ */
246
+ let handle;
247
+ const finish = (event) => {
248
+ if (closed)
249
+ return;
250
+ closed = true;
251
+ clearTimeout(timer);
252
+ handle?.close();
253
+ answer(event);
254
+ };
255
+ const timer = setTimeout(() => finish(undefined), Math.max(1, (options.timeoutSeconds ?? 900)) * 1000);
256
+ timer.unref?.();
257
+ handle = host.subscribe(uri, (event) => {
258
+ options.onEvent?.(event);
259
+ if (done(event))
260
+ finish(event);
261
+ });
262
+ // Already over, before there was a handle to close. Closing it now is what
263
+ // `finish` could not do.
264
+ if (closed)
265
+ handle.close();
266
+ });
267
+ }
268
+ /**
269
+ * Ask again once the catalogue moves, for an answer that starts out empty.
270
+ *
271
+ * A harness enumerates its models once its host has asked one, and a host that
272
+ * has just been started has not finished asking. An empty list is a real
273
+ * answer - a harness nobody has signed into has none - so this waits for the
274
+ * host to say something changed rather than for a fixed time, and gives up
275
+ * quickly enough that the real empty answer is still prompt.
276
+ */
277
+ async function settled(host, ask, seconds = 8) {
278
+ const first = await ask();
279
+ if (first.length > 0)
280
+ return first;
281
+ return await new Promise((answer) => {
282
+ const stop = (found) => {
283
+ clearTimeout(timer);
284
+ watching.close();
285
+ answer(found);
286
+ };
287
+ const timer = setTimeout(() => stop([]), seconds * 1000);
288
+ timer.unref?.();
289
+ const watching = host.onSessions(() => {
290
+ void ask().then((found) => { if (found.length > 0)
291
+ stop(found); }).catch(() => { });
292
+ });
293
+ });
294
+ }
295
+ /**
296
+ * How many pages of history `--all` will walk.
297
+ *
298
+ * A bound rather than a promise: a conversation somebody has been having for
299
+ * a year is one this would otherwise read to the end of before printing a
300
+ * line, and stopping is better than appearing to hang.
301
+ */
302
+ const PAGES = 100;
303
+ /** One session's snapshot, and nothing after it. */
304
+ const snapshot = async (host, uri) => {
305
+ const event = await until(host, uri, (e) => e.type === 'snapshot', { timeoutSeconds: 30 });
306
+ return event?.type === 'snapshot' ? event : undefined;
307
+ };
308
+ /** A turn, as a line of prose rather than a tree of parts. */
309
+ const spoken = (turn) => turn.parts
310
+ .map((part) => (part.kind === 'markdown' ? part.content : ''))
311
+ .join('')
312
+ .trim();
313
+ /**
314
+ * One command, and then the process is done.
315
+ *
316
+ * A switch rather than a registry: every branch is a few lines against
317
+ * `HostConnection`, and the shape of the whole surface being readable in one
318
+ * file is worth more than the indirection a registry would buy.
319
+ */
320
+ export async function cli(command, rest) {
321
+ const args = new Args(rest);
322
+ const wants = args.has('--json');
323
+ if (command === 'help' || args.has('--help')) {
324
+ process.stdout.write(HELP);
325
+ return 0;
326
+ }
327
+ /*
328
+ * Answered before any connection, because it is not a question about one.
329
+ *
330
+ * `ahpc config` is what you run when the host cannot be reached and you want
331
+ * to know which host it was trying - so needing a host to answer it would
332
+ * make it useless exactly when it is wanted.
333
+ */
334
+ if (command === 'config') {
335
+ const file = loadConfig('ahpc', args.value('--config-file'));
336
+ const at = args.value('--config-file') ?? configPath('ahpc');
337
+ if (wants) {
338
+ json({ path: at, values: file });
339
+ return 0;
340
+ }
341
+ line(at);
342
+ const rows = Object.entries(file).map(([key, value]) => [key, String(value)]);
343
+ if (rows.length === 0)
344
+ line(' (nothing set)');
345
+ else
346
+ table(rows);
347
+ return 0;
348
+ }
349
+ const host = await connect(where(args));
350
+ try {
351
+ switch (command) {
352
+ case 'status': {
353
+ const rows = await host.listSessions().catch(() => []);
354
+ if (wants) {
355
+ json({ id: host.id, url: host.url, state: host.state(), sessions: rows.length });
356
+ break;
357
+ }
358
+ line(`${host.state()} ${host.url || '(scripted host)'}`);
359
+ line(`${rows.length} session(s)`);
360
+ break;
361
+ }
362
+ case 'session': return await sessions(host, args, wants);
363
+ case 'chat': return await chats(host, args, wants);
364
+ case 'terminal': return await shells(host, args, wants);
365
+ case 'resource': return await files(host, args, wants);
366
+ case 'auth': return await signIn(host, args, wants);
367
+ case 'automation': return await automation(host, args, wants);
368
+ case 'logs': {
369
+ if (!host.watchLogs)
370
+ throw new Fault('This host emits no logs.');
371
+ const follow = args.has('--follow');
372
+ const watching = await host.watchLogs((record) => {
373
+ if (wants) {
374
+ json(record);
375
+ return;
376
+ }
377
+ line([
378
+ record.at?.slice(11, 19) ?? '',
379
+ record.severity ?? '',
380
+ record.body,
381
+ ].filter(Boolean).join(' '));
382
+ }, { ...(args.value('--level') ? { level: args.value('--level') } : {}) });
383
+ // Without `--follow` this is a tail of whatever arrives in the next
384
+ // moment, which is what a stateless channel can offer: telemetry is
385
+ // live-edge only and is not replayed.
386
+ if (!follow) {
387
+ await new Promise((resolve) => { setTimeout(resolve, 1500); });
388
+ watching.close();
389
+ }
390
+ else
391
+ await new Promise(() => undefined);
392
+ return 0;
393
+ }
394
+ case 'agents': {
395
+ const found = await settled(host, () => host.agents());
396
+ if (wants) {
397
+ json(found);
398
+ break;
399
+ }
400
+ table(found.map((a) => [a.provider, a.displayName ?? '', `${a.models.length} model(s)`]));
401
+ break;
402
+ }
403
+ case 'models': {
404
+ // Settled on the models rather than the harnesses: a host advertises a
405
+ // harness at once and its models when it has asked one.
406
+ const found = await settled(host, async () => (await host.agents()).filter((a) => a.models.length > 0));
407
+ if (wants) {
408
+ json(found.flatMap((a) => a.models));
409
+ break;
410
+ }
411
+ // The levels a model takes, where it says. A model that takes one is
412
+ // as worth saying as a model that takes five, and a column that is
413
+ // empty for most rows is what a person scanning for the exception
414
+ // reads.
415
+ table(found.flatMap((a) => a.models.map((m) => [
416
+ m.provider || a.provider,
417
+ m.id,
418
+ m.displayName,
419
+ (m.options ?? []).flatMap((option) => option.values.map((one) => one.value)).join(' '),
420
+ ])));
421
+ break;
422
+ }
423
+ case 'commands': {
424
+ const found = await host.harnessCommands();
425
+ if (wants) {
426
+ json(found);
427
+ break;
428
+ }
429
+ table(found.map((c) => [`/${c.name}`, c.kind, c.description ?? '']));
430
+ break;
431
+ }
432
+ /*
433
+ * What every harness on this host offers, with no session anywhere.
434
+ *
435
+ * `session customizations` is the same list resolved against one
436
+ * session's directory. This is the unresolved one, off the root channel,
437
+ * and it is the only one answerable before somebody has decided which
438
+ * agent to start - which is when a person picking a skill to open with
439
+ * is asking.
440
+ *
441
+ * Settled on, because a harness is advertised at once and what it offers
442
+ * arrives when its probe answers.
443
+ */
444
+ case 'customizations': {
445
+ const kind = args.value('--kind');
446
+ const found = await settled(host, async () => (await host.agents())
447
+ .filter((agent) => (agent.customizations ?? []).length > 0));
448
+ const rows = found.flatMap((agent) => (agent.customizations ?? [])
449
+ .filter((one) => kind === undefined || one.kind === kind)
450
+ .map((one) => ({ provider: agent.provider, ...one })));
451
+ if (wants) {
452
+ json(rows);
453
+ break;
454
+ }
455
+ if (rows.length === 0) {
456
+ line('This host advertises no customizations. A harness nobody has signed into offers none.');
457
+ break;
458
+ }
459
+ table(rows.map((one) => [
460
+ one.provider,
461
+ one.kind,
462
+ one.name,
463
+ // The state is the half a list is read for: a server that needs
464
+ // signing into looks exactly like a working one without it.
465
+ one.state ?? (one.enabled ? 'on' : 'off'),
466
+ // The first sentence, clipped. A skill's description is written for
467
+ // a model deciding whether to load it and runs to a paragraph, which
468
+ // in a table is one row pushing the next sixty off the screen.
469
+ // `--json` is where the whole thing is.
470
+ brief(one.description),
471
+ ]));
472
+ break;
473
+ }
474
+ case 'completions': {
475
+ const uri = needs(args, 0, 'a session URI');
476
+ const text = needs(args, 1, 'the text being typed');
477
+ const offset = args.value('--offset');
478
+ const found = await host.completions({
479
+ channel: uri, text, ...(offset ? { offset: Number(offset) } : {}),
480
+ });
481
+ if (wants) {
482
+ json(found);
483
+ break;
484
+ }
485
+ table(found.map((c) => [c.insertText ?? '', c.label ?? '']));
486
+ break;
487
+ }
488
+ case 'changes': {
489
+ const uri = needs(args, 0, 'a session URI');
490
+ const scopes = (await host.changesets?.(uri)) ?? [];
491
+ /** What a scope is called, once the parts still to be filled in are gone. */
492
+ const named = (template) => template
493
+ .replace(uri, '')
494
+ .replace(/^\/changeset\//, '')
495
+ .replace(/\/?\{\w+\}/g, '');
496
+ if (args.has('--list')) {
497
+ if (wants) {
498
+ json(scopes);
499
+ break;
500
+ }
501
+ if (scopes.length === 0) {
502
+ line('This host advertises no changesets.');
503
+ break;
504
+ }
505
+ // The name to pass to --scope first, since that is what this
506
+ // listing is read for.
507
+ table(scopes.map((s) => [
508
+ named(s.uriTemplate),
509
+ s.variables.map((v) => `--${v}`).join(' '),
510
+ s.reviewable ? 'reviewable' : '',
511
+ s.label,
512
+ s.description ?? '',
513
+ ]));
514
+ break;
515
+ }
516
+ /*
517
+ * Which one, and what fills it in.
518
+ *
519
+ * A scope is chosen by label or by the tail of its template, because
520
+ * those are what `--list` prints; the variables come from flags named
521
+ * after them, which is the only mapping that survives the protocol
522
+ * adding a template shape this client has never heard of.
523
+ */
524
+ const wantedName = args.value('--scope');
525
+ const chosen = wantedName === undefined
526
+ ? undefined
527
+ : scopes.find((s) => s.label === wantedName
528
+ || s.uriTemplate === wantedName
529
+ || named(s.uriTemplate) === wantedName);
530
+ if (wantedName !== undefined && !chosen) {
531
+ throw new Fault(`No changeset called ${wantedName}. 'changes <uri> --list' says what there is.`);
532
+ }
533
+ let target;
534
+ if (chosen) {
535
+ target = chosen.uriTemplate;
536
+ for (const variable of chosen.variables) {
537
+ // `{turnId}` is filled from `--turnId`, and so is anything else
538
+ // the protocol adds later without this needing to know it.
539
+ const given = args.value(`--${variable}`);
540
+ if (given === undefined) {
541
+ throw new Fault(`${chosen.label} needs --${variable}. Its template is ${chosen.uriTemplate}.`);
542
+ }
543
+ target = target.replace(`{${variable}}`, given);
544
+ }
545
+ }
546
+ /*
547
+ * Ticking files off, which needs the changeset's own URI.
548
+ *
549
+ * So it is here rather than a command of its own: choosing which
550
+ * changeset is the same question either way, and a second command
551
+ * would have to ask it again.
552
+ */
553
+ const ticking = args.every('--reviewed').concat(args.every('--unreviewed'));
554
+ if (ticking.length > 0) {
555
+ if (!target)
556
+ throw new Fault('Which changeset? --scope says, and --list says what there is.');
557
+ if (!host.review)
558
+ throw new Fault('This host connection cannot mark files reviewed.');
559
+ const on = args.every('--reviewed');
560
+ const off = args.every('--unreviewed');
561
+ // Whole `file://` URIs are what a row's id is, and what this prints,
562
+ // so a path typed as it was printed is accepted too.
563
+ const idOf = (one) => (one.startsWith('file://') ? one : `file://${one}`);
564
+ if (on.length > 0)
565
+ host.review(target, on.map(idOf), true);
566
+ if (off.length > 0)
567
+ host.review(target, off.map(idOf), false);
568
+ await host.flush?.();
569
+ }
570
+ /*
571
+ * Running one of the verbs the changeset advertises.
572
+ *
573
+ * Here rather than a command of its own for the same reason ticking is:
574
+ * choosing which changeset is the same question, and a second command
575
+ * would ask it again. `--run` names an id from `--operations`, and
576
+ * `--file` points it at a row where the operation is not
577
+ * changeset-wide.
578
+ */
579
+ const running = args.value('--run');
580
+ if (running !== undefined) {
581
+ if (!target)
582
+ throw new Fault('Which changeset? --scope says, and --list says what there is.');
583
+ if (!host.invoke)
584
+ throw new Fault('This host connection cannot run changeset operations.');
585
+ const set = await host.changes(uri, target);
586
+ const one = (set.operations ?? []).find((op) => op.id === running);
587
+ if (!one) {
588
+ throw new Fault(`No operation called ${running} on that changeset.`
589
+ + ` It offers ${(set.operations ?? []).map((op) => op.id).join(', ') || 'none'}.`);
590
+ }
591
+ if (one.status === 'disabled')
592
+ throw new Fault(`${one.label} is disabled right now, probably because a turn is running.`);
593
+ const file = args.value('--file');
594
+ const needsFile = !one.scopes.includes('changeset');
595
+ if (needsFile && file === undefined)
596
+ throw new Fault(`${one.label} acts on one file. Pass --file.`);
597
+ // The protocol says a client MUST show the confirmation before
598
+ // invoking. In a shell that means saying it and requiring the person
599
+ // to have meant it.
600
+ if (one.confirmation !== undefined && !args.has('--yes')) {
601
+ throw new Fault(`${one.confirmation}\nPass --yes to go ahead.`);
602
+ }
603
+ const done = await operate(host, target, running, {
604
+ ...(needsFile || file !== undefined
605
+ ? {
606
+ target: {
607
+ kind: 'resource',
608
+ resource: file?.startsWith('file://') === true ? file : `file://${file ?? ''}`,
609
+ },
610
+ }
611
+ : {}),
612
+ /*
613
+ * A shell says what it is about to do and does it.
614
+ *
615
+ * `--yes` has already been required for anything the host called
616
+ * destructive, so the person has said so once; making them say it
617
+ * twice for the *permission* would be asking about the plumbing
618
+ * rather than about the act. What is not silent is the fact that
619
+ * access was taken, which is printed.
620
+ */
621
+ ask: (request) => {
622
+ line(`Asking ${request.uri} for write access.`);
623
+ return true;
624
+ },
625
+ });
626
+ await host.flush?.();
627
+ if (wants) {
628
+ json(done);
629
+ break;
630
+ }
631
+ line(done.message ?? `${one.label} done.`);
632
+ break;
633
+ }
634
+ const found = await host.changes(uri, target);
635
+ if (wants) {
636
+ json(found);
637
+ break;
638
+ }
639
+ if (args.has('--operations')) {
640
+ const offered = found.operations ?? [];
641
+ if (offered.length === 0) {
642
+ line('This changeset offers nothing to do to it.');
643
+ break;
644
+ }
645
+ // The status is the half worth having: a verb that cannot be pressed
646
+ // right now looks exactly like one that can without it.
647
+ table(offered.map((op) => [
648
+ op.id,
649
+ op.status,
650
+ op.scopes.join('/'),
651
+ op.confirmation !== undefined ? 'asks first' : '',
652
+ op.label,
653
+ brief(op.error?.message ?? op.description),
654
+ ]));
655
+ break;
656
+ }
657
+ if (found.files.length === 0) {
658
+ line('No changes.');
659
+ break;
660
+ }
661
+ // Creation and deletion are the absences, which is how the protocol
662
+ // says them: no `before` is new, no `after` is gone.
663
+ table(found.files.map((f) => [
664
+ f.reviewed ? '\u2713' : ' ',
665
+ f.before === undefined ? 'new' : f.after === undefined ? 'gone' : 'edit',
666
+ `+${f.diff.added} -${f.diff.removed}`,
667
+ f.uri.replace(/^file:\/\//, ''),
668
+ ]));
669
+ break;
670
+ }
671
+ case 'content': {
672
+ const uri = needs(args, 0, 'a session URI');
673
+ const wanted = needs(args, 1, 'a file in it');
674
+ const set = await host.changes(uri);
675
+ // Matched on the path a person would type, not the whole `file://`
676
+ // URI: the changes listing prints the short form, and what it prints
677
+ // is what can be pasted back in.
678
+ const edit = set.files.find((f) => f.uri === wanted || f.uri.endsWith(`/${wanted}`));
679
+ if (!edit)
680
+ throw new Fault(`No file called ${wanted} in that changeset.`);
681
+ const ref = edit.content?.after ?? edit.content?.before;
682
+ if (!ref)
683
+ throw new Fault('The host kept no content for that file.');
684
+ const found = await host.content(ref);
685
+ if (wants) {
686
+ json(found);
687
+ break;
688
+ }
689
+ line(found.text);
690
+ break;
691
+ }
692
+ case 'prompt':
693
+ case 'exec':
694
+ case 'cancel':
695
+ case 'queue':
696
+ case 'unqueue':
697
+ case 'watch':
698
+ case 'confirm':
699
+ case 'answer':
700
+ case 'dispatch':
701
+ return await turns(host, command, args, wants);
702
+ default:
703
+ process.stderr.write(`No command called ${command}. Try 'ahpc help'.\n`);
704
+ return 2;
705
+ }
706
+ return 0;
707
+ }
708
+ finally {
709
+ // Sent, then hung up. A command that dispatches one action and exits is
710
+ // the only caller that can close a connection faster than its own
711
+ // dispatch leaves it.
712
+ await host.flush?.();
713
+ await host.close?.();
714
+ }
715
+ }
716
+ /** Everything under `session`. */
717
+ async function sessions(host, args, wants) {
718
+ const verb = args.positional(0) ?? 'list';
719
+ const uri = args.positional(1);
720
+ if (verb === 'list') {
721
+ const rows = (await host.listSessions())
722
+ .filter((s) => args.has('--archived') || !archived(s.status))
723
+ .sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
724
+ if (wants) {
725
+ json(rows);
726
+ return 0;
727
+ }
728
+ // Empty is a sentence. An empty table is indistinguishable from a table
729
+ // that failed to draw.
730
+ if (rows.length === 0) {
731
+ line('No sessions.');
732
+ return 0;
733
+ }
734
+ table(rows.map((s) => [
735
+ mark(s.status),
736
+ s.title.slice(0, 44),
737
+ [project(s), branch(s)].filter(Boolean).join(' '),
738
+ ago(s.modifiedAt),
739
+ s.resource,
740
+ ]));
741
+ return 0;
742
+ }
743
+ if (verb === 'new') {
744
+ const provider = args.value('--agent') ?? (await host.agents())[0]?.provider;
745
+ if (!provider)
746
+ throw new Fault('This host advertises no harness to start one on.');
747
+ const made = await host.createSession({
748
+ provider,
749
+ ...(args.value('--cwd') ? { workingDirectory: args.value('--cwd') } : {}),
750
+ ...(Object.keys(args.pairs('--set')).length ? { config: args.pairs('--set') } : {}),
751
+ });
752
+ if (wants)
753
+ json({ resource: made });
754
+ else
755
+ line(made);
756
+ return 0;
757
+ }
758
+ if (!uri)
759
+ throw new Fault(`'session ${verb}' wants a session URI.`);
760
+ switch (verb) {
761
+ case 'show': {
762
+ const detail = await host.detail(uri);
763
+ if (args.has('--full') || wants) {
764
+ json(detail);
765
+ return 0;
766
+ }
767
+ const row = (await host.listSessions()).find((s) => s.resource === uri);
768
+ table([
769
+ ['Session', uri],
770
+ ['Title', row?.title ?? ''],
771
+ ['Status', row ? mark(row.status) : ''],
772
+ ['Project', row ? [project(row), branch(row)].filter(Boolean).join(' ') : ''],
773
+ ['Workspace', (row?.workingDirectories ?? []).map((d) => d.replace(/^file:\/\//, '')).join(', ')],
774
+ ['Chat', detail.chat ?? ''],
775
+ ['Model', detail.model ? [detail.model.displayName, detail.model.id].filter(Boolean).join(' ') : ''],
776
+ ['Updated', row ? ago(row.modifiedAt) : ''],
777
+ ].filter(([, value]) => value !== ''));
778
+ return 0;
779
+ }
780
+ case 'rm':
781
+ await host.disposeSession(uri);
782
+ line(`Disposed ${uri}.`);
783
+ return 0;
784
+ case 'read':
785
+ host.setRead(uri, !args.has('--unread'));
786
+ return 0;
787
+ case 'archive':
788
+ host.setArchived(uri, !args.has('--undo'));
789
+ return 0;
790
+ case 'config': {
791
+ const config = await host.config(uri);
792
+ if (wants) {
793
+ json(config);
794
+ return 0;
795
+ }
796
+ table(Object.entries(config.values).map(([key, value]) => [key, String(value)]));
797
+ return 0;
798
+ }
799
+ case 'set': {
800
+ const key = needs(args, 2, 'a config key');
801
+ const value = needs(args, 3, 'a value for it');
802
+ host.setConfig(uri, key, value);
803
+ return 0;
804
+ }
805
+ case 'history': {
806
+ /*
807
+ * Everything the host will give, when asked for it.
808
+ *
809
+ * A snapshot is a tail window on one host and nothing at all on
810
+ * another, so without this the command prints whatever happened to
811
+ * arrive - which against a host that sends no turns is an empty list
812
+ * and no sign that a conversation is there. Bounded, because `--all` is
813
+ * a person asking for a long read and not for an unbounded one.
814
+ */
815
+ if (args.has('--all')) {
816
+ for (let page = 0; page < PAGES; page += 1) {
817
+ if (!await host.loadOlderTurns(uri))
818
+ break;
819
+ }
820
+ }
821
+ const shot = await snapshot(host, uri);
822
+ if (!shot)
823
+ throw new Fault('The host sent no snapshot for that session.');
824
+ const all = [...shot.turns, ...(shot.active ? [shot.active] : [])];
825
+ if (wants || args.has('--full')) {
826
+ json(all);
827
+ return 0;
828
+ }
829
+ for (const turn of all) {
830
+ line(`${turn.role === 'user' ? '›' : '‹'} ${turn.role} ${ago(turn.at)} ${turn.state}`);
831
+ const text = turn.role === 'user' ? (turn.message ?? '') : spoken(turn);
832
+ if (text)
833
+ line(` ${text.replace(/\n/g, '\n ')}`);
834
+ line();
835
+ }
836
+ return 0;
837
+ }
838
+ case 'customizations': {
839
+ const found = await host.customizations(uri);
840
+ if (wants) {
841
+ json(found);
842
+ return 0;
843
+ }
844
+ table(found.map((c) => [c.enabled ? 'on' : 'off', c.kind, c.name, c.description ?? '']));
845
+ return 0;
846
+ }
847
+ /*
848
+ * The whole session, as one document.
849
+ *
850
+ * Everything here could already be read one command at a time and never
851
+ * together, so there was no way to hand somebody a session, keep one after
852
+ * a host is gone, or diff two. This is assembly rather than anything new -
853
+ * `show`, `history`, `customizations` and `changes`, fetched in parallel
854
+ * and written out once.
855
+ *
856
+ * There is no import, and that is not an omission. Nothing in the protocol
857
+ * carries a turn *into* a host: `createSession` starts an empty one and
858
+ * every turn after it is the agent's own work. So a session read out of a
859
+ * host cannot be put back into another, and a command that pretended
860
+ * otherwise would be the worst thing here.
861
+ */
862
+ case 'export': {
863
+ const [detail, shot, custom, rows] = await Promise.all([
864
+ host.detail(uri),
865
+ snapshot(host, uri),
866
+ host.customizations(uri).catch(() => []),
867
+ host.listSessions().catch(() => []),
868
+ ]);
869
+ const row = rows.find((one) => one.resource === uri);
870
+ const turns = [...(shot?.turns ?? []), ...(shot?.active ? [shot.active] : [])];
871
+ /*
872
+ * Every changeset the host will answer for, not only the default one.
873
+ *
874
+ * A scope still carrying `{turnId}` is skipped rather than guessed at:
875
+ * an export that filled a template with the first turn id it saw would
876
+ * be putting a diff in the document that nobody asked about.
877
+ */
878
+ const scopes = (await host.changesets?.(uri).catch(() => [])) ?? [];
879
+ const sets = await Promise.all(scopes
880
+ .filter((scope) => scope.variables.length === 0)
881
+ .map(async (scope) => ({
882
+ label: scope.label,
883
+ uri: scope.uriTemplate,
884
+ changes: await host.changes(uri, scope.uriTemplate).catch(() => undefined),
885
+ })));
886
+ const document = {
887
+ exportedAt: new Date().toISOString(),
888
+ host: { url: host.url },
889
+ session: { resource: uri, ...(row ?? {}), detail },
890
+ turns,
891
+ customizations: custom,
892
+ changesets: sets.filter((one) => one.changes !== undefined),
893
+ };
894
+ if (!args.has('--markdown')) {
895
+ json(document);
896
+ return 0;
897
+ }
898
+ // The readable form, which is what somebody actually pastes into a
899
+ // ticket. One heading per turn, and the diffs as counts rather than
900
+ // bodies - a changeset of forty files would otherwise bury the
901
+ // conversation the document is about.
902
+ line(`# ${row?.title ?? uri}`);
903
+ line();
904
+ line(`- Session: \`${uri}\``);
905
+ if (row?.provider)
906
+ line(`- Harness: ${row.provider}`);
907
+ if (detail.model)
908
+ line(`- Model: ${detail.model.displayName} (\`${detail.model.id}\`)`);
909
+ if (row?.workingDirectories?.length) {
910
+ line(`- Workspace: ${row.workingDirectories.map((d) => d.replace(/^file:\/\//, '')).join(', ')}`);
911
+ }
912
+ line(`- Exported: ${document.exportedAt}`);
913
+ line();
914
+ for (const turn of turns) {
915
+ const text = turn.role === 'user' ? (turn.message ?? '') : spoken(turn);
916
+ // The model and what it was asked for. An export that named the model
917
+ // and not the thinking level recorded half of what produced the answer
918
+ // underneath it.
919
+ const asked = turn.model
920
+ ? [turn.model.id, ...Object.values(turn.model.config ?? {})].join(', ')
921
+ : '';
922
+ line(`## ${turn.role === 'user' ? 'Said' : 'Answered'}${asked ? ` (${asked})` : ''}`);
923
+ line();
924
+ if (text) {
925
+ line(text);
926
+ line();
927
+ }
928
+ }
929
+ for (const set of sets) {
930
+ if (!set.changes || set.changes.files.length === 0)
931
+ continue;
932
+ line(`## ${set.label}`);
933
+ line();
934
+ for (const file of set.changes.files) {
935
+ const kind = file.before === undefined ? 'new' : file.after === undefined ? 'gone' : 'edit';
936
+ line(`- \`${file.uri.replace(/^file:\/\//, '')}\` — ${kind}, +${file.diff.added} -${file.diff.removed}`);
937
+ }
938
+ line();
939
+ }
940
+ return 0;
941
+ }
942
+ case 'toggle': {
943
+ const id = needs(args, 2, 'a customization id');
944
+ host.setCustomizationEnabled(uri, id, !args.has('--off'));
945
+ return 0;
946
+ }
947
+ default: throw new Fault(`No 'session ${verb}'. Try 'ahpc help'.`);
948
+ }
949
+ }
950
+ /**
951
+ * One line of a description, short enough to sit in a column.
952
+ *
953
+ * Skill descriptions are written for a model choosing whether to load one, so
954
+ * they run to a paragraph and carry newlines. `--json` is the whole answer;
955
+ * this is the one a person reads down.
956
+ */
957
+ const brief = (text, width = 72) => {
958
+ if (!text)
959
+ return '';
960
+ const line_ = text.split('\n')[0]?.trim() ?? '';
961
+ return line_.length > width ? `${line_.slice(0, width - 1)}…` : line_;
962
+ };
963
+ /** Everything under `chat`. */
964
+ async function chats(host, args, wants) {
965
+ const verb = args.positional(0) ?? 'list';
966
+ const uri = args.positional(1);
967
+ if (!uri)
968
+ throw new Fault(`'chat ${verb}' wants a URI.`);
969
+ if (verb === 'list') {
970
+ const event = await until(host, uri, (e) => e.type === 'chats', { timeoutSeconds: 30 });
971
+ const items = event?.type === 'chats' ? event.items : [];
972
+ if (wants) {
973
+ json(items);
974
+ return 0;
975
+ }
976
+ if (items.length === 0) {
977
+ line('One chat, and the host says nothing more about it.');
978
+ return 0;
979
+ }
980
+ table(items.map((c) => [c.resource, c.title]));
981
+ return 0;
982
+ }
983
+ if (verb === 'new') {
984
+ const made = await host.createChat(uri, args.positional(2));
985
+ if (wants)
986
+ json({ resource: made });
987
+ else
988
+ line(made);
989
+ return 0;
990
+ }
991
+ if (verb === 'rm') {
992
+ await host.disposeChat(uri);
993
+ line(`Closed ${uri}.`);
994
+ return 0;
995
+ }
996
+ throw new Fault(`No 'chat ${verb}'. Try 'ahpc help'.`);
997
+ }
998
+ /** Everything under `terminal`. */
999
+ async function shells(host, args, wants) {
1000
+ const verb = args.positional(0) ?? 'list';
1001
+ if (verb === 'list') {
1002
+ const rows = await host.terminals();
1003
+ if (wants) {
1004
+ json(rows);
1005
+ return 0;
1006
+ }
1007
+ if (rows.length === 0) {
1008
+ line('No terminals.');
1009
+ return 0;
1010
+ }
1011
+ table(rows.map((r) => [r.resource, r.title ?? '']));
1012
+ return 0;
1013
+ }
1014
+ if (verb === 'new') {
1015
+ const made = await host.createTerminal({
1016
+ ...(args.value('--cwd') ? { cwd: args.value('--cwd') } : {}),
1017
+ ...(args.value('--name') ? { name: args.value('--name') } : {}),
1018
+ });
1019
+ if (wants)
1020
+ json({ resource: made });
1021
+ else
1022
+ line(made);
1023
+ return 0;
1024
+ }
1025
+ const uri = args.positional(1);
1026
+ if (!uri)
1027
+ throw new Fault(`'terminal ${verb}' wants a terminal URI.`);
1028
+ if (verb === 'rm') {
1029
+ await host.disposeTerminal(uri);
1030
+ line(`Closed ${uri}.`);
1031
+ return 0;
1032
+ }
1033
+ if (verb === 'send') {
1034
+ // A newline, because a shell over pipes runs a line rather than a string,
1035
+ // and `terminal send ls` that never runs reads as a terminal that is broken.
1036
+ host.writeTerminal(uri, `${needs(args, 2, 'something to type')}\n`);
1037
+ return 0;
1038
+ }
1039
+ if (verb === 'watch') {
1040
+ let last = '';
1041
+ await new Promise((done) => {
1042
+ const timer = setTimeout(() => { handle.close(); done(); }, Math.max(1, Number(args.value('--timeout') ?? 30)) * 1000);
1043
+ timer.unref?.();
1044
+ const handle = host.watchTerminal(uri, (state) => {
1045
+ // Only what is new. The state carries the whole buffer each time, and
1046
+ // re-printing it per event is the same output over and over.
1047
+ const text = state.output ?? '';
1048
+ if (text.startsWith(last))
1049
+ process.stdout.write(text.slice(last.length));
1050
+ else
1051
+ process.stdout.write(text);
1052
+ last = text;
1053
+ if (state.exitCode !== undefined) {
1054
+ clearTimeout(timer);
1055
+ handle.close();
1056
+ done();
1057
+ }
1058
+ });
1059
+ });
1060
+ return 0;
1061
+ }
1062
+ throw new Fault(`No 'terminal ${verb}'. Try 'ahpc help'.`);
1063
+ }
1064
+ /** Everything under `resource`. */
1065
+ async function files(host, args, wants) {
1066
+ const verb = args.positional(0) ?? 'list';
1067
+ const uri = needs(args, 1, 'a file:// URI on the host');
1068
+ if (!host.resourceList || !host.resourceRead) {
1069
+ throw new Fault('This host serves no filesystem. A host is given one, and this one was not.');
1070
+ }
1071
+ if (verb === 'list') {
1072
+ const found = await host.resourceList(uri);
1073
+ if (wants) {
1074
+ json(found);
1075
+ return 0;
1076
+ }
1077
+ if (found.length === 0) {
1078
+ line('Nothing there.');
1079
+ return 0;
1080
+ }
1081
+ // Directories first, then by name: a listing is navigated downwards before
1082
+ // it is read across.
1083
+ const sorted = [...found].sort((a, b) => (a.kind === b.kind
1084
+ ? a.name.localeCompare(b.name)
1085
+ : a.kind === 'directory' ? -1 : 1));
1086
+ table(sorted.map((e) => [e.kind === 'directory' ? 'dir' : '', e.name, e.size === undefined ? '' : String(e.size)]));
1087
+ return 0;
1088
+ }
1089
+ if (verb === 'read') {
1090
+ const found = await host.resourceRead(uri);
1091
+ if (wants) {
1092
+ json(found);
1093
+ return 0;
1094
+ }
1095
+ // Bytes as bytes, so `ahpc resource read … > out.png` is a file rather
1096
+ // than a screenful of base64.
1097
+ if (found.encoding === 'base64')
1098
+ process.stdout.write(Buffer.from(found.data, 'base64'));
1099
+ else
1100
+ line(found.data);
1101
+ return 0;
1102
+ }
1103
+ if (verb === 'stat') {
1104
+ if (!host.resourceResolve)
1105
+ throw new Fault('This host does not resolve paths.');
1106
+ const found = await host.resourceResolve(uri);
1107
+ if (wants) {
1108
+ json(found);
1109
+ return 0;
1110
+ }
1111
+ table([
1112
+ ['Uri', found.uri],
1113
+ ['Type', found.type],
1114
+ ...(found.size === undefined ? [] : [['Size', String(found.size)]]),
1115
+ ...(found.mtime === undefined ? [] : [['Modified', found.mtime]]),
1116
+ ]);
1117
+ return 0;
1118
+ }
1119
+ if (verb === 'write') {
1120
+ if (!host.resourceWrite)
1121
+ throw new Fault('This host serves no writable filesystem.');
1122
+ // From a file, or from stdin: `ahpc resource write <uri> < thing` is how
1123
+ // this gets used, and a second positional is the convenience.
1124
+ const from = args.positional(2);
1125
+ const data = from === undefined ? await readAll(process.stdin) : await readFile(from, 'utf8');
1126
+ /*
1127
+ * The etag the file has now, unless told not to.
1128
+ *
1129
+ * A write with no `ifMatch` lands on whatever is there, which is how a
1130
+ * read-modify-write loses an edit that arrived in between. Resolved here
1131
+ * rather than asked for, because a person writing a file has no way to
1132
+ * know the token and every write from a shell is a read-modify-write.
1133
+ * `--force` is the way to say the current contents do not matter.
1134
+ */
1135
+ let ifMatch;
1136
+ if (!args.has('--force') && host.resourceResolve) {
1137
+ try {
1138
+ ifMatch = (await host.resourceResolve(uri)).etag;
1139
+ }
1140
+ catch { /* not there yet, so there is nothing to have changed */ }
1141
+ }
1142
+ await host.resourceWrite(uri, data, {
1143
+ ...(args.has('--create-only') ? { createOnly: true } : {}),
1144
+ ...(ifMatch === undefined ? {} : { ifMatch }),
1145
+ });
1146
+ return 0;
1147
+ }
1148
+ if (verb === 'rm') {
1149
+ if (!host.resourceDelete)
1150
+ throw new Fault('This host serves no writable filesystem.');
1151
+ await host.resourceDelete(uri, { ...(args.has('--recursive') ? { recursive: true } : {}) });
1152
+ return 0;
1153
+ }
1154
+ if (verb === 'mkdir') {
1155
+ if (!host.resourceMkdir)
1156
+ throw new Fault('This host serves no writable filesystem.');
1157
+ await host.resourceMkdir(uri);
1158
+ return 0;
1159
+ }
1160
+ if (verb === 'mv' || verb === 'cp') {
1161
+ const move = host.resourceMove;
1162
+ const copy = host.resourceCopy;
1163
+ if (!move || !copy)
1164
+ throw new Fault('This host serves no writable filesystem.');
1165
+ const to = needs(args, 2, 'somewhere to put it');
1166
+ const options = { ...(args.has('--fail-if-exists') ? { failIfExists: true } : {}) };
1167
+ await (verb === 'mv' ? move(uri, to, options) : copy(uri, to, options));
1168
+ return 0;
1169
+ }
1170
+ throw new Fault(`No 'resource ${verb}'. Try 'ahpc help'.`);
1171
+ }
1172
+ /**
1173
+ * Automations, from a script.
1174
+ *
1175
+ * The point of an automation is doing something without a person present, so a
1176
+ * feature only a screen can reach is the wrong shape for it. Every verb here
1177
+ * is one the seam already had; `triggers` and `runs` are the two the screen
1178
+ * never asked for either.
1179
+ */
1180
+ async function automation(host, args, wants) {
1181
+ const verb = args.positional(0) ?? 'list';
1182
+ if (!host.automations)
1183
+ throw new Fault('This host serves no automations.');
1184
+ if (verb === 'list') {
1185
+ const found = await host.automations();
1186
+ if (wants) {
1187
+ json(found);
1188
+ return 0;
1189
+ }
1190
+ table(found.map((one) => [
1191
+ one.enabled ? 'on' : 'off',
1192
+ one.title,
1193
+ one.schedule?.expression ?? '',
1194
+ one.nextRunAt ?? '',
1195
+ one.resource,
1196
+ ]));
1197
+ return 0;
1198
+ }
1199
+ if (verb === 'triggers') {
1200
+ if (!host.automationTriggers)
1201
+ throw new Fault('This host does not say which triggers it has.');
1202
+ const found = await host.automationTriggers();
1203
+ if (wants) {
1204
+ json(found);
1205
+ return 0;
1206
+ }
1207
+ table(found.map((one) => [one.kind, one.title ?? '', one.description ?? '']));
1208
+ return 0;
1209
+ }
1210
+ const uri = needs(args, 1, 'an automation URI');
1211
+ if (verb === 'show') {
1212
+ const found = (await host.automations()).find((one) => one.resource === uri);
1213
+ if (!found)
1214
+ throw new Fault(`No automation at ${uri}.`);
1215
+ if (wants) {
1216
+ json(found);
1217
+ return 0;
1218
+ }
1219
+ table([
1220
+ ['Title', found.title],
1221
+ ['Enabled', found.enabled ? 'yes' : 'no'],
1222
+ ['Schedule', found.schedule?.expression ?? ''],
1223
+ ['Zone', found.schedule?.timeZone ?? ''],
1224
+ ['Next run', found.nextRunAt ?? ''],
1225
+ ['Operations', found.operations.join(', ')],
1226
+ ].filter(([, value]) => value !== ''));
1227
+ return 0;
1228
+ }
1229
+ if (verb === 'runs') {
1230
+ if (!host.automationRuns)
1231
+ throw new Fault('This host does not page run history.');
1232
+ // Every page, because a cursor is the host's and a caller should not have
1233
+ // to hold one to read a history.
1234
+ const rows = [];
1235
+ let cursor;
1236
+ do {
1237
+ const page = await host.automationRuns(uri, cursor);
1238
+ rows.push(...page.runs);
1239
+ cursor = page.nextCursor;
1240
+ } while (cursor !== undefined && rows.length < 500);
1241
+ if (wants) {
1242
+ json(rows);
1243
+ return 0;
1244
+ }
1245
+ table(rows.map((one) => [one.status, one.startedAt ?? '', one.resource]));
1246
+ return 0;
1247
+ }
1248
+ if (verb === 'run') {
1249
+ if (!host.runAutomation)
1250
+ throw new Fault('This host will not start an automation.');
1251
+ await host.runAutomation(uri);
1252
+ return 0;
1253
+ }
1254
+ if (verb === 'enable' || verb === 'disable') {
1255
+ if (!host.setAutomationEnabled)
1256
+ throw new Fault('This host will not switch an automation.');
1257
+ await host.setAutomationEnabled(uri, verb === 'enable');
1258
+ return 0;
1259
+ }
1260
+ if (verb === 'rm') {
1261
+ if (!host.removeAutomation)
1262
+ throw new Fault('This host will not remove an automation.');
1263
+ // The host revalidates, and asking first is what the specification says a
1264
+ // client SHOULD do: an automation that does not advertise `remove` is one
1265
+ // this refuses rather than one the host refuses.
1266
+ const found = (await host.automations()).find((one) => one.resource === uri);
1267
+ if (found && !found.operations.includes('remove')) {
1268
+ throw new Fault(`${uri} does not offer removal.`);
1269
+ }
1270
+ await host.removeAutomation(uri);
1271
+ return 0;
1272
+ }
1273
+ throw new Fault(`No 'automation ${verb}'. Try list, show, triggers, runs, run, enable, disable, rm.`);
1274
+ }
1275
+ /**
1276
+ * A token for one of the host's protected resources.
1277
+ *
1278
+ * `ahpc auth` lists what the host protects; `ahpc auth <resource>` pushes a
1279
+ * token for one. The token comes from `--token`, then an environment variable
1280
+ * named after the resource, then standard input - a flag is this invocation, a
1281
+ * variable is this shell, and neither puts a credential in shell history the
1282
+ * way a positional argument would.
1283
+ */
1284
+ async function signIn(host, args, wants) {
1285
+ if (!host.protectedResources || !host.authenticate) {
1286
+ throw new Fault('This host serves no protected resources.');
1287
+ }
1288
+ const known = await host.protectedResources();
1289
+ const resource = args.positional(0);
1290
+ if (resource === undefined) {
1291
+ if (wants) {
1292
+ json(known);
1293
+ return 0;
1294
+ }
1295
+ if (known.length === 0) {
1296
+ line('This host protects nothing.');
1297
+ return 0;
1298
+ }
1299
+ table(known.map((one) => [one.resource, one.name ?? '', (one.scopes ?? []).join(' ')]));
1300
+ return 0;
1301
+ }
1302
+ const token = args.value('--token')
1303
+ ?? process.env[tokenVariable(resource)]
1304
+ ?? (process.stdin.isTTY ? undefined : (await readAll(process.stdin)).trim());
1305
+ if (token === undefined) {
1306
+ throw new Fault(`No token. Pass --token, set ${tokenVariable(resource)}, or pipe one in.`);
1307
+ }
1308
+ // An expiry is only sent when it is known and is a positive integer, which
1309
+ // is what the specification requires of it.
1310
+ const expires = Number(args.value('--expires-in') ?? '');
1311
+ await host.authenticate(resource, token, {
1312
+ ...(Number.isInteger(expires) && expires > 0 ? { expiresIn: expires } : {}),
1313
+ });
1314
+ return 0;
1315
+ }
1316
+ /**
1317
+ * The environment variable a resource's token is read from.
1318
+ *
1319
+ * Derived from the resource rather than fixed, because a host may protect
1320
+ * several and one variable for all of them is one credential for all of them:
1321
+ * `https://api.anthropic.com` becomes `AHPC_TOKEN_API_ANTHROPIC_COM`.
1322
+ */
1323
+ function tokenVariable(resource) {
1324
+ const name = resource.replace(/^[a-z]+:\/\//, '').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, '');
1325
+ return `AHPC_TOKEN_${name.toUpperCase()}`;
1326
+ }
1327
+ /** Everything on a stream, for the write that takes its content from a pipe. */
1328
+ async function readAll(stream) {
1329
+ const chunks = [];
1330
+ for await (const chunk of stream)
1331
+ chunks.push(Buffer.from(chunk));
1332
+ return Buffer.concat(chunks).toString('utf8');
1333
+ }
1334
+ /** Prompts, approvals, and the escape hatch. */
1335
+ async function turns(host, command, args, wants) {
1336
+ /**
1337
+ * Stream a turn to stdout and answer with the finished one.
1338
+ *
1339
+ * Written against **snapshots**, because that is what a live host sends. Its
1340
+ * `subscribe` re-emits the whole state on every action rather than turning
1341
+ * each one into a delta - the reducers are the authority on what the state
1342
+ * is now, and a second hand-written path from action to screen would be a
1343
+ * second answer to the same question. So "what is new" is the part of the
1344
+ * running turn not yet printed, which is a length rather than an event.
1345
+ */
1346
+ const run = async (uri, text) => {
1347
+ let printed = 0;
1348
+ let sawActive = false;
1349
+ let before = new Set();
1350
+ let first = true;
1351
+ let noted;
1352
+ let answer;
1353
+ // Subscribed before saying anything: the first snapshot is the baseline
1354
+ // that says which turns were already there, and one taken afterwards
1355
+ // would count the new turn among them.
1356
+ const finished = until(host, uri, (event) => {
1357
+ if (event.type !== 'snapshot')
1358
+ return false;
1359
+ if (first) {
1360
+ first = false;
1361
+ before = new Set(event.turns.map((turn) => turn.id));
1362
+ }
1363
+ if (event.active) {
1364
+ sawActive = true;
1365
+ if (!wants) {
1366
+ const now = spoken(event.active);
1367
+ if (now.length > printed) {
1368
+ process.stdout.write(now.slice(printed));
1369
+ printed = now.length;
1370
+ }
1371
+ // On stderr, so a pipe still gets only the answer while a person
1372
+ // watching sees why it stopped.
1373
+ const call = event.active.parts.find((part) => part.kind === 'toolCall'
1374
+ && part.call.status === 'pending-confirmation');
1375
+ if (call?.kind === 'toolCall' && noted !== call.call.id) {
1376
+ noted = call.call.id;
1377
+ process.stderr.write(` · waiting on ${call.call.name} ${call.call.id}\n`);
1378
+ }
1379
+ }
1380
+ return false;
1381
+ }
1382
+ // Something wants a person. Not finished, and not this command's to answer.
1383
+ if (event.input)
1384
+ return false;
1385
+ // Nothing running. Done once a turn of *ours* has finished - one that
1386
+ // was not in the baseline, rather than merely the last in the list.
1387
+ const fresh = event.turns.filter((turn) => turn.role === 'agent' && !before.has(turn.id));
1388
+ if (!sawActive && fresh.length === 0)
1389
+ return false;
1390
+ answer = fresh[fresh.length - 1];
1391
+ return true;
1392
+ }, { timeoutSeconds: Number(args.value('--timeout') ?? 900) });
1393
+ host.say(uri, text, selected(args));
1394
+ await finished;
1395
+ if (!wants && printed > 0)
1396
+ line();
1397
+ return answer;
1398
+ };
1399
+ switch (command) {
1400
+ case 'prompt': {
1401
+ const uri = needs(args, 0, 'a session URI');
1402
+ const turn = await run(uri, needs(args, 1, 'something to say'));
1403
+ if (wants)
1404
+ json(turn ?? { state: 'timeout' });
1405
+ return turn?.state === 'complete' ? 0 : 1;
1406
+ }
1407
+ case 'exec': {
1408
+ const text = needs(args, 0, 'something to say');
1409
+ const provider = args.value('--agent') ?? (await host.agents())[0]?.provider;
1410
+ if (!provider)
1411
+ throw new Fault('This host advertises no harness to start one on.');
1412
+ const uri = await host.createSession({
1413
+ provider,
1414
+ ...(args.value('--cwd') ? { workingDirectory: args.value('--cwd') } : {}),
1415
+ });
1416
+ try {
1417
+ const turn = await run(uri, text);
1418
+ if (wants)
1419
+ json(turn ?? { state: 'timeout' });
1420
+ return turn?.state === 'complete' ? 0 : 1;
1421
+ }
1422
+ finally {
1423
+ // One turn, and then it is gone - which is what makes this the
1424
+ // one-shot rather than `session new` followed by `prompt`.
1425
+ await host.disposeSession(uri).catch(() => { });
1426
+ }
1427
+ }
1428
+ case 'cancel':
1429
+ host.stopTurn(needs(args, 0, 'a session URI'));
1430
+ return 0;
1431
+ case 'queue': {
1432
+ const uri = needs(args, 0, 'a session URI');
1433
+ host.queue(uri, needs(args, 1, 'something to say'), selected(args));
1434
+ return 0;
1435
+ }
1436
+ case 'unqueue': {
1437
+ const uri = needs(args, 0, 'a session URI');
1438
+ host.unqueue(uri, needs(args, 1, 'a queued message id'));
1439
+ return 0;
1440
+ }
1441
+ case 'watch': {
1442
+ const uri = needs(args, 0, 'a session URI');
1443
+ const stop = args.value('--until') ?? 'input';
1444
+ let first = true;
1445
+ // What "done" means, so a script can wait for the thing it will act on:
1446
+ // an approval to give, an answer to read, or simply quiet. Read off the
1447
+ // snapshot, because that is the only event a live host sends.
1448
+ const reached = (event) => {
1449
+ if (event.type !== 'snapshot')
1450
+ return false;
1451
+ /*
1452
+ * Whether the state it arrived in counts.
1453
+ *
1454
+ * Two of these are *conditions* and one is an event. A session that is
1455
+ * already quiet satisfies "block until it is quiet", and one that is
1456
+ * already waiting on a person satisfies "block until something wants
1457
+ * a person" - a pending input is not the past, it is still pending,
1458
+ * and skipping it means a script hangs on exactly the approval it was
1459
+ * started to give.
1460
+ *
1461
+ * `turn` is the event: a finished turn in the opening snapshot is
1462
+ * history, and returning it would answer about the turn before this
1463
+ * one.
1464
+ */
1465
+ const opening = first;
1466
+ first = false;
1467
+ if (stop === 'idle')
1468
+ return event.active === undefined && event.input === undefined;
1469
+ if (stop === 'input')
1470
+ return event.input !== undefined;
1471
+ if (opening)
1472
+ return false;
1473
+ return event.active === undefined && event.turns.length > 0;
1474
+ };
1475
+ const event = await until(host, uri, reached, {
1476
+ timeoutSeconds: Number(args.value('--timeout') ?? 900),
1477
+ });
1478
+ if (!event || event.type !== 'snapshot') {
1479
+ process.stderr.write('Nothing happened before the timeout.\n');
1480
+ return 1;
1481
+ }
1482
+ if (wants) {
1483
+ json(event);
1484
+ return 0;
1485
+ }
1486
+ const input = event.input;
1487
+ if (input?.kind === 'toolConfirmation')
1488
+ line(`tool ${input.call.id} ${input.call.name}`);
1489
+ else if (input?.kind === 'chatInput')
1490
+ line(`question ${input.id} ${input.message}`);
1491
+ else {
1492
+ const last = event.turns[event.turns.length - 1];
1493
+ line(last ? spoken(last) : 'idle');
1494
+ }
1495
+ return 0;
1496
+ }
1497
+ case 'confirm': {
1498
+ const uri = needs(args, 0, 'a session URI');
1499
+ const call = needs(args, 1, 'a tool call id');
1500
+ host.confirmToolCall(uri, call, !args.has('--deny'), args.value('--option'));
1501
+ return 0;
1502
+ }
1503
+ case 'answer': {
1504
+ const uri = needs(args, 0, 'a session URI');
1505
+ const request = needs(args, 1, 'an input request id');
1506
+ // Everything arrives as text, because argv is text. A host that wanted a
1507
+ // number said so in its schema, and guessing here would send `1` for the
1508
+ // answer "1" to a question that asked for a name.
1509
+ const answers = {};
1510
+ for (const [key, value] of Object.entries(args.pairs('--field'))) {
1511
+ answers[key] = { kind: 'text', value };
1512
+ }
1513
+ host.completeInput(uri, request, !args.has('--reject'), answers);
1514
+ return 0;
1515
+ }
1516
+ case 'dispatch': {
1517
+ const uri = needs(args, 0, 'a session URI');
1518
+ const type = needs(args, 1, 'an action type, e.g. session/isReadChanged');
1519
+ if (!host.dispatch) {
1520
+ throw new Fault('This host connection sends no raw actions. Only a live host does.');
1521
+ }
1522
+ const action = { type };
1523
+ for (const [key, value] of Object.entries(args.pairs('--field'))) {
1524
+ // `true`, `false` and numbers as themselves; everything else as text.
1525
+ // An action field is typed by the protocol, and sending "true" where a
1526
+ // boolean belongs is a dispatch the host quietly ignores.
1527
+ action[key] = value === 'true' ? true : value === 'false' ? false
1528
+ : /^-?\d+(\.\d+)?$/.test(value) ? Number(value) : value;
1529
+ }
1530
+ host.dispatch(uri, action, args.has('--chat'));
1531
+ if (wants)
1532
+ json(action);
1533
+ return 0;
1534
+ }
1535
+ default: throw new Fault(`No command called ${command}.`);
1536
+ }
1537
+ }