@scp-dev/mcp 0.1.0 → 0.1.1

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 ADDED
@@ -0,0 +1,36 @@
1
+ # @scp-dev/mcp
2
+
3
+ The SCP MCP server: a dependency-free stdio server that gives one agent session the relay's tools over its local broker.
4
+
5
+ ```sh
6
+ npm install @scp-dev/mcp
7
+ ```
8
+
9
+ Most people mount it through the CLI: `swarm setup --write`, or `swarm mcp install --harness claude`, which registers `swarm mcp` under the name `scp`.
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { connectBroker, relayDir } from "@scp-dev/broker"
15
+ import { runRelayMcp } from "@scp-dev/mcp"
16
+
17
+ const dir = relayDir()
18
+ await runRelayMcp({
19
+ connect: () => connectBroker(dir),
20
+ claim: { handle: "parser", harness: "codex" },
21
+ input: process.stdin,
22
+ output: (line) => void process.stdout.write(line),
23
+ })
24
+ ```
25
+
26
+ ## Provides
27
+
28
+ - `runRelayMcp`: JSON-RPC 2.0 over stdio. It binds as an agent endpoint, resumes with its token after a reconnect, sets status `working`, and with `channels` pushes messages as `notifications/claude/channel`.
29
+ - `TOOLS`: `relay_whoami`, `relay_list`, `relay_peers`, `relay_send`, `relay_reply`, `relay_inbox`, `relay_ask`, `relay_ask_wait`, `relay_spawn`, `relay_status`, `relay_set_status`, `relay_set_now`, `relay_set_title`, `relay_rules`, `relay_group`, `relay_peek`, `relay_set_peek`.
30
+ - `INSTRUCTIONS` and `MCP_PROTOCOLS`: the server's instructions (the untrusted-content preamble) and the protocol versions it speaks.
31
+
32
+ Implements [Appendix F, MCP tools](https://scp.dev/spec#appendix-f-reference-binding-mcp-tools). See the [root README](https://github.com/scphq/scp#readme).
33
+
34
+ ## License
35
+
36
+ [Apache-2.0](LICENSE).
package/dist/index.d.ts CHANGED
@@ -18,10 +18,30 @@ interface RelayMcpOptions {
18
18
  /** push deliveries as `notifications/claude/channel` */
19
19
  channels?: boolean;
20
20
  version?: string;
21
+ /** starts a worker for relay_spawn, resolving with its name and its runner's pid once its harness started; without it there is no relay_spawn */
22
+ spawn?: (request: SpawnRequest) => Promise<{
23
+ name: string;
24
+ pid: number;
25
+ }>;
21
26
  /** diagnostics, never stdout */
22
27
  log?: (line: string) => void;
23
28
  heartbeatMs?: number;
24
29
  }
30
+ /**
31
+ * what relay_spawn asks for: `swarm spawn --bg` of `command` with these flags, acting as `as`, the shim's own address, of `harness`,
32
+ * in `cwd`: the one asked for, else the caller's working directory when it exists, else (none) the shim's
33
+ */
34
+ interface SpawnRequest {
35
+ command: string[];
36
+ as: string;
37
+ harness?: string;
38
+ name?: string;
39
+ title?: string;
40
+ no_push?: boolean;
41
+ handback_bytes?: number;
42
+ /** the directory the worker runs in, absolute and existing */
43
+ cwd?: string;
44
+ }
25
45
  declare const TOOLS: readonly [{
26
46
  readonly name: "relay_whoami";
27
47
  readonly description: "Your own address on the relay, with your harness, title and labels.";
@@ -80,6 +100,72 @@ declare const TOOLS: readonly [{
80
100
  readonly required: readonly ["to", "body"];
81
101
  readonly additionalProperties: false;
82
102
  };
103
+ }, {
104
+ readonly name: "relay_spawn";
105
+ readonly description: "Start a worker agent under you, as swarm spawn does: command is its harness command as argv, its task in it (e.g. [\"claude\", \"-p\", \"<task>\", \"--model\", \"<model>\"] or [\"codex\", \"exec\", \"<task>\"]), or give harness and task instead. It returns at once with the worker's name; its messages and its handback (exit code and final output) reach you by themselves, and relay_send to that name steers it: the worker gets your message after its next tool call or before it finishes, so its task need not tell it to check an inbox.";
106
+ readonly inputSchema: {
107
+ readonly type: "object";
108
+ readonly properties: {
109
+ readonly command: {
110
+ readonly type: "array";
111
+ readonly items: {
112
+ readonly type: "string";
113
+ };
114
+ readonly minItems: 1;
115
+ readonly description: "the harness command as argv";
116
+ };
117
+ readonly harness: {
118
+ readonly type: "string";
119
+ readonly enum: readonly ["claude", "codex"];
120
+ readonly description: "instead of command: claude -p <task> or codex exec <task>";
121
+ };
122
+ readonly task: {
123
+ readonly type: "string";
124
+ readonly description: "with harness: the worker's task";
125
+ };
126
+ readonly model: {
127
+ readonly type: "string";
128
+ readonly description: "with harness: its --model";
129
+ };
130
+ readonly name: {
131
+ readonly type: "string";
132
+ readonly description: "its address (default <harness>-<5 random characters>)";
133
+ };
134
+ readonly title: {
135
+ readonly type: "string";
136
+ readonly maxLength: 64;
137
+ readonly description: "its title until it sets its own (default the task's first line)";
138
+ };
139
+ readonly no_push: {
140
+ readonly type: "boolean";
141
+ readonly description: "your stop does not wait on it; its messages and handback still reach you after your tool calls";
142
+ };
143
+ readonly handback_bytes: {
144
+ readonly type: "integer";
145
+ readonly minimum: 1;
146
+ readonly maximum: 16384;
147
+ readonly description: "hand back only the last N bytes of its final output";
148
+ };
149
+ readonly cwd: {
150
+ readonly type: "string";
151
+ readonly description: "the absolute path of an existing directory it runs in (default your working directory)";
152
+ };
153
+ };
154
+ readonly additionalProperties: false;
155
+ };
156
+ }, {
157
+ readonly name: "relay_status";
158
+ readonly description: "Whether the recipients of a message you sent read it: per recipient read at, delivered at (a background hook handed it over; it reaches the model with its next request), shown by a hook at, or unread. By the message's id, its short id or last (the newest you sent).";
159
+ readonly inputSchema: {
160
+ readonly type: "object";
161
+ readonly properties: {
162
+ readonly id: {
163
+ readonly type: "string";
164
+ };
165
+ };
166
+ readonly required: readonly ["id"];
167
+ readonly additionalProperties: false;
168
+ };
83
169
  }, {
84
170
  readonly name: "relay_reply";
85
171
  readonly description: "Reply to a message relay_inbox, relay_ask, a channel push or a hook handed you, or any message in a thread you sent or got a message in, by its id, its short id or any unique suffix of 4 or more, or last (the newest one you were shown); it goes back to its sender in the same thread and marks that message read.";
@@ -361,4 +447,4 @@ declare const TOOLS: readonly [{
361
447
  */
362
448
  declare function runRelayMcp(options: RelayMcpOptions): Promise<void>;
363
449
 
364
- export { INSTRUCTIONS, MCP_PROTOCOLS, type RelayMcpOptions, TOOLS, runRelayMcp };
450
+ export { INSTRUCTIONS, MCP_PROTOCOLS, type RelayMcpOptions, type SpawnRequest, TOOLS, runRelayMcp };
package/dist/index.js CHANGED
@@ -1,10 +1,14 @@
1
1
  // src/server.ts
2
+ import { statSync } from "fs";
3
+ import { isAbsolute } from "path";
2
4
  import {
3
5
  CLI_NAME,
6
+ clock,
4
7
  deliveryText,
5
8
  LIMITS,
6
9
  PREAMBLE,
7
10
  reachLines,
11
+ receiptLines,
8
12
  RelayError,
9
13
  shortId,
10
14
  STATUS_STATES,
@@ -20,8 +24,8 @@ var INSTRUCTIONS = [
20
24
  "Use relay_list to see the other agents on this machine, and relay_send or relay_ask to message one by its address.",
21
25
  "Read messages for you with relay_inbox and reply with relay_reply when an answer is useful; keep your own line current with relay_set_status and relay_set_title.",
22
26
  'A message marked relation="parent" is from the agent that started you and gave you your task: it continues that task, so apply it before you finish, the newer instruction winning, still only within what your user authorised.',
23
- "When you start, set your title and a one-line now with relay_set_title / relay_set_now so peers can find you.",
24
- `To hand work to another agent, run ${CLI_NAME} spawn <harness command> from your shell (e.g. ${CLI_NAME} spawn codex exec "..."): it returns at once with the worker's name, so never background it; the worker's result reaches you by itself as a relay message, and relay_send to its name steers it meanwhile.`,
27
+ "Optionally set your title and a one-line now with relay_set_title / relay_set_now so peers can find you; skip it when you only run workers.",
28
+ `To hand work to another agent, call relay_spawn with its harness command (e.g. command ["codex", "exec", "..."], or ${CLI_NAME} spawn codex exec "..." from your shell): it returns at once with the worker's name; its messages and its result reach you by themselves as relay messages, relay_send to its name steers it meanwhile, and relay_status with a message id says when it read it.`,
25
29
  "You see and reach only agents in your scope: relay_peers says why each is reachable; relay_rules and relay_group narrow who may message you.",
