@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 CHANGED
@@ -7,7 +7,7 @@
7
7
  ![built with TextUI](https://img.shields.io/badge/built%20with-TextUI-7048e8)
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) or tui (interactive chat).
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.
@@ -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
  },
@@ -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 /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
  }
@@ -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 = 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)
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
+ }