@softov/ahpc 0.1.0 → 0.2.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 +40 -1
- package/dist/src/ahp/channels.js +10 -3
- package/dist/src/ahp/publish.js +31 -6
- package/dist/src/cli/main.d.ts +1 -1
- package/dist/src/cli/main.js +70 -98
- package/dist/src/flags.js +4 -0
- package/dist/src/mcp/http.d.ts +24 -0
- package/dist/src/mcp/http.js +140 -0
- package/dist/src/mcp/serve.d.ts +58 -0
- package/dist/src/mcp/serve.js +118 -0
- package/dist/src/mcp/stdio.d.ts +13 -0
- package/dist/src/mcp/stdio.js +58 -0
- package/dist/src/mcp/tools.d.ts +33 -0
- package/dist/src/mcp/tools.js +328 -0
- package/dist/src/wait.d.ts +45 -0
- package/dist/src/wait.js +147 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|

|
|
8
8
|
|
|
9
9
|
A terminal client for the [Agent Host Protocol](https://microsoft.github.io/agent-host-protocol/).
|
|
10
|
-
It can be used as cli (commands)
|
|
10
|
+
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
11
|
|
|
12
12
|
Connect to an AHP host, manage sessions, and work with agents directly from your terminal.
|
|
13
13
|
|
|
@@ -55,6 +55,7 @@ ahpc session list --host ws://127.0.0.1:9187
|
|
|
55
55
|
| Automations | Scheduled and triggered runs, with their history. |
|
|
56
56
|
| Customizations | Skills, prompts, agents and MCP servers, and which are enabled. |
|
|
57
57
|
| Telemetry | Stream the host's log. |
|
|
58
|
+
| Tool server | Serve those sessions to an agent somewhere else, over MCP or a plain JSON API. |
|
|
58
59
|
|
|
59
60
|
## Interactive
|
|
60
61
|
|
|
@@ -209,6 +210,13 @@ Writes are guarded by the file's etag unless you pass `--force`, so two clients
|
|
|
209
210
|
| `automation enable <uri>` / `disable <uri>` | Enable or disable it | |
|
|
210
211
|
| `automation rm <uri>` | Delete it | |
|
|
211
212
|
|
|
213
|
+
### Serving these sessions to something else
|
|
214
|
+
|
|
215
|
+
| Command | | |
|
|
216
|
+
|---|---|---|
|
|
217
|
+
| `mcp` | MCP on stdin and stdout, for a client that launches this process | |
|
|
218
|
+
| `serve` | The same tools on a socket, shared | `--serve-host H` `--serve-port N` `--serve-token T` |
|
|
219
|
+
|
|
212
220
|
### Anything else
|
|
213
221
|
|
|
214
222
|
| Command | | |
|
|
@@ -217,6 +225,37 @@ Writes are guarded by the file's etag unless you pass `--force`, so two clients
|
|
|
217
225
|
| `config` | Show the config file path and current values | `--json` |
|
|
218
226
|
| `help` | Print this command list | |
|
|
219
227
|
|
|
228
|
+
## As a tool server
|
|
229
|
+
|
|
230
|
+
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.
|
|
231
|
+
|
|
232
|
+
`ahpc mcp` speaks MCP on stdin and stdout, which is what an MCP client that launches the process expects:
|
|
233
|
+
|
|
234
|
+
```json
|
|
235
|
+
{
|
|
236
|
+
"mcpServers": {
|
|
237
|
+
"ahp": { "command": "ahpc", "args": ["--host", "ws://127.0.0.1:9187", "mcp"] }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`ahpc serve` is the same twelve tools on a socket that several callers share, and it stays up until it is stopped:
|
|
243
|
+
|
|
244
|
+
```sh
|
|
245
|
+
ahpc --host ws://127.0.0.1:9187 serve --serve-port 7431
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`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.
|
|
249
|
+
|
|
250
|
+
```sh
|
|
251
|
+
curl -XPOST localhost:7431/api/new_session -d '{"workingDirectory":"/work"}'
|
|
252
|
+
curl -XPOST localhost:7431/api/send_turn -d '{"session":"claude:/…","text":"what is in this directory"}'
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`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.
|
|
256
|
+
|
|
257
|
+
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.
|
|
258
|
+
|
|
220
259
|
## AHP support
|
|
221
260
|
|
|
222
261
|
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/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 /mcp is MCP; /api/<tool> is plain JSON\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";
|
|
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 { TOOLS } 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,6 +107,13 @@ 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
|
+
/mcp is MCP; /api/<tool> is plain JSON
|
|
116
|
+
|
|
105
117
|
Anything else
|
|
106
118
|
dispatch <uri> <type> send one action verbatim [--field k=v]… [--chat]
|
|
107
119
|
status what this client is connected to [--json]
|
|
@@ -221,50 +233,6 @@ const needs = (args, index, what) => {
|
|
|
221
233
|
throw new Fault(`This wants ${what}.`);
|
|
222
234
|
return found;
|
|
223
235
|
};
|
|
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
236
|
/**
|
|
269
237
|
* Ask again once the catalogue moves, for an answer that starts out empty.
|
|
270
238
|
*
|
|
@@ -305,11 +273,6 @@ const snapshot = async (host, uri) => {
|
|
|
305
273
|
const event = await until(host, uri, (e) => e.type === 'snapshot', { timeoutSeconds: 30 });
|
|
306
274
|
return event?.type === 'snapshot' ? event : undefined;
|
|
307
275
|
};
|
|
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
276
|
/**
|
|
314
277
|
* One command, and then the process is done.
|
|
315
278
|
*
|
|
@@ -359,6 +322,45 @@ export async function cli(command, rest) {
|
|
|
359
322
|
line(`${rows.length} session(s)`);
|
|
360
323
|
break;
|
|
361
324
|
}
|
|
325
|
+
/*
|
|
326
|
+
* The tool server, on whichever transport was asked for.
|
|
327
|
+
*
|
|
328
|
+
* Both serve the same table from `mcp/tools.ts`. `mcp` is stdio and is
|
|
329
|
+
* owned by the client that launched this process; `serve` is a socket
|
|
330
|
+
* several callers share, speaking MCP at `/mcp` and a plain JSON API at
|
|
331
|
+
* `/api/<tool>` for anything that is not an MCP client.
|
|
332
|
+
*/
|
|
333
|
+
case 'mcp': {
|
|
334
|
+
// Nothing but JSON-RPC on stdout, ever: a stray line here is a parse
|
|
335
|
+
// error at the other end of a pipe nobody can see.
|
|
336
|
+
process.stderr.write(`ahpc mcp on ${host.url || '(scripted host)'}, ${TOOLS.length} tools\n`);
|
|
337
|
+
await stdio(host, { ...SERVER, onProblem: (said) => process.stderr.write(`${said}\n`) });
|
|
338
|
+
return 0;
|
|
339
|
+
}
|
|
340
|
+
case 'serve': {
|
|
341
|
+
const at = await serveHttp(host, {
|
|
342
|
+
...SERVER,
|
|
343
|
+
host: args.value('--serve-host') ?? '127.0.0.1',
|
|
344
|
+
port: Number(args.value('--serve-port') ?? 7431),
|
|
345
|
+
...(args.value('--serve-token') === undefined ? {} : { token: args.value('--serve-token') }),
|
|
346
|
+
onProblem: (said) => process.stderr.write(`${said}\n`),
|
|
347
|
+
});
|
|
348
|
+
line(`ahpc on http://${at.host}:${at.port} against ${host.url || '(scripted host)'}`);
|
|
349
|
+
line(` /mcp MCP, ${TOOLS.length} tools`);
|
|
350
|
+
line(' /api/<tool> the same tools as plain JSON');
|
|
351
|
+
if (args.value('--serve-token') === undefined && at.host !== '127.0.0.1' && at.host !== '::1') {
|
|
352
|
+
// Said rather than refused: binding wide open is a decision somebody
|
|
353
|
+
// may have made on purpose behind something else.
|
|
354
|
+
line(' no token: anyone who can reach this port can drive every session on the host');
|
|
355
|
+
}
|
|
356
|
+
// Until it is stopped. There is no work left to return to.
|
|
357
|
+
await new Promise((forever) => {
|
|
358
|
+
const stop = () => { void at.close().then(() => forever()); };
|
|
359
|
+
process.on('SIGINT', stop);
|
|
360
|
+
process.on('SIGTERM', stop);
|
|
361
|
+
});
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
362
364
|
case 'session': return await sessions(host, args, wants);
|
|
363
365
|
case 'chat': return await chats(host, args, wants);
|
|
364
366
|
case 'terminal': return await shells(host, args, wants);
|
|
@@ -1343,56 +1345,26 @@ async function turns(host, command, args, wants) {
|
|
|
1343
1345
|
* second answer to the same question. So "what is new" is the part of the
|
|
1344
1346
|
* running turn not yet printed, which is a length rather than an event.
|
|
1345
1347
|
*/
|
|
1348
|
+
/*
|
|
1349
|
+
* Say something and wait for the answer, printing it as it arrives.
|
|
1350
|
+
*
|
|
1351
|
+
* The waiting is `wait.ts`, which the tool server uses too; what is here is
|
|
1352
|
+
* the part that is about a terminal. `--json` prints the finished turn
|
|
1353
|
+
* instead, so the stream is suppressed rather than interleaved with it.
|
|
1354
|
+
*/
|
|
1346
1355
|
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)
|
|
1356
|
+
let printed = false;
|
|
1357
|
+
const answer = await runTurn(host, uri, text, {
|
|
1358
|
+
model: selected(args),
|
|
1359
|
+
timeoutSeconds: Number(args.value('--timeout') ?? 900),
|
|
1360
|
+
...(wants ? {} : {
|
|
1361
|
+
onDelta: (part) => { printed = true; process.stdout.write(part); },
|
|
1362
|
+
// On stderr, so a pipe still gets only the answer while a person
|
|
1363
|
+
// watching sees why it stopped.
|
|
1364
|
+
onWaiting: (call) => process.stderr.write(` \u00b7 waiting on ${call.name} ${call.id}\n`),
|
|
1365
|
+
}),
|
|
1366
|
+
});
|
|
1367
|
+
if (!wants && printed)
|
|
1396
1368
|
line();
|
|
1397
1369
|
return answer;
|
|
1398
1370
|
};
|
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.
|
|
@@ -52,6 +53,9 @@ export const SWITCHES = new Set([
|
|
|
52
53
|
'--operations', '--markdown',
|
|
53
54
|
// Asking rather than doing, and agreeing in advance.
|
|
54
55
|
'--list', '--yes',
|
|
56
|
+
// Which transport the tool server speaks. Neither takes a value, and the
|
|
57
|
+
// ports it listens on are `--serve-host` and `--serve-port`, which do.
|
|
58
|
+
'--stdio', '--http',
|
|
55
59
|
]);
|
|
56
60
|
/**
|
|
57
61
|
* The command, wherever it is.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { HostConnection } from '../ahp/connection.js';
|
|
2
|
+
export interface ServeOptions {
|
|
3
|
+
host: string;
|
|
4
|
+
port: number;
|
|
5
|
+
name: string;
|
|
6
|
+
version: string;
|
|
7
|
+
/**
|
|
8
|
+
* A bearer token every request must carry, if any.
|
|
9
|
+
*
|
|
10
|
+
* Absent means anybody who can reach the port can drive every session on the
|
|
11
|
+
* host, which is why `--serve-host` defaults to the loopback address. A
|
|
12
|
+
* token is what makes binding anywhere else defensible.
|
|
13
|
+
*/
|
|
14
|
+
token?: string;
|
|
15
|
+
onProblem?(said: string): void;
|
|
16
|
+
}
|
|
17
|
+
/** What this is listening on, and how to stop it. */
|
|
18
|
+
export interface Serving {
|
|
19
|
+
host: string;
|
|
20
|
+
port: number;
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/** Start listening. Answers once the socket is up. */
|
|
24
|
+
export declare function serve(host: HostConnection, options: ServeOptions): Promise<Serving>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The same tools over a socket, twice.
|
|
3
|
+
*
|
|
4
|
+
* `POST /mcp` is MCP's streamable HTTP transport, which for a tools-only
|
|
5
|
+
* server is a JSON-RPC request in and a JSON-RPC response out. `POST
|
|
6
|
+
* /api/<tool>` is the same tool with the arguments as the body and the answer
|
|
7
|
+
* as the body, for everything that is not an MCP client - a shell script, a
|
|
8
|
+
* webhook, a program in another language. `GET /api` lists what there is.
|
|
9
|
+
*
|
|
10
|
+
* One process, one connection to the host, however many callers. That is the
|
|
11
|
+
* difference from `stdio`, and the reason both exist: stdio is owned by the
|
|
12
|
+
* client that launched it, this is shared and outlives any of them.
|
|
13
|
+
*/
|
|
14
|
+
import { createServer } from 'node:http';
|
|
15
|
+
import { answer, call, listing } from './serve.js';
|
|
16
|
+
/** How much of a request body is read before it is refused, in bytes. */
|
|
17
|
+
const LIMIT = 1_000_000;
|
|
18
|
+
const body = async (request) => {
|
|
19
|
+
let read = '';
|
|
20
|
+
for await (const chunk of request) {
|
|
21
|
+
read += String(chunk);
|
|
22
|
+
if (read.length > LIMIT)
|
|
23
|
+
throw new Error('That request is too big.');
|
|
24
|
+
}
|
|
25
|
+
return read;
|
|
26
|
+
};
|
|
27
|
+
const send = (response, code, value) => {
|
|
28
|
+
const text = JSON.stringify(value);
|
|
29
|
+
response.writeHead(code, {
|
|
30
|
+
'content-type': 'application/json',
|
|
31
|
+
'content-length': String(Buffer.byteLength(text)),
|
|
32
|
+
});
|
|
33
|
+
response.end(text);
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Whether a request carries the token, where one was set.
|
|
37
|
+
*
|
|
38
|
+
* `Authorization: Bearer <token>`, which is what MCP's own HTTP transport
|
|
39
|
+
* says, and what every client that speaks it already sends.
|
|
40
|
+
*/
|
|
41
|
+
const allowed = (request, token) => {
|
|
42
|
+
if (token === undefined)
|
|
43
|
+
return true;
|
|
44
|
+
const said = request.headers.authorization;
|
|
45
|
+
return typeof said === 'string' && said.trim() === `Bearer ${token}`;
|
|
46
|
+
};
|
|
47
|
+
/** Start listening. Answers once the socket is up. */
|
|
48
|
+
export async function serve(host, options) {
|
|
49
|
+
const server = createServer((request, response) => {
|
|
50
|
+
void (async () => {
|
|
51
|
+
try {
|
|
52
|
+
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
|
|
53
|
+
const path = url.pathname.replace(/\/+$/, '') || '/';
|
|
54
|
+
if (!allowed(request, options.token)) {
|
|
55
|
+
send(response, 401, { error: 'This server needs a bearer token.' });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// What is here, for somebody who has just started it and wants to know
|
|
59
|
+
// what to call. Every tool with its schema, which is also what an MCP
|
|
60
|
+
// client gets from `tools/list`.
|
|
61
|
+
if (request.method === 'GET' && (path === '/api' || path === '/')) {
|
|
62
|
+
send(response, 200, listing());
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (path === '/mcp') {
|
|
66
|
+
if (request.method !== 'POST') {
|
|
67
|
+
// No SSE stream: this server sends nothing a client did not ask
|
|
68
|
+
// for, so there is nothing for a GET to hold open.
|
|
69
|
+
send(response, 405, { error: 'POST a JSON-RPC message here.' });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let message;
|
|
73
|
+
try {
|
|
74
|
+
message = JSON.parse(await body(request));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
send(response, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'That is not JSON.' } });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const reply = await answer(host, message, options);
|
|
81
|
+
// A notification is answered with 202 and no body, which is what the
|
|
82
|
+
// transport says and what a client waiting on one would hang over.
|
|
83
|
+
if (reply === undefined) {
|
|
84
|
+
response.writeHead(202);
|
|
85
|
+
response.end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
send(response, 200, reply);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (path.startsWith('/api/')) {
|
|
92
|
+
if (request.method !== 'POST') {
|
|
93
|
+
send(response, 405, { error: 'POST to call a tool.' });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const name = path.slice('/api/'.length);
|
|
97
|
+
const raw = await body(request);
|
|
98
|
+
let input = {};
|
|
99
|
+
if (raw.trim() !== '') {
|
|
100
|
+
try {
|
|
101
|
+
input = JSON.parse(raw);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
send(response, 400, { error: 'That is not JSON.' });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const result = await call(host, name, input);
|
|
109
|
+
// Shaped for a program rather than for a model: the answer itself,
|
|
110
|
+
// or the refusal as an error, without MCP's content envelope around
|
|
111
|
+
// it. A caller that wants the envelope has `/mcp`.
|
|
112
|
+
if (result.isError === true) {
|
|
113
|
+
send(response, 400, { error: result.content?.[0]?.text ?? 'That did not work.' });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
send(response, 200, result.structuredContent ?? {});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
send(response, 404, { error: `Nothing at ${path}. Try GET /api.` });
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
options.onProblem?.(error instanceof Error ? error.message : String(error));
|
|
123
|
+
if (!response.headersSent)
|
|
124
|
+
send(response, 500, { error: 'Something went wrong here.' });
|
|
125
|
+
else
|
|
126
|
+
response.end();
|
|
127
|
+
}
|
|
128
|
+
})();
|
|
129
|
+
});
|
|
130
|
+
await new Promise((up, fail) => {
|
|
131
|
+
server.once('error', fail);
|
|
132
|
+
server.listen(options.port, options.host, () => { server.off('error', fail); up(); });
|
|
133
|
+
});
|
|
134
|
+
const found = server.address();
|
|
135
|
+
return {
|
|
136
|
+
host: typeof found === 'object' && found !== null ? found.address : options.host,
|
|
137
|
+
port: typeof found === 'object' && found !== null ? found.port : options.port,
|
|
138
|
+
close: () => new Promise((done) => { server.close(() => done()); }),
|
|
139
|
+
};
|
|
140
|
+
}
|