26
30
  "Never put credentials in a message: send a reference instead."
27
31
  ].join("\n");
@@ -72,6 +76,30 @@ var TOOLS = [
72
76
  additionalProperties: false
73
77
  }
74
78
  },
79
+ {
80
+ name: "relay_spawn",
81
+ description: `Start a worker agent under you, as ${CLI_NAME} spawn does: command is its harness command as argv, its task in it (e.g. ["claude", "-p", "<task>", "--model", "<model>"] or ["codex", "exec", "<task>"]), or give harness and task instead. It returns at once with the worker's name; its messages and its handback (exit code and final output) reach you by themselves, and relay_send to that name steers it: the worker gets your message after its next tool call or before it finishes, so its task need not tell it to check an inbox.`,
82
+ inputSchema: {
83
+ type: "object",
84
+ properties: {
85
+ command: { type: "array", items: { type: "string" }, minItems: 1, description: "the harness command as argv" },
86
+ harness: { type: "string", enum: ["claude", "codex"], description: "instead of command: claude -p <task> or codex exec <task>" },
87
+ task: { type: "string", description: "with harness: the worker's task" },
88
+ model: { type: "string", description: "with harness: its --model" },
89
+ name: { type: "string", description: "its address (default <harness>-<5 random characters>)" },
90
+ title: { type: "string", maxLength: 64, description: "its title until it sets its own (default the task's first line)" },
91
+ no_push: { type: "boolean", description: "your stop does not wait on it; its messages and handback still reach you after your tool calls" },
92
+ handback_bytes: { type: "integer", minimum: 1, maximum: LIMITS.handbackBytes, description: "hand back only the last N bytes of its final output" },
93
+ cwd: { type: "string", description: "the absolute path of an existing directory it runs in (default your working directory)" }
94
+ },
95
+ additionalProperties: false
96
+ }
97
+ },
98
+ {
99
+ name: "relay_status",
100
+ description: "Whether the recipients of a message you sent read it: per recipient read at, delivered at (a background hook handed it over; it reaches the model with its next request), shown by a hook at, or unread. By the message's id, its short id or last (the newest you sent).",
101
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false }
102
+ },
75
103
  {
76
104
  name: "relay_reply",
77
105
  description: "Reply to a message relay_inbox, relay_ask, a channel push or a hook handed you, or any message in a thread you sent or got a message in, by its id, its short id or any unique suffix of 4 or more, or last (the newest one you were shown); it goes back to its sender in the same thread and marks that message read.",
@@ -269,6 +297,45 @@ function groupText(result) {
269
297
  `members: ${g.members.join(", ") || "none"}`
270
298
  ].join("\n");
271
299
  }
