@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 +69 -1
- package/dist/src/ahp/channels.js +10 -3
- package/dist/src/ahp/live.js +63 -6
- package/dist/src/ahp/publish.js +31 -6
- package/dist/src/cli/main.d.ts +1 -1
- package/dist/src/cli/main.js +100 -98
- package/dist/src/flags.js +7 -0
- package/dist/src/main.js +14 -2
- package/dist/src/mcp/http.d.ts +34 -0
- package/dist/src/mcp/http.js +247 -0
- package/dist/src/mcp/serve.d.ts +96 -0
- package/dist/src/mcp/serve.js +169 -0
- package/dist/src/mcp/stdio.d.ts +14 -0
- package/dist/src/mcp/stdio.js +64 -0
- package/dist/src/mcp/tools.d.ts +68 -0
- package/dist/src/mcp/tools.js +765 -0
- package/dist/src/tui.d.ts +1 -1
- package/dist/src/tui.js +1 -0
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.js +38 -0
- package/dist/src/wait.d.ts +56 -0
- package/dist/src/wait.js +160 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# ahpc
|
|
2
2
|
|
|
3
|
+
[](https://github.com/softov/ahpc/actions/workflows/ci.yml)
|
|
3
4
|
[](https://www.npmjs.com/package/@softov/ahpc)
|
|
4
5
|

|
|
5
6
|

|
|
@@ -7,7 +8,7 @@
|
|
|
7
8
|

|
|
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)
|
|
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.
|
package/dist/src/ahp/channels.js
CHANGED
|
@@ -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
|
-
|
|
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
|
},
|
package/dist/src/ahp/live.js
CHANGED
|
@@ -427,11 +427,12 @@ export function activityOf(status, asked, running, failed) {
|
|
|
427
427
|
*/
|
|
428
428
|
function transcript(chat) {
|
|
429
429
|
const out = [];
|
|
430
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
446
|
+
rows.push(agent);
|
|
447
|
+
return rows;
|
|
446
448
|
};
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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',
|
package/dist/src/ahp/publish.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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);
|
package/dist/src/cli/main.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/src/cli/main.js
CHANGED
|
@@ -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 =
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
*
|