@softov/ahpc 0.1.0 → 0.3.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/README.md CHANGED
@@ -1,5 +1,6 @@
1
1
  # ahpc
2
2
 
3
+ [![CI](https://github.com/softov/ahpc/actions/workflows/ci.yml/badge.svg)](https://github.com/softov/ahpc/actions/workflows/ci.yml)
3
4
  [![npm](https://img.shields.io/npm/v/%40softov%2Fahpc)](https://www.npmjs.com/package/@softov/ahpc)
4
5
  ![license MIT](https://img.shields.io/badge/license-MIT-blue)
5
6
  ![node >=22](https://img.shields.io/badge/node-%3E%3D22-5fa04e)
@@ -7,7 +8,7 @@
7
8
  ![built with TextUI](https://img.shields.io/badge/built%20with-TextUI-7048e8)
8
9
 
9
10
  A terminal client for the [Agent Host Protocol](https://microsoft.github.io/agent-host-protocol/).
10
- It can be used as cli (commands) or tui (interactive chat).
11
+ It can be used as cli (commands), tui (interactive chat), or a tool server that lets an agent elsewhere drive the sessions on your host.
11
12
 
12
13
  Connect to an AHP host, manage sessions, and work with agents directly from your terminal.
13
14
 
@@ -55,6 +56,7 @@ ahpc session list --host ws://127.0.0.1:9187
55
56
  | Automations | Scheduled and triggered runs, with their history. |
56
57
  | Customizations | Skills, prompts, agents and MCP servers, and which are enabled. |
57
58
  | Telemetry | Stream the host's log. |
59
+ | Tool server | Serve those sessions to an agent somewhere else, over MCP or a plain JSON API. |
58
60
 
59
61
  ## Interactive
60
62
 
@@ -209,14 +211,80 @@ Writes are guarded by the file's etag unless you pass `--force`, so two clients
209
211
  | `automation enable <uri>` / `disable <uri>` | Enable or disable it | |
210
212
  | `automation rm <uri>` | Delete it | |
211
213
 
214
+ ### Serving these sessions to something else
215
+
216
+ | Command | | |
217
+ |---|---|---|
218
+ | `mcp` | MCP on stdin and stdout, for a client that launches this process | `--mcp-tools G,…` |
219
+ | `serve` | The same tools on a socket, shared | `--serve-host H` `--serve-port N` `--serve-token T` `--serve-origin URL` `--mcp-tools G,…` |
220
+
212
221
  ### Anything else
213
222
 
214
223
  | Command | | |
215
224
  |---|---|---|
216
225
  | `dispatch <uri> <type>` | Send a raw protocol action | `--field k=v` `--chat` |
217
226
  | `config` | Show the config file path and current values | `--json` |
227
+ | `--version` | What version this is | |
218
228
  | `help` | Print this command list | |
219
229
 
230
+ ## As a tool server
231
+
232
+ The other direction: an agent somewhere else driving the sessions on your host, through this client. Twelve tools - list, create and dispose a session, read its transcript, say something and wait for the answer, and answer what the agent stops to ask.
233
+
234
+ `ahpc mcp` speaks MCP on stdin and stdout, which is what an MCP client that launches the process expects:
235
+
236
+ ```json
237
+ {
238
+ "mcpServers": {
239
+ "ahp": { "command": "ahpc", "args": ["--host", "ws://127.0.0.1:9187", "mcp"] }
240
+ }
241
+ }
242
+ ```
243
+
244
+ `ahpc serve` is the same twelve tools on a socket that several callers share, and it stays up until it is stopped:
245
+
246
+ ```sh
247
+ ahpc --host ws://127.0.0.1:9187 serve --serve-port 7431
248
+ ```
249
+
250
+ `POST /mcp` is MCP for a client that speaks it. `POST /api/<tool>` is the same tool with the arguments as the body and the answer as the body, for everything that is not one - a shell script, a webhook, a program in another language. `GET /api` lists what there is.
251
+
252
+ ```sh
253
+ curl -XPOST localhost:7431/api/new_session -d '{"workingDirectory":"/work"}'
254
+ curl -XPOST localhost:7431/api/send_turn -d '{"session":"claude:/…","text":"what is in this directory"}'
255
+ ```
256
+
257
+ ### More than the twelve
258
+
259
+ Files, terminals, automations and changesets are there too, one group at a time, and off unless asked for. That is on purpose: a tool table is read by a model alongside everything else it was given, and thirty tools is a worse server than twelve for the thing almost everybody wants, which is driving a session.
260
+
261
+ ```sh
262
+ ahpc --host ws://127.0.0.1:9187 mcp --mcp-tools resources,changes
263
+ ```
264
+
265
+ | Group | Tools |
266
+ |---|---|
267
+ | `resources` | `list_directory` `read_file` `write_file` `make_directory` `delete_path` `move_path` `copy_path` |
268
+ | `terminals` | `list_terminals` `new_terminal` `send_to_terminal` `read_terminal` `dispose_terminal` |
269
+ | `automations` | `list_automations` `run_automation` `set_automation_enabled` `remove_automation` |
270
+ | `changes` | `list_changesets` `show_changes` |
271
+
272
+ Repeatable as well as comma-separated. It is `--mcp-tools` rather than `--tools` because every other flag on this client is an AHP thing, and a bare `--tools` would read like it was choosing which tools the *agent* may call - a different question with a different answer.
273
+
274
+ Calling a tool from a group nobody turned on is refused with the flag that would turn it on, rather than with "no such tool", because those are different problems and only the person who started the server can fix the first.
275
+
276
+ Writing needs the host to have granted write access to that directory, and a host that has not refuses with `-32009` saying so. That is not something this client can grant on a model's behalf: it is the same question a person answers before a session may edit their repository.
277
+
278
+ `read_file` and `write_file` take a `file://` URI on the *host*, not a path on the machine running `ahpc` - the two may not be the same machine. `write_file` reads the file's etag first and refuses a write if it changed in between, unless passed `force`; a model reading a file, thinking, and writing it back is a read-modify-write with a person editing in the middle of it.
279
+
280
+ `send_turn` blocks until the turn ends and returns what the agent said. A turn that stops to ask a person something is not finished: `wait_for_attention` says what it wants, and `confirm_tool_call` and `answer_question` answer it.
281
+
282
+ A caller that does not want to sit in silence for a minute puts a `progressToken` in the request's `_meta`, and gets a `notifications/progress` line for each tool the agent reaches for. On `serve` that also decides the shape of the reply: asked for, the POST is answered with an SSE stream carrying the notifications and then the result; not asked for, it is one JSON object. MCP has no shape for streaming partial *result* content, so the reply itself still arrives whole at the end - what this fixes is an agent that looked frozen, not one you want to watch write.
283
+
284
+ It binds to `127.0.0.1` unless told otherwise, because anybody who can reach the port can drive every session on the host. `--serve-token` sets a bearer token, which is what makes `--serve-host 0.0.0.0` defensible.
285
+
286
+ Requests carrying a browser `Origin` are refused unless the origin is this server's own or was named with `--serve-origin`, repeatable. That is the transport's own rule and it is not paranoia: loopback is not the protection it looks like, because a page on any site can POST to `127.0.0.1` from inside the browser of the person running this, and the request arrives from their own machine. A program - a script, a webhook, an MCP client - sends no `Origin` and is let through.
287
+
220
288
  ## AHP support
221
289
 
222
290
  All 30 client-to-server requests are implemented, and 21 of the 45 client-dispatchable actions are used. `ahpc` subscribes to the root, session, chat, terminal and automation channels, plus the telemetry channel the host advertises for its log.
@@ -21,7 +21,7 @@ export function openChannels(options) {
21
21
  const found = held.get(uri);
22
22
  if (found)
23
23
  return found;
24
- const made = { uses: 0, consumers: new Set(), opened: false, told: false, waiting: [] };
24
+ const made = { uses: 0, consumers: new Set(), opened: false, known: false, told: false, waiting: [] };
25
25
  held.set(uri, made);
26
26
  return made;
27
27
  };
@@ -142,6 +142,7 @@ export function openChannels(options) {
142
142
  const asking = client.subscribe(uri).then(({ result }) => {
143
143
  if (era === generation && held.get(uri) === channel) {
144
144
  channel.opened = true;
145
+ channel.known = true;
145
146
  channel.fromSeq = result.snapshot?.fromSeq;
146
147
  }
147
148
  return bag(result.snapshot?.state);
@@ -320,7 +321,11 @@ export function openChannels(options) {
320
321
  drop(uri);
321
322
  }
322
323
  },
323
- held: () => [...held.keys()].filter((uri) => (held.get(uri)?.uses ?? 0) > 0),
324
+ // What the host can be asked to resume: held by a reader *and* opened at
325
+ // some point, because the host cannot restore a subscription it was never
326
+ // sent.
327
+ held: () => [...held.keys()]
328
+ .filter((uri) => (held.get(uri)?.uses ?? 0) > 0 && held.get(uri)?.known === true),
324
329
  seq: () => seen,
325
330
  refusal: (uri) => refused.get(uri),
326
331
  forget: (uri) => { if (uri === undefined)
@@ -368,7 +373,7 @@ export function openChannels(options) {
368
373
  // subscriptions behind it are ones it restored itself and none of them
369
374
  // needs asking for again.
370
375
  for (const channel of held.values()) {
371
- if (channel.uses === 0 || channel.opened)
376
+ if (channel.uses === 0 || channel.opened || !channel.known)
372
377
  continue;
373
378
  channel.opened = true;
374
379
  channel.told = true;
@@ -386,6 +391,7 @@ export function openChannels(options) {
386
391
  if (!channel)
387
392
  continue;
388
393
  channel.opened = true;
394
+ channel.known = true;
389
395
  channel.told = true;
390
396
  channel.waiting.length = 0;
391
397
  for (const consumer of channel.consumers)
@@ -403,6 +409,7 @@ export function openChannels(options) {
403
409
  adopt: (uri, state) => {
404
410
  const channel = entry(uri);
405
411
  channel.opened = true;
412
+ channel.known = true;
406
413
  channel.told = false;
407
414
  channel.initial = state;
408
415
  },
@@ -427,11 +427,12 @@ export function activityOf(status, asked, running, failed) {
427
427
  */
428
428
  function transcript(chat) {
429
429
  const out = [];
430
- const add = (value, running) => {
430
+ const built = (value, running) => {
431
+ const rows = [];
431
432
  const found = bag(value);
432
433
  const said = str(bag(found.message).text);
433
434
  if (said) {
434
- out.push({
435
+ rows.push({
435
436
  id: `${str(found.id) ?? ''}:said`,
436
437
  role: 'user',
437
438
  message: said,
@@ -442,14 +443,70 @@ function transcript(chat) {
442
443
  }
443
444
  const agent = turn(found, running);
444
445
  delete agent.message;
445
- out.push(agent);
446
+ rows.push(agent);
447
+ return rows;
446
448
  };
447
- for (const entry of list(chat.turns))
448
- add(entry, false);
449
+ const finished = list(chat.turns);
450
+ /*
451
+ * The whole finished prefix, when it is the same array it was last time.
452
+ *
453
+ * The per-turn cache below removes the building; this removes the walking.
454
+ * While a reply streams, only `activeTurn` changes - `turns` keeps its
455
+ * identity through every delta - so the history is copied once per turn
456
+ * rather than once per token, which is the difference between constant and
457
+ * linear on the path that runs most.
458
+ */
459
+ const whole = typeof chat.turns === 'object' && chat.turns !== null
460
+ ? prefixes.get(chat.turns)
461
+ : undefined;
462
+ if (whole !== undefined)
463
+ out.push(...whole);
464
+ else
465
+ for (const entry of finished) {
466
+ /*
467
+ * A finished turn is built once, and after that it is the same object.
468
+ *
469
+ * This runs after every reduced action, so a token arriving into a long
470
+ * conversation rebuilt every turn in it - measured at 2.5ms per token into
471
+ * two thousand turns, against a microsecond for the reduce that caused it.
472
+ *
473
+ * Keyed on the turn object rather than on its id, which is what makes the
474
+ * invalidation right by construction: the reducer is immutable, so a turn
475
+ * that changed is a new object and misses, and a turn that did not is the
476
+ * same one and hits. History loads, truncation and reconnect snapshots all
477
+ * build new objects, so there is nothing to remember to invalidate - which
478
+ * is the part of a cache like this that goes wrong.
479
+ *
480
+ * Weak, so nothing here keeps a conversation alive after the view on it
481
+ * has gone. Nothing mutates a `Turn` - every reader treats them as values -
482
+ * which is what makes handing the same array back twice safe.
483
+ */
484
+ if (typeof entry === 'object' && entry !== null) {
485
+ const had = ready.get(entry);
486
+ if (had !== undefined) {
487
+ out.push(...had);
488
+ continue;
489
+ }
490
+ const made = built(entry, false);
491
+ ready.set(entry, made);
492
+ out.push(...made);
493
+ continue;
494
+ }
495
+ out.push(...built(entry, false));
496
+ }
497
+ if (whole === undefined && typeof chat.turns === 'object' && chat.turns !== null) {
498
+ prefixes.set(chat.turns, [...out]);
499
+ }
500
+ // Never the running turn: it is a new object on every delta, so caching it
501
+ // would be a write per token and a hit never.
449
502
  if (chat.activeTurn)
450
- add(chat.activeTurn, true);
503
+ out.push(...built(chat.activeTurn, true));
451
504
  return out;
452
505
  }
506
+ /** What `transcript` has already built for a finished turn. See the note in it. */
507
+ const ready = new WeakMap();
508
+ /** And for a whole `turns` array, which is what a streaming reply does not change. */
509
+ const prefixes = new WeakMap();
453
510
  const KINDS = {
454
511
  text: 'text', number: 'number', integer: 'integer', boolean: 'boolean',
455
512
  'single-select': 'single-select', 'multi-select': 'multi-select',
@@ -153,6 +153,26 @@ export function publish(options = {}) {
153
153
  * What is not deliberate is fixing one and not the other: everything here
154
154
  * was wrong in both at once, and was corrected in both at once.
155
155
  */
156
+ /*
157
+ * The destination half of a copy or a move.
158
+ *
159
+ * Two refusals the source does not have. A destination that is a symbolic
160
+ * link would carry the bytes to wherever it points, which is the same hole
161
+ * `O_NOFOLLOW` closes for a write. And `failIfExists` is the caller saying
162
+ * it does not want an overwrite, which is the whole reason the flag is on
163
+ * the wire - a copy that quietly replaced a file is the loss it exists to
164
+ * prevent. `ahpd` refuses both in its `pair`.
165
+ */
166
+ const pair = async (destination, failIfExists) => {
167
+ const to = await where(destination, true);
168
+ if (await lstat(to).then((found) => found.isSymbolicLink(), () => false)) {
169
+ throw new PublishRefusal(PERMISSION_DENIED, `${String(destination)} is a symbolic link.`);
170
+ }
171
+ if (failIfExists && await stat(to).then(() => true, () => false)) {
172
+ throw new PublishRefusal(ALREADY_EXISTS, `${String(destination)} already exists.`);
173
+ }
174
+ return to;
175
+ };
156
176
  const write = async (at, uri, params) => {
157
177
  const before = writes.get(at) ?? Promise.resolve();
158
178
  const operation = before.catch(() => { }).then(async () => {
@@ -323,7 +343,10 @@ export function publish(options = {}) {
323
343
  resourceWrite: async (params) => {
324
344
  const input = params;
325
345
  mutable();
326
- await write(await where(input.uri), input.uri, input);
346
+ // The link itself, never its destination: `O_NOFOLLOW` in `write` can
347
+ // only refuse a final symbolic link if the path it is handed still
348
+ // has one. `ahpd` resolves only the parent for the same reason.
349
+ await write(await where(input.uri, true), input.uri, input);
327
350
  return {};
328
351
  },
329
352
  resourceDelete: async (params) => {
@@ -334,20 +357,22 @@ export function publish(options = {}) {
334
357
  },
335
358
  resourceMkdir: async (params) => {
336
359
  mutable();
337
- await mkdir(await where(params.uri), { recursive: true });
360
+ await mkdir(await where(params.uri, true), { recursive: true });
338
361
  return {};
339
362
  },
340
363
  resourceMove: async (params) => {
341
- const { source, destination } = params;
364
+ const { source, destination, failIfExists } = params;
342
365
  mutable();
343
- await rename(await where(source, true), await where(destination, true));
366
+ const from = await where(source, true);
367
+ const to = await pair(destination, failIfExists === true);
368
+ await rename(from, to);
344
369
  return {};
345
370
  },
346
371
  resourceCopy: async (params) => {
347
- const { source, destination } = params;
372
+ const { source, destination, failIfExists } = params;
348
373
  mutable();
349
374
  const from = await where(source);
350
- const to = await where(destination);
375
+ const to = await pair(destination, failIfExists === true);
351
376
  const input = await open(from, constants.O_RDONLY | constants.O_NOFOLLOW);
352
377
  try {
353
378
  const output = await open(to, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW);
@@ -1,5 +1,5 @@
1
1
  /** Every command, and the argv reading that picks one. */
2
- export declare const HELP = "ahpc - drive an agent host from a shell\n\n ahpc [--host ws://\u2026] <command> [args] the screen is 'ahpc' with no command\n\nSessions\n session list the catalogue, newest first [--archived] [--json]\n session show <uri> what the host says about one [--full] [--json]\n session new start one [--agent P] [--cwd DIR] [--set k=v]\u2026 [--json]\n session rm <uri> dispose it\n session history <uri> its turns [--all] [--full] [--json]\n session config <uri> the schema and what is in force [--json]\n session set <uri> <k> <v> change one config key\n session read <uri> mark read [--unread]\n session archive <uri> put it away [--undo]\n session customizations <uri> skills, prompts, agents, servers [--json]\n session export <uri> the whole session as one document\n [--json] [--markdown]\n session toggle <uri> <id> turn one on [--off]\n\nTurns\n prompt <uri> <text> say it and stream the answer [--model M] [--json]\n exec <text> a session, one turn, and dispose it\n [--agent P] [--cwd DIR] [--model M] [--json]\n cancel <uri> stop the running turn\n queue <uri> <text> say it after the one running [--model M]\n unqueue <uri> <id> take it back\n\nAnswering\n watch <uri> BLOCK until something wants a person, print, exit\n [--until turn|input|idle] [--timeout S] [--json]\n confirm <uri> <toolCallId> approve a tool call [--deny] [--option ID]\n answer <uri> <requestId> answer a question [--field k=v]\u2026 [--reject]\n\nChats\n chat list <uri> the conversations in a session [--json]\n chat new <uri> [text] another one beside it\n chat rm <chatUri> close one\n\nThe harness\n agents what it serves, and each one's models [--json]\n models every model, by harness [--json]\n commands what a slash offers [--json]\n customizations skills, prompts, agents and MCP servers,\n before any session exists [--kind k] [--json]\n completions <uri> <text> what the host would complete [--offset N] [--json]\n\nChanges and files\n changes <uri> the files a session touched [--json]\n [--list] [--scope s] [--<variable> v]\n [--reviewed f] [--unreviewed f]\n [--operations] what may be done to it\n [--run id] [--file f] [--yes] do one of them\n [--list] every changeset it offers\n [--scope <name>] one of them, e.g. turn\n [--turnId <id>] what a chosen scope still needs\n [--reviewed <file>] tick one off, repeatable\n [--unreviewed <file>] and clear one\n content <uri> <file> one of them, in full\n resource list <uri> a directory the host serves [--json]\n resource read <uri> a file on the host\n resource stat <uri> what it is, without reading it [--json]\n resource write <uri> [file] from a file, or from stdin [--create-only]\n guarded by the file's etag unless [--force]\n resource rm <uri> delete it [--recursive]\n resource mkdir <uri> make a directory\n resource mv <uri> <to> move it [--fail-if-exists]\n resource cp <uri> <to> copy it [--fail-if-exists]\n\nAutomations\n automation list what runs on its own [--json]\n automation show <uri> one of them [--json]\n automation triggers what this host can trigger on [--json]\n automation runs <uri> its history, every page [--json]\n automation run <uri> start it now\n automation enable <uri> switch it on\n automation disable <uri> switch it off\n automation rm <uri> forget it\n\nThe host's own log\n logs what the daemon is saying [--level L] [--follow]\n\nSigning in\n auth what this host protects [--json]\n auth <resource> push a token [--token T] [--expires-in S]\n or set AHPC_TOKEN_<RESOURCE>, or pipe one in\n\nTerminals\n terminal list what is running [--json]\n terminal new open a shell [--cwd DIR] [--name N]\n terminal rm <uri> kill it\n terminal send <uri> <text> type into it\n terminal watch <uri> follow its output [--timeout S]\n\nRecording\n AHPC_RECORD=<file> append every frame, both directions, for\n 'npm run wire' to check against the protocol\n\nAnything else\n dispatch <uri> <type> send one action verbatim [--field k=v]\u2026 [--chat]\n status what this client is connected to [--json]\n help this\n\nThe host\n --host <url> ws://host:port, or AHPC_HOST, or the config file\n --token <tkn> a bearer token for it, or AHPC_TOKEN, or the config file\n --config-file read this instead of the one below\n (none) the scripted host, which needs nothing installed\n\nConfiguration\n config where the file is, and what is in force [--json]\n\nOutput is for reading. --json is the same answer for a program.\n";
2
+ export declare const HELP = "ahpc - drive an agent host from a shell\n\n ahpc [--host ws://\u2026] <command> [args] the screen is 'ahpc' with no command\n\nSessions\n session list the catalogue, newest first [--archived] [--json]\n session show <uri> what the host says about one [--full] [--json]\n session new start one [--agent P] [--cwd DIR] [--set k=v]\u2026 [--json]\n session rm <uri> dispose it\n session history <uri> its turns [--all] [--full] [--json]\n session config <uri> the schema and what is in force [--json]\n session set <uri> <k> <v> change one config key\n session read <uri> mark read [--unread]\n session archive <uri> put it away [--undo]\n session customizations <uri> skills, prompts, agents, servers [--json]\n session export <uri> the whole session as one document\n [--json] [--markdown]\n session toggle <uri> <id> turn one on [--off]\n\nTurns\n prompt <uri> <text> say it and stream the answer [--model M] [--json]\n exec <text> a session, one turn, and dispose it\n [--agent P] [--cwd DIR] [--model M] [--json]\n cancel <uri> stop the running turn\n queue <uri> <text> say it after the one running [--model M]\n unqueue <uri> <id> take it back\n\nAnswering\n watch <uri> BLOCK until something wants a person, print, exit\n [--until turn|input|idle] [--timeout S] [--json]\n confirm <uri> <toolCallId> approve a tool call [--deny] [--option ID]\n answer <uri> <requestId> answer a question [--field k=v]\u2026 [--reject]\n\nChats\n chat list <uri> the conversations in a session [--json]\n chat new <uri> [text] another one beside it\n chat rm <chatUri> close one\n\nThe harness\n agents what it serves, and each one's models [--json]\n models every model, by harness [--json]\n commands what a slash offers [--json]\n customizations skills, prompts, agents and MCP servers,\n before any session exists [--kind k] [--json]\n completions <uri> <text> what the host would complete [--offset N] [--json]\n\nChanges and files\n changes <uri> the files a session touched [--json]\n [--list] [--scope s] [--<variable> v]\n [--reviewed f] [--unreviewed f]\n [--operations] what may be done to it\n [--run id] [--file f] [--yes] do one of them\n [--list] every changeset it offers\n [--scope <name>] one of them, e.g. turn\n [--turnId <id>] what a chosen scope still needs\n [--reviewed <file>] tick one off, repeatable\n [--unreviewed <file>] and clear one\n content <uri> <file> one of them, in full\n resource list <uri> a directory the host serves [--json]\n resource read <uri> a file on the host\n resource stat <uri> what it is, without reading it [--json]\n resource write <uri> [file] from a file, or from stdin [--create-only]\n guarded by the file's etag unless [--force]\n resource rm <uri> delete it [--recursive]\n resource mkdir <uri> make a directory\n resource mv <uri> <to> move it [--fail-if-exists]\n resource cp <uri> <to> copy it [--fail-if-exists]\n\nAutomations\n automation list what runs on its own [--json]\n automation show <uri> one of them [--json]\n automation triggers what this host can trigger on [--json]\n automation runs <uri> its history, every page [--json]\n automation run <uri> start it now\n automation enable <uri> switch it on\n automation disable <uri> switch it off\n automation rm <uri> forget it\n\nThe host's own log\n logs what the daemon is saying [--level L] [--follow]\n\nSigning in\n auth what this host protects [--json]\n auth <resource> push a token [--token T] [--expires-in S]\n or set AHPC_TOKEN_<RESOURCE>, or pipe one in\n\nTerminals\n terminal list what is running [--json]\n terminal new open a shell [--cwd DIR] [--name N]\n terminal rm <uri> kill it\n terminal send <uri> <text> type into it\n terminal watch <uri> follow its output [--timeout S]\n\nRecording\n AHPC_RECORD=<file> append every frame, both directions, for\n 'npm run wire' to check against the protocol\n\nServing these sessions to something else\n mcp MCP on stdin and stdout, for a client that\n launches this process\n serve the same tools on a socket, shared\n [--serve-host H] [--serve-port N] [--serve-token T]\n [--serve-origin URL]\u2026 a browser page allowed in\n /mcp is MCP; /api/<tool> is plain JSON\n both take [--mcp-tools G,\u2026] extra tool groups to serve, on top of the\n sessions ones: resources, terminals,\n automations, changes\n\nAnything else\n dispatch <uri> <type> send one action verbatim [--field k=v]\u2026 [--chat]\n status what this client is connected to [--json]\n --version what version this is\n help this\n\nThe host\n --host <url> ws://host:port, or AHPC_HOST, or the config file\n --token <tkn> a bearer token for it, or AHPC_TOKEN, or the config file\n --config-file read this instead of the one below\n (none) the scripted host, which needs nothing installed\n\nConfiguration\n config where the file is, and what is in force [--json]\n\nOutput is for reading. --json is the same answer for a program.\n";
3
3
  /** A message for the person, not a stack trace. */
4
4
  export declare class Fault extends Error {
5
5
  }
@@ -5,6 +5,11 @@ import { configPath, loadConfig } from '../config.js';
5
5
  import { ago, archived, branch, json, line, mark, project, table } from './render.js';
6
6
  import { operate } from '../ahp/operate.js';
7
7
  import { SWITCHES } from '../flags.js';
8
+ import { spoken, turn as runTurn, until } from '../wait.js';
9
+ import { GROUPS, served } from '../mcp/tools.js';
10
+ import { SERVER } from '../mcp/serve.js';
11
+ import { stdio } from '../mcp/stdio.js';
12
+ import { serve as serveHttp } from '../mcp/http.js';
8
13
  export const HELP = `ahpc - drive an agent host from a shell
9
14
 
10
15
  ahpc [--host ws://…] <command> [args] the screen is 'ahpc' with no command
@@ -102,9 +107,21 @@ Recording
102
107
  AHPC_RECORD=<file> append every frame, both directions, for
103
108
  'npm run wire' to check against the protocol
104
109
 
110
+ Serving these sessions to something else
111
+ mcp MCP on stdin and stdout, for a client that
112
+ launches this process
113
+ serve the same tools on a socket, shared
114
+ [--serve-host H] [--serve-port N] [--serve-token T]
115
+ [--serve-origin URL]… a browser page allowed in
116
+ /mcp is MCP; /api/<tool> is plain JSON
117
+ both take [--mcp-tools G,…] extra tool groups to serve, on top of the
118
+ sessions ones: resources, terminals,
119
+ automations, changes
120
+
105
121
  Anything else
106
122
  dispatch <uri> <type> send one action verbatim [--field k=v]… [--chat]
107
123
  status what this client is connected to [--json]
124
+ --version what version this is
108
125
  help this
109
126
 
110
127
  The host
@@ -221,50 +238,6 @@ const needs = (args, index, what) => {
221
238
  throw new Fault(`This wants ${what}.`);
222
239
  return found;
223
240
  };
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
241
  /**
269
242
  * Ask again once the catalogue moves, for an answer that starts out empty.
270
243
  *
@@ -305,11 +278,6 @@ const snapshot = async (host, uri) => {
305
278
  const event = await until(host, uri, (e) => e.type === 'snapshot', { timeoutSeconds: 30 });
306
279
  return event?.type === 'snapshot' ? event : undefined;
307
280
  };
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
281
  /**
314
282
  * One command, and then the process is done.
315
283
  *
@@ -359,6 +327,50 @@ export async function cli(command, rest) {
359
327
  line(`${rows.length} session(s)`);
360
328
  break;
361
329
  }
330
+ /*
331
+ * The tool server, on whichever transport was asked for.
332
+ *
333
+ * Both serve the same table from `mcp/tools.ts`. `mcp` is stdio and is
334
+ * owned by the client that launched this process; `serve` is a socket
335
+ * several callers share, speaking MCP at `/mcp` and a plain JSON API at
336
+ * `/api/<tool>` for anything that is not an MCP client.
337
+ */
338
+ case 'mcp': {
339
+ const groups = wanted(args);
340
+ // Nothing but JSON-RPC on stdout, ever: a stray line here is a parse
341
+ // error at the other end of a pipe nobody can see.
342
+ process.stderr.write(`ahpc mcp on ${host.url || '(scripted host)'}, ${served(groups).length} tools\n`);
343
+ await stdio(host, { ...SERVER, groups, onProblem: (said) => process.stderr.write(`${said}\n`) });
344
+ return 0;
345
+ }
346
+ case 'serve': {
347
+ const groups = wanted(args);
348
+ const at = await serveHttp(host, {
349
+ ...SERVER,
350
+ groups,
351
+ host: args.value('--serve-host') ?? '127.0.0.1',
352
+ port: Number(args.value('--serve-port') ?? 7431),
353
+ ...(args.value('--serve-token') === undefined ? {} : { token: args.value('--serve-token') }),
354
+ // Repeatable, because a page and its API are often two origins.
355
+ origins: args.every('--serve-origin'),
356
+ onProblem: (said) => process.stderr.write(`${said}\n`),
357
+ });
358
+ line(`ahpc on http://${at.host}:${at.port} against ${host.url || '(scripted host)'}`);
359
+ line(` /mcp MCP, ${served(groups).length} tools`);
360
+ line(' /api/<tool> the same tools as plain JSON');
361
+ if (args.value('--serve-token') === undefined && at.host !== '127.0.0.1' && at.host !== '::1') {
362
+ // Said rather than refused: binding wide open is a decision somebody
363
+ // may have made on purpose behind something else.
364
+ line(' no token: anyone who can reach this port can drive every session on the host');
365
+ }
366
+ // Until it is stopped. There is no work left to return to.
367
+ await new Promise((forever) => {
368
+ const stop = () => { void at.close().then(() => forever()); };
369
+ process.on('SIGINT', stop);
370
+ process.on('SIGTERM', stop);
371
+ });
372
+ return 0;
373
+ }
362
374
  case 'session': return await sessions(host, args, wants);
363
375
  case 'chat': return await chats(host, args, wants);
364
376
  case 'terminal': return await shells(host, args, wants);
@@ -995,6 +1007,26 @@ async function chats(host, args, wants) {
995
1007
  }
996
1008
  throw new Fault(`No 'chat ${verb}'. Try 'ahpc help'.`);
997
1009
  }
1010
+ /**
1011
+ * The tool groups `--mcp-tools` asked for.
1012
+ *
1013
+ * Comma-separated and repeatable, because both are what people type. Named
1014
+ * `--mcp-tools` and not `--tools`: every other flag on this client is an AHP
1015
+ * thing, and a bare `--tools` reads like one - it would look like it was
1016
+ * choosing which tools the *agent* may call, which is a different question
1017
+ * with a different answer.
1018
+ */
1019
+ function wanted(args) {
1020
+ const said = args.every('--mcp-tools')
1021
+ .flatMap((one) => one.split(','))
1022
+ .map((one) => one.trim())
1023
+ .filter((one) => one !== '');
1024
+ const strange = said.filter((one) => !GROUPS.includes(one));
1025
+ if (strange.length > 0) {
1026
+ throw new Fault(`No tool group called ${strange.join(', ')}. There is ${GROUPS.join(', ')}.`);
1027
+ }
1028
+ return said;
1029
+ }
998
1030
  /** Everything under `terminal`. */
999
1031
  async function shells(host, args, wants) {
1000
1032
  const verb = args.positional(0) ?? 'list';
@@ -1343,56 +1375,26 @@ async function turns(host, command, args, wants) {
1343
1375
  * second answer to the same question. So "what is new" is the part of the
1344
1376
  * running turn not yet printed, which is a length rather than an event.
1345
1377
  */
1378
+ /*
1379
+ * Say something and wait for the answer, printing it as it arrives.
1380
+ *
1381
+ * The waiting is `wait.ts`, which the tool server uses too; what is here is
1382
+ * the part that is about a terminal. `--json` prints the finished turn
1383
+ * instead, so the stream is suppressed rather than interleaved with it.
1384
+ */
1346
1385
  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)
1386
+ let printed = false;
1387
+ const answer = await runTurn(host, uri, text, {
1388
+ model: selected(args),
1389
+ timeoutSeconds: Number(args.value('--timeout') ?? 900),
1390
+ ...(wants ? {} : {
1391
+ onDelta: (part) => { printed = true; process.stdout.write(part); },
1392
+ // On stderr, so a pipe still gets only the answer while a person
1393
+ // watching sees why it stopped.
1394
+ onWaiting: (call) => process.stderr.write(` \u00b7 waiting on ${call.name} ${call.id}\n`),
1395
+ }),
1396
+ });
1397
+ if (!wants && printed)
1396
1398
  line();
1397
1399
  return answer;
1398
1400
  };
package/dist/src/flags.js CHANGED
@@ -27,6 +27,7 @@ export const COMMANDS = new Set([
27
27
  'agents', 'models', 'commands', 'customizations', 'completions', 'changes', 'content',
28
28
  'prompt', 'exec', 'cancel', 'queue', 'unqueue',
29
29
  'watch', 'confirm', 'answer', 'dispatch',
30
+ 'mcp', 'serve',
30
31
  ]);
31
32
  /**
32
33
  * Every flag that takes no value, in either front end.
@@ -40,6 +41,9 @@ export const COMMANDS = new Set([
40
41
  export const SWITCHES = new Set([
41
42
  // The screen's own.
42
43
  '--static', '-s', '--settled', '--approve', '--answer', '--bood', '--help', '-h',
44
+ // Answered by the entry point before either front end is loaded, and here
45
+ // so that neither swallows the word after it.
46
+ '--version', '-v',
43
47
  // Shape and scope, on both sides.
44
48
  '--json', '--full', '--all', '--archived', '--unread', '--undo',
45
49
  '--off', '--deny', '--reject', '--claude', '--chat',
@@ -52,6 +56,9 @@ export const SWITCHES = new Set([
52
56
  '--operations', '--markdown',
53
57
  // Asking rather than doing, and agreeing in advance.
54
58
  '--list', '--yes',
59
+ // Which transport the tool server speaks. Neither takes a value, and the
60
+ // ports it listens on are `--serve-host` and `--serve-port`, which do.
61
+ '--stdio', '--http',
55
62
  ]);
56
63
  /**
57
64
  * The command, wherever it is.
package/dist/src/main.js CHANGED
@@ -12,8 +12,20 @@
12
12
  // does not load a front end, which is the reason they were copied here.
13
13
  import { commandIn } from './flags.js';
14
14
  const argv = process.argv.slice(2);
15
- const first = commandIn(argv);
16
- if (first !== undefined) {
15
+ /*
16
+ * Answered before anything is loaded.
17
+ *
18
+ * `--version` with no command would otherwise open the screen, which is a
19
+ * question answered by a whole renderer starting up and then being read off a
20
+ * status bar. Neither front end is imported to answer it.
21
+ */
22
+ const asked = argv.includes('--version') || argv.includes('-v');
23
+ const first = asked ? undefined : commandIn(argv);
24
+ if (asked) {
25
+ const { version } = await import('./version.js');
26
+ process.stdout.write(`${version()}\n`);
27
+ }
28
+ else if (first !== undefined) {
17
29
  /*
18
30
  * A closed pipe is not an error.
19
31
  *