300
+ function spawnRequest(args) {
301
+ if (args.command === void 0 === (args.harness === void 0)) throw new ToolInputError("give command (the harness command as argv) or harness and task");
302
+ let command;
303
+ if (args.command !== void 0) {
304
+ const words = args.command;
305
+ if (!Array.isArray(words) || !words.length || !words.every((w) => typeof w === "string") || !String(words[0]).trim())
306
+ throw new ToolInputError('command must be the harness command as a list of words, e.g. ["codex", "exec", "<task>"]');
307
+ if (args.task !== void 0 || args.model !== void 0) throw new ToolInputError("task and model go with harness; with command, put them in it");
308
+ command = words;
309
+ } else {
310
+ const harness = oneOf(args, "harness", ["claude", "codex"]);
311
+ const model = str(args, "model");
312
+ command = [...harness === "claude" ? ["claude", "-p"] : ["codex", "exec"], str(args, "task", true), ...model ? ["--model", model] : []];
313
+ }
314
+ const bytes = args.handback_bytes;
315
+ if (bytes !== void 0 && (typeof bytes !== "number" || !Number.isInteger(bytes) || bytes < 1 || bytes > LIMITS.handbackBytes))
316
+ throw new ToolInputError(`handback_bytes must be whole bytes, 1 to ${LIMITS.handbackBytes}`);
317
+ const name = args.name === void 0 ? void 0 : str(args, "name", true);
318
+ const title = str(args, "title");
319
+ const noPush = bool(args, "no_push");
320
+ const cwd = str(args, "cwd");
321
+ if (cwd !== void 0 && !isAbsolute(cwd)) throw new ToolInputError("cwd must be an absolute path");
322
+ if (cwd !== void 0 && !isDirectory(cwd)) throw new ToolInputError(`cwd ${cwd} is not an existing directory`);
323
+ return {
324
+ command,
325
+ ...name ? { name } : {},
326
+ ...title !== void 0 ? { title } : {},
327
+ ...noPush ? { no_push: true } : {},
328
+ ...bytes !== void 0 ? { handback_bytes: bytes } : {},
329
+ ...cwd !== void 0 ? { cwd } : {}
330
+ };
331
+ }
332
+ function isDirectory(path) {
333
+ try {
334
+ return statSync(path).isDirectory();
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
272
339
  function seconds(args, fallback) {
273
340
  const value = args.wait_s;
274
341
  if (value === void 0) return fallback;
@@ -301,11 +368,14 @@ async function* chunks(input) {
301
368
  for (let read = await reader.read(); !read.done; read = await reader.read()) yield read.value;
302
369
  }
303
370
  function delivered(messages) {
304
- return [PREAMBLE, ...messages.map(wrapMessage)].join("\n\n");
371
+ const shown = (message) => message.shown_at ? `(a hook already showed you this at ${clock(message.shown_at)}; that copy may reach you after this call)
372
+ ` : "";
373
+ return [PREAMBLE, ...messages.map((message) => `${shown(message)}${wrapMessage(message)}`)].join("\n\n");
305
374
  }
306
375
  async function runRelayMcp(options) {
307
376
  const log = options.log ?? (() => {
308
377
  });
378
+ const tools = options.spawn ? TOOLS : TOOLS.filter((t) => t.name !== "relay_spawn");
309
379
  const home = options.env?.HOME;
310
380
  const sent = /* @__PURE__ */ new Map();
311
381
  let client;
@@ -450,11 +520,39 @@ async function runRelayMcp(options) {
450
520
  if (result.held) return text(heldText(result.held));
451
521
  const lines = reachLines(result.deliveries);
452
522
  const to = result.deliveries.map((d) => lines.length ? d.address : deliveryText(d)).join(", ");
523
+ const receipt = result.deliveries.some((d) => d.state === "running" || d.state === "starting") ? [`relay_status with id ${shortId(result.id)} shows when it is read`] : [];
453
524
  return {
454
- ...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines].join("\n")),
525
+ ...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines, ...receipt].join("\n")),
455
526
  structuredContent: sentContent(result)
456
527
  };
457
528
  }
529
+ case "relay_spawn": {
530
+ const request = spawnRequest(args);
531
+ const spawn = options.spawn;
532
+ if (!spawn) throw new RelayError("method_not_found", "no tool relay_spawn");
533
+ const view = await call("whoami");
534
+ let started;
535
+ try {
536
+ const cwd = request.cwd ?? (view.cwd && isAbsolute(view.cwd) && isDirectory(view.cwd) ? view.cwd : void 0);
537
+ started = await spawn({ ...request, as: view.address, ...view.harness ? { harness: view.harness } : {}, ...cwd ? { cwd } : {} });
538
+ } catch (error) {
539
+ return text(error instanceof Error ? error.message : String(error), true);
540
+ }
541
+ return {
542
+ ...text(
543
+ `${started.name} started (pid ${started.pid}) and runs in the background: its messages and its handback reach you by themselves, after your tool calls or when you stop; steer it with relay_send to ${started.name} (it gets the message after its next tool call or before it finishes)`
544
+ ),
545
+ structuredContent: { name: started.name, pid: started.pid }
546
+ };
547
+ }
548
+ case "relay_status": {
549
+ const receipts = await call("receipts", { id: str(args, "id", true) });
550
+ const lines = receiptLines(receipts);
551
+ return {
552
+ ...text([`${receipts.id}:`, ...lines.length ? lines : ["no deliveries of it are kept any more"]].join("\n")),
553
+ structuredContent: { ...receipts }
554
+ };
555
+ }
458
556
  case "relay_reply": {
459
557
  let id = str(args, "id", true);
460
558
  const body = str(args, "body", true);
@@ -681,10 +779,10 @@ ${wrapMessage(message)}`,
681
779
  case "ping":
682
780
  return respond({});
683
781
  case "tools/list":
684
- return respond({ tools: TOOLS });
782
+ return respond({ tools });
685
783
  case "tools/call": {
686
784
  if (!initialized) return write({ jsonrpc: "2.0", id, error: { code: -32002, message: "call initialize first" } });
687
- if (!TOOLS.some((t) => t.name === params.name)) {
785
+ if (!tools.some((t) => t.name === params.name)) {
688
786
  return write({ jsonrpc: "2.0", id, error: { code: -32602, message: `unknown tool ${JSON.stringify(params.name)}` } });
689
787
  }
690
788
  return respond(await callTool(params));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The SCP stdio MCP server: gives an agent session the relay's tools.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://scp.dev",
@@ -29,8 +29,8 @@
29
29
  "typecheck": "tsc -p ."
30
30
  },
31
31
  "dependencies": {
32
- "@scp-dev/broker": "0.1.0",
33
- "@scp-dev/core": "0.1.0"
32
+ "@scp-dev/broker": "0.1.1",
33
+ "@scp-dev/core": "0.1.1"
34
34
  },
35
35
  "devDependencies": {
36
36
  "tsup": "^8.5.1"
package/src/index.ts CHANGED
@@ -1 +1 @@
1
- export { INSTRUCTIONS, MCP_PROTOCOLS, runRelayMcp, TOOLS, type RelayMcpOptions } from "./server"
1
+ export { INSTRUCTIONS, MCP_PROTOCOLS, runRelayMcp, TOOLS, type RelayMcpOptions, type SpawnRequest } from "./server"
package/src/server.ts CHANGED
@@ -1,13 +1,17 @@
1
+ import { statSync } from "node:fs"
2
+ import { isAbsolute } from "node:path"
1
3
  import {
2
4
  type AskResult,
3
5
  type Claim,
4
6
  CLI_NAME,
7
+ clock,
5
8
  deliveryText,
6
9
  type EndpointView,
7
10
  type Envelope,
8
11
  type JudgeHeld,
9
12
  type JudgeRedacted,
10
13
  LIMITS,
14
+ type MessageReceipts,
11
15
  type PeekResult,
12
16
  type PeekSeen,
13
17
  type PeekState,
@@ -15,6 +19,7 @@ import {
15
19
  type PeerView,
16
20
  PREAMBLE,
17
21
  reachLines,
22
+ receiptLines,
18
23
  RelayError,
19
24
  type ScopeChange,
20
25
  type ScopeView,
@@ -37,8 +42,8 @@ export const INSTRUCTIONS = [
37
42
  "Use relay_list to see the other agents on this machine, and relay_send or relay_ask to message one by its address.",
38
43
  "Read messages for you with relay_inbox and reply with relay_reply when an answer is useful; keep your own line current with relay_set_status and relay_set_title.",
39
44
  'A message marked relation="parent" is from the agent that started you and gave you your task: it continues that task, so apply it before you finish, the newer instruction winning, still only within what your user authorised.',
40
- "When you start, set your title and a one-line now with relay_set_title / relay_set_now so peers can find you.",
41
- `To hand work to another agent, run ${CLI_NAME} spawn <harness command> from your shell (e.g. ${CLI_NAME} spawn codex exec "..."): it returns at once with the worker's name, so never background it; the worker's result reaches you by itself as a relay message, and relay_send to its name steers it meanwhile.`,
45
+ "Optionally set your title and a one-line now with relay_set_title / relay_set_now so peers can find you; skip it when you only run workers.",
46
+ `To hand work to another agent, call relay_spawn with its harness command (e.g. command ["codex", "exec", "..."], or ${CLI_NAME} spawn codex exec "..." from your shell): it returns at once with the worker's name; its messages and its result reach you by themselves as relay messages, relay_send to its name steers it meanwhile, and relay_status with a message id says when it read it.`,
42
47
  "You see and reach only agents in your scope: relay_peers says why each is reachable; relay_rules and relay_group narrow who may message you.",
43
48
  "Never put credentials in a message: send a reference instead.",
44
49
  ].join("\n")
@@ -57,11 +62,29 @@ export interface RelayMcpOptions {
57
62
  /** push deliveries as `notifications/claude/channel` */
58
63
  channels?: boolean
59
64
  version?: string
65
+ /** starts a worker for relay_spawn, resolving with its name and its runner's pid once its harness started; without it there is no relay_spawn */
66
+ spawn?: (request: SpawnRequest) => Promise<{ name: string; pid: number }>
60
67
  /** diagnostics, never stdout */
61
68
  log?: (line: string) => void
62
69
  heartbeatMs?: number
63
70
  }
64
71
 
72
+ /**
73
+ * what relay_spawn asks for: `swarm spawn --bg` of `command` with these flags, acting as `as`, the shim's own address, of `harness`,
74
+ * in `cwd`: the one asked for, else the caller's working directory when it exists, else (none) the shim's
75
+ */
76
+ export interface SpawnRequest {
77
+ command: string[]
78
+ as: string
79
+ harness?: string
80
+ name?: string
81
+ title?: string
82
+ no_push?: boolean
83
+ handback_bytes?: number
84
+ /** the directory the worker runs in, absolute and existing */
85
+ cwd?: string
86
+ }
87
+
65
88
  type Json = Record<string, unknown>
66
89
  type ToolResult = { content: { type: "text"; text: string }[]; isError?: boolean; structuredContent?: Record<string, unknown> }
67
90
 
@@ -117,6 +140,31 @@ export const TOOLS = [
117
140
  additionalProperties: false,
118
141
  },
119
142
  },
143
+ {
144
+ name: "relay_spawn",
145
+ description: `Start a worker agent under you, as ${CLI_NAME} spawn does: command is its harness command as argv, its task in it (e.g. ["claude", "-p", "<task>", "--model", "<model>"] or ["codex", "exec", "<task>"]), or give harness and task instead. It returns at once with the worker's name; its messages and its handback (exit code and final output) reach you by themselves, and relay_send to that name steers it: the worker gets your message after its next tool call or before it finishes, so its task need not tell it to check an inbox.`,
146
+ inputSchema: {
147
+ type: "object",
148
+ properties: {
149
+ command: { type: "array", items: { type: "string" }, minItems: 1, description: "the harness command as argv" },
150
+ harness: { type: "string", enum: ["claude", "codex"], description: "instead of command: claude -p <task> or codex exec <task>" },
151
+ task: { type: "string", description: "with harness: the worker's task" },
152
+ model: { type: "string", description: "with harness: its --model" },
153
+ name: { type: "string", description: "its address (default <harness>-<5 random characters>)" },
154
+ title: { type: "string", maxLength: 64, description: "its title until it sets its own (default the task's first line)" },
155
+ no_push: { type: "boolean", description: "your stop does not wait on it; its messages and handback still reach you after your tool calls" },
156
+ handback_bytes: { type: "integer", minimum: 1, maximum: LIMITS.handbackBytes, description: "hand back only the last N bytes of its final output" },
157
+ cwd: { type: "string", description: "the absolute path of an existing directory it runs in (default your working directory)" },
158
+ },
159
+ additionalProperties: false,
160
+ },
161
+ },
162
+ {
163
+ name: "relay_status",
164
+ description:
165
+ "Whether the recipients of a message you sent read it: per recipient read at, delivered at (a background hook handed it over; it reaches the model with its next request), shown by a hook at, or unread. By the message's id, its short id or last (the newest you sent).",
166
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
167
+ },
120
168
  {
121
169
  name: "relay_reply",
122
170
  description:
@@ -353,6 +401,48 @@ function groupText(result: unknown): string {
353
401
  ].join("\n")
354
402
  }
355
403
 
404
+ /** relay_spawn's arguments as a spawn request, all but who spawns; a `cwd` must be an existing directory's absolute path */
405
+ function spawnRequest(args: Json): Omit<SpawnRequest, "as" | "harness"> {
406
+ if ((args.command === undefined) === (args.harness === undefined)) throw new ToolInputError("give command (the harness command as argv) or harness and task")
407
+ let command: string[]
408
+ if (args.command !== undefined) {
409
+ const words = args.command
410
+ if (!Array.isArray(words) || !words.length || !words.every((w) => typeof w === "string") || !String(words[0]).trim())
411
+ throw new ToolInputError('command must be the harness command as a list of words, e.g. ["codex", "exec", "<task>"]')
412
+ if (args.task !== undefined || args.model !== undefined) throw new ToolInputError("task and model go with harness; with command, put them in it")
413
+ command = words as string[]
414
+ } else {
415
+ const harness = oneOf(args, "harness", ["claude", "codex"] as const)
416
+ const model = str(args, "model")
417
+ command = [...(harness === "claude" ? ["claude", "-p"] : ["codex", "exec"]), str(args, "task", true), ...(model ? ["--model", model] : [])]
418
+ }
419
+ const bytes = args.handback_bytes
420
+ if (bytes !== undefined && (typeof bytes !== "number" || !Number.isInteger(bytes) || bytes < 1 || bytes > LIMITS.handbackBytes))
421
+ throw new ToolInputError(`handback_bytes must be whole bytes, 1 to ${LIMITS.handbackBytes}`)
422
+ const name = args.name === undefined ? undefined : str(args, "name", true)
423
+ const title = str(args, "title")
424
+ const noPush = bool(args, "no_push")
425
+ const cwd = str(args, "cwd")
426
+ if (cwd !== undefined && !isAbsolute(cwd)) throw new ToolInputError("cwd must be an absolute path")
427
+ if (cwd !== undefined && !isDirectory(cwd)) throw new ToolInputError(`cwd ${cwd} is not an existing directory`)
428
+ return {
429
+ command,
430
+ ...(name ? { name } : {}),
431
+ ...(title !== undefined ? { title } : {}),
432
+ ...(noPush ? { no_push: true } : {}),
433
+ ...(bytes !== undefined ? { handback_bytes: bytes } : {}),
434
+ ...(cwd !== undefined ? { cwd } : {}),
435
+ }
436
+ }
437
+
438
+ function isDirectory(path: string): boolean {
439
+ try {
440
+ return statSync(path).isDirectory()
441
+ } catch {
442
+ return false
443
+ }
444
+ }
445
+
356
446
  function seconds(args: Json, fallback: number): number {
357
447
  const value = args.wait_s
358
448
  if (value === undefined) return fallback
@@ -392,7 +482,9 @@ async function* chunks(input: RelayMcpOptions["input"]): AsyncIterable<Uint8Arra
392
482
  }
393
483
 
394
484
  function delivered(messages: Envelope[]): string {
395
- return [PREAMBLE, ...messages.map(wrapMessage)].join("\n\n")
485
+ const shown = (message: Envelope) =>
486
+ message.shown_at ? `(a hook already showed you this at ${clock(message.shown_at)}; that copy may reach you after this call)\n` : ""
487
+ return [PREAMBLE, ...messages.map((message) => `${shown(message)}${wrapMessage(message)}`)].join("\n\n")
396
488
  }
397
489
 
398
490
  /**
@@ -401,6 +493,7 @@ function delivered(messages: Envelope[]): string {
401
493
  */
402
494
  export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
403
495
  const log = options.log ?? (() => {})
496
+ const tools = options.spawn ? TOOLS : TOOLS.filter((t) => t.name !== "relay_spawn")
404
497
  const home = options.env?.HOME
405
498
  const sent = new Map<string, { from: string; thread: string }>()
406
499
  let client: RelayClient | undefined
@@ -561,11 +654,41 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
561
654
  if (result.held) return text(heldText(result.held))
562
655
  const lines = reachLines(result.deliveries)
563
656
  const to = result.deliveries.map((d) => (lines.length ? d.address : deliveryText(d))).join(", ")
657
+ const receipt = result.deliveries.some((d) => d.state === "running" || d.state === "starting")
658
+ ? [`relay_status with id ${shortId(result.id)} shows when it is read`]
659
+ : []
564
660
  return {
565
- ...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines].join("\n")),
661
+ ...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines, ...receipt].join("\n")),
566
662
  structuredContent: sentContent(result),
567
663
  }
568
664
  }
665
+ case "relay_spawn": {
666
+ const request = spawnRequest(args)
667
+ const spawn = options.spawn
668
+ if (!spawn) throw new RelayError("method_not_found", "no tool relay_spawn")
669
+ const view = await call<EndpointView>("whoami")
670
+ let started: { name: string; pid: number }
671
+ try {
672
+ const cwd = request.cwd ?? (view.cwd && isAbsolute(view.cwd) && isDirectory(view.cwd) ? view.cwd : undefined)
673
+ started = await spawn({ ...request, as: view.address, ...(view.harness ? { harness: view.harness } : {}), ...(cwd ? { cwd } : {}) })
674
+ } catch (error) {
675
+ return text(error instanceof Error ? error.message : String(error), true)
676
+ }
677
+ return {
678
+ ...text(
679
+ `${started.name} started (pid ${started.pid}) and runs in the background: its messages and its handback reach you by themselves, after your tool calls or when you stop; steer it with relay_send to ${started.name} (it gets the message after its next tool call or before it finishes)`,
680
+ ),
681
+ structuredContent: { name: started.name, pid: started.pid },
682
+ }
683
+ }
684
+ case "relay_status": {
685
+ const receipts = await call<MessageReceipts>("receipts", { id: str(args, "id", true) })
686
+ const lines = receiptLines(receipts)
687
+ return {
688
+ ...text([`${receipts.id}:`, ...(lines.length ? lines : ["no deliveries of it are kept any more"])].join("\n")),
689
+ structuredContent: { ...receipts },
690
+ }
691
+ }
569
692
  case "relay_reply": {
570
693
  let id = str(args, "id", true)
571
694
  const body = str(args, "body", true)
@@ -793,10 +916,10 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
793
916
  case "ping":
794
917
  return respond({})
795
918
  case "tools/list":
796
- return respond({ tools: TOOLS })
919
+ return respond({ tools })
797
920
  case "tools/call": {
798
921
  if (!initialized) return write({ jsonrpc: "2.0", id, error: { code: -32002, message: "call initialize first" } })
799
- if (!TOOLS.some((t) => t.name === params.name)) {
922
+ if (!tools.some((t) => t.name === params.name)) {
800
923
  return write({ jsonrpc: "2.0", id, error: { code: -32602, message: `unknown tool ${JSON.stringify(params.name)}` } })
801
924
  }
802
925
  return respond(await callTool(params))