@scp-dev/mcp 0.1.0 → 0.1.2

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
@@ -15,13 +15,35 @@ interface RelayMcpOptions {
15
15
  output: (line: string) => void;
16
16
  /** HOME shortens cwds in relay_list */
17
17
  env?: Record<string, string | undefined>;
18
- /** push deliveries as `notifications/claude/channel` */
18
+ /** push deliveries as `notifications/claude/channel` to a client that declares `claude/channel` or was launched with channels */
19
19
  channels?: boolean;
20
+ /** the client was started with channels loaded (Claude Code does not declare the capability itself) */
21
+ launchedWithChannels?: boolean;
20
22
  version?: string;
23
+ /** 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 */
24
+ spawn?: (request: SpawnRequest) => Promise<{
25
+ name: string;
26
+ pid: number;
27
+ }>;
21
28
  /** diagnostics, never stdout */
22
29
  log?: (line: string) => void;
23
30
  heartbeatMs?: number;
24
31
  }
32
+ /**
33
+ * what relay_spawn asks for: `swarm spawn --bg` of `command` with these flags, acting as `as`, the shim's own address, of `harness`,
34
+ * in `cwd`: the one asked for, else the caller's working directory when it exists, else (none) the shim's
35
+ */
36
+ interface SpawnRequest {
37
+ command: string[];
38
+ as: string;
39
+ harness?: string;
40
+ name?: string;
41
+ title?: string;
42
+ no_push?: boolean;
43
+ handback_bytes?: number;
44
+ /** the directory the worker runs in, absolute and existing */
45
+ cwd?: string;
46
+ }
25
47
  declare const TOOLS: readonly [{
26
48
  readonly name: "relay_whoami";
27
49
  readonly description: "Your own address on the relay, with your harness, title and labels.";
@@ -80,6 +102,72 @@ declare const TOOLS: readonly [{
80
102
  readonly required: readonly ["to", "body"];
81
103
  readonly additionalProperties: false;
82
104
  };
105
+ }, {
106
+ readonly name: "relay_spawn";
107
+ 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.";
108
+ readonly inputSchema: {
109
+ readonly type: "object";
110
+ readonly properties: {
111
+ readonly command: {
112
+ readonly type: "array";
113
+ readonly items: {
114
+ readonly type: "string";
115
+ };
116
+ readonly minItems: 1;
117
+ readonly description: "the harness command as argv";
118
+ };
119
+ readonly harness: {
120
+ readonly type: "string";
121
+ readonly enum: readonly ["claude", "codex"];
122
+ readonly description: "instead of command: claude -p <task> or codex exec <task>";
123
+ };
124
+ readonly task: {
125
+ readonly type: "string";
126
+ readonly description: "with harness: the worker's task";
127
+ };
128
+ readonly model: {
129
+ readonly type: "string";
130
+ readonly description: "with harness: its --model";
131
+ };
132
+ readonly name: {
133
+ readonly type: "string";
134
+ readonly description: "its address (default <harness>-<5 random characters>)";
135
+ };
136
+ readonly title: {
137
+ readonly type: "string";
138
+ readonly maxLength: 64;
139
+ readonly description: "its title until it sets its own (default the task's first line)";
140
+ };
141
+ readonly no_push: {
142
+ readonly type: "boolean";
143
+ readonly description: "your stop does not wait on it; its messages and handback still reach you after your tool calls";
144
+ };
145
+ readonly handback_bytes: {
146
+ readonly type: "integer";
147
+ readonly minimum: 1;
148
+ readonly maximum: 16384;
149
+ readonly description: "hand back only the last N bytes of its final output";
150
+ };
151
+ readonly cwd: {
152
+ readonly type: "string";
153
+ readonly description: "the absolute path of an existing directory it runs in (default your working directory)";
154
+ };
155
+ };
156
+ readonly additionalProperties: false;
157
+ };
158
+ }, {
159
+ readonly name: "relay_status";
160
+ 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).";
161
+ readonly inputSchema: {
162
+ readonly type: "object";
163
+ readonly properties: {
164
+ readonly id: {
165
+ readonly type: "string";
166
+ };
167
+ };
168
+ readonly required: readonly ["id"];
169
+ readonly additionalProperties: false;
170
+ };
83
171
  }, {
84
172
  readonly name: "relay_reply";
85
173
  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 +449,4 @@ declare const TOOLS: readonly [{
361
449
  */
362
450
  declare function runRelayMcp(options: RelayMcpOptions): Promise<void>;
363
451
 
364
- export { INSTRUCTIONS, MCP_PROTOCOLS, type RelayMcpOptions, TOOLS, runRelayMcp };
452
+ 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,46 @@ 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
+ }
339
+ var capped = (wait) => typeof wait === "number" && wait > LIMITS.waitMaxS;
272
340
  function seconds(args, fallback) {
273
341
  const value = args.wait_s;
274
342
  if (value === void 0) return fallback;
@@ -301,11 +369,14 @@ async function* chunks(input) {
301
369
  for (let read = await reader.read(); !read.done; read = await reader.read()) yield read.value;
302
370
  }
303
371
  function delivered(messages) {
304
- return [PREAMBLE, ...messages.map(wrapMessage)].join("\n\n");
372
+ 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)
373
+ ` : "";
374
+ return [PREAMBLE, ...messages.map((message) => `${shown(message)}${wrapMessage(message)}`)].join("\n\n");
305
375
  }
306
376
  async function runRelayMcp(options) {
307
377
  const log = options.log ?? (() => {
308
378
  });
379
+ const tools = options.spawn ? TOOLS : TOOLS.filter((t) => t.name !== "relay_spawn");
309
380
  const home = options.env?.HOME;
310
381
  const sent = /* @__PURE__ */ new Map();
311
382
  let client;
@@ -450,11 +521,39 @@ async function runRelayMcp(options) {
450
521
  if (result.held) return text(heldText(result.held));
451
522
  const lines = reachLines(result.deliveries);
452
523
  const to = result.deliveries.map((d) => lines.length ? d.address : deliveryText(d)).join(", ");
524
+ 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
525
  return {
454
- ...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines].join("\n")),
526
+ ...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines, ...receipt].join("\n")),
455
527
  structuredContent: sentContent(result)
456
528
  };
457
529
  }
530
+ case "relay_spawn": {
531
+ const request = spawnRequest(args);
532
+ const spawn = options.spawn;
533
+ if (!spawn) throw new RelayError("method_not_found", "no tool relay_spawn");
534
+ const view = await call("whoami");
535
+ let started;
536
+ try {
537
+ const cwd = request.cwd ?? (view.cwd && isAbsolute(view.cwd) && isDirectory(view.cwd) ? view.cwd : void 0);
538
+ started = await spawn({ ...request, as: view.address, ...view.harness ? { harness: view.harness } : {}, ...cwd ? { cwd } : {} });
539
+ } catch (error) {
540
+ return text(error instanceof Error ? error.message : String(error), true);
541
+ }
542
+ return {
543
+ ...text(
544
+ `${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)`
545
+ ),
546
+ structuredContent: { name: started.name, pid: started.pid }
547
+ };
548
+ }
549
+ case "relay_status": {
550
+ const receipts = await call("receipts", { id: str(args, "id", true) });
551
+ const lines = receiptLines(receipts);
552
+ return {
553
+ ...text([`${receipts.id}:`, ...lines.length ? lines : ["no deliveries of it are kept any more"]].join("\n")),
554
+ structuredContent: { ...receipts }
555
+ };
556
+ }
458
557
  case "relay_reply": {
459
558
  let id = str(args, "id", true);
460
559
  const body = str(args, "body", true);
@@ -483,7 +582,7 @@ async function runRelayMcp(options) {
483
582
  case "relay_inbox": {
484
583
  const ack = bool(args, "ack") ?? true;
485
584
  const { messages } = await call("inbox", { wait_s: seconds(args, 0) });
486
- if (!messages.length) return text("no unread messages");
585
+ if (!messages.length) return text(`no unread messages${capped(args.wait_s) ? `; waited ${LIMITS.waitMaxS} s (cap)` : ""}`);
487
586
  for (const message of messages) remember(message);
488
587
  if (ack) await call("ack", { seqs: messages.map((m) => m.seq) });
489
588
  return text(delivered(messages));
@@ -668,9 +767,8 @@ ${wrapMessage(message)}`,
668
767
  const protocolVersion = typeof asked === "string" && MCP_PROTOCOLS.includes(asked) ? asked : MCP_PROTOCOLS[0];
669
768
  initialized = true;
670
769
  const capabilities = params.capabilities;
671
- const client2 = params.clientInfo;
672
770
  const channel = capabilities?.experimental?.["claude/channel"];
673
- channelClient = typeof channel === "object" && channel !== null || client2?.name === "claude-code";
771
+ channelClient = typeof channel === "object" && channel !== null || options.launchedWithChannels === true;
674
772
  return respond({
675
773
  protocolVersion,
676
774
  capabilities: { tools: {}, ...options.channels ? { experimental: { "claude/channel": {} } } : {} },
@@ -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.2",
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.2",
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")
@@ -54,14 +59,34 @@ export interface RelayMcpOptions {
54
59
  output: (line: string) => void
55
60
  /** HOME shortens cwds in relay_list */
56
61
  env?: Record<string, string | undefined>
57
- /** push deliveries as `notifications/claude/channel` */
62
+ /** push deliveries as `notifications/claude/channel` to a client that declares `claude/channel` or was launched with channels */
58
63
  channels?: boolean
64
+ /** the client was started with channels loaded (Claude Code does not declare the capability itself) */
65
+ launchedWithChannels?: boolean
59
66
  version?: string
67
+ /** 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 */
68
+ spawn?: (request: SpawnRequest) => Promise<{ name: string; pid: number }>
60
69
  /** diagnostics, never stdout */
61
70
  log?: (line: string) => void
62
71
  heartbeatMs?: number
63
72
  }
64
73
 
74
+ /**
75
+ * what relay_spawn asks for: `swarm spawn --bg` of `command` with these flags, acting as `as`, the shim's own address, of `harness`,
76
+ * in `cwd`: the one asked for, else the caller's working directory when it exists, else (none) the shim's
77
+ */
78
+ export interface SpawnRequest {
79
+ command: string[]
80
+ as: string
81
+ harness?: string
82
+ name?: string
83
+ title?: string
84
+ no_push?: boolean
85
+ handback_bytes?: number
86
+ /** the directory the worker runs in, absolute and existing */
87
+ cwd?: string
88
+ }
89
+
65
90
  type Json = Record<string, unknown>
66
91
  type ToolResult = { content: { type: "text"; text: string }[]; isError?: boolean; structuredContent?: Record<string, unknown> }
67
92
 
@@ -117,6 +142,31 @@ export const TOOLS = [
117
142
  additionalProperties: false,
118
143
  },
119
144
  },
145
+ {
146
+ name: "relay_spawn",
147
+ 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.`,
148
+ inputSchema: {
149
+ type: "object",
150
+ properties: {
151
+ command: { type: "array", items: { type: "string" }, minItems: 1, description: "the harness command as argv" },
152
+ harness: { type: "string", enum: ["claude", "codex"], description: "instead of command: claude -p <task> or codex exec <task>" },
153
+ task: { type: "string", description: "with harness: the worker's task" },
154
+ model: { type: "string", description: "with harness: its --model" },
155
+ name: { type: "string", description: "its address (default <harness>-<5 random characters>)" },
156
+ title: { type: "string", maxLength: 64, description: "its title until it sets its own (default the task's first line)" },
157
+ no_push: { type: "boolean", description: "your stop does not wait on it; its messages and handback still reach you after your tool calls" },
158
+ handback_bytes: { type: "integer", minimum: 1, maximum: LIMITS.handbackBytes, description: "hand back only the last N bytes of its final output" },
159
+ cwd: { type: "string", description: "the absolute path of an existing directory it runs in (default your working directory)" },
160
+ },
161
+ additionalProperties: false,
162
+ },
163
+ },
164
+ {
165
+ name: "relay_status",
166
+ description:
167
+ "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).",
168
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
169
+ },
120
170
  {
121
171
  name: "relay_reply",
122
172
  description:
@@ -353,6 +403,50 @@ function groupText(result: unknown): string {
353
403
  ].join("\n")
354
404
  }
355
405
 
406
+ /** relay_spawn's arguments as a spawn request, all but who spawns; a `cwd` must be an existing directory's absolute path */
407
+ function spawnRequest(args: Json): Omit<SpawnRequest, "as" | "harness"> {
408
+ if ((args.command === undefined) === (args.harness === undefined)) throw new ToolInputError("give command (the harness command as argv) or harness and task")
409
+ let command: string[]
410
+ if (args.command !== undefined) {
411
+ const words = args.command
412
+ if (!Array.isArray(words) || !words.length || !words.every((w) => typeof w === "string") || !String(words[0]).trim())
413
+ throw new ToolInputError('command must be the harness command as a list of words, e.g. ["codex", "exec", "<task>"]')
414
+ if (args.task !== undefined || args.model !== undefined) throw new ToolInputError("task and model go with harness; with command, put them in it")
415
+ command = words as string[]
416
+ } else {
417
+ const harness = oneOf(args, "harness", ["claude", "codex"] as const)
418
+ const model = str(args, "model")
419
+ command = [...(harness === "claude" ? ["claude", "-p"] : ["codex", "exec"]), str(args, "task", true), ...(model ? ["--model", model] : [])]
420
+ }
421
+ const bytes = args.handback_bytes
422
+ if (bytes !== undefined && (typeof bytes !== "number" || !Number.isInteger(bytes) || bytes < 1 || bytes > LIMITS.handbackBytes))
423
+ throw new ToolInputError(`handback_bytes must be whole bytes, 1 to ${LIMITS.handbackBytes}`)
424
+ const name = args.name === undefined ? undefined : str(args, "name", true)
425
+ const title = str(args, "title")
426
+ const noPush = bool(args, "no_push")
427
+ const cwd = str(args, "cwd")
428
+ if (cwd !== undefined && !isAbsolute(cwd)) throw new ToolInputError("cwd must be an absolute path")
429
+ if (cwd !== undefined && !isDirectory(cwd)) throw new ToolInputError(`cwd ${cwd} is not an existing directory`)
430
+ return {
431
+ command,
432
+ ...(name ? { name } : {}),
433
+ ...(title !== undefined ? { title } : {}),
434
+ ...(noPush ? { no_push: true } : {}),
435
+ ...(bytes !== undefined ? { handback_bytes: bytes } : {}),
436
+ ...(cwd !== undefined ? { cwd } : {}),
437
+ }
438
+ }
439
+
440
+ function isDirectory(path: string): boolean {
441
+ try {
442
+ return statSync(path).isDirectory()
443
+ } catch {
444
+ return false
445
+ }
446
+ }
447
+
448
+ const capped = (wait: unknown): boolean => typeof wait === "number" && wait > LIMITS.waitMaxS
449
+
356
450
  function seconds(args: Json, fallback: number): number {
357
451
  const value = args.wait_s
358
452
  if (value === undefined) return fallback
@@ -392,7 +486,9 @@ async function* chunks(input: RelayMcpOptions["input"]): AsyncIterable<Uint8Arra
392
486
  }
393
487
 
394
488
  function delivered(messages: Envelope[]): string {
395
- return [PREAMBLE, ...messages.map(wrapMessage)].join("\n\n")
489
+ const shown = (message: Envelope) =>
490
+ message.shown_at ? `(a hook already showed you this at ${clock(message.shown_at)}; that copy may reach you after this call)\n` : ""
491
+ return [PREAMBLE, ...messages.map((message) => `${shown(message)}${wrapMessage(message)}`)].join("\n\n")
396
492
  }
397
493
 
398
494
  /**
@@ -401,6 +497,7 @@ function delivered(messages: Envelope[]): string {
401
497
  */
402
498
  export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
403
499
  const log = options.log ?? (() => {})
500
+ const tools = options.spawn ? TOOLS : TOOLS.filter((t) => t.name !== "relay_spawn")
404
501
  const home = options.env?.HOME
405
502
  const sent = new Map<string, { from: string; thread: string }>()
406
503
  let client: RelayClient | undefined
@@ -409,7 +506,7 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
409
506
  let me: EndpointView | null = null
410
507
  let announced = false
411
508
  let initialized = false
412
- /** whether the client takes channel notifications: it says so, or it is Claude Code, which takes them without saying so when started with channels */
509
+ /** whether the client takes channel notifications: it says so, or it was launched with channels (Claude Code does not say so itself) */
413
510
  let channelClient = false
414
511
  let stopped = false
415
512
  /** seqs this shim pushed, so a push whose confirm was lost is not pushed again */
@@ -561,11 +658,41 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
561
658
  if (result.held) return text(heldText(result.held))
562
659
  const lines = reachLines(result.deliveries)
563
660
  const to = result.deliveries.map((d) => (lines.length ? d.address : deliveryText(d))).join(", ")
661
+ const receipt = result.deliveries.some((d) => d.state === "running" || d.state === "starting")
662
+ ? [`relay_status with id ${shortId(result.id)} shows when it is read`]
663
+ : []
564
664
  return {
565
- ...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines].join("\n")),
665
+ ...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines, ...receipt].join("\n")),
566
666
  structuredContent: sentContent(result),
567
667
  }
568
668
  }
669
+ case "relay_spawn": {
670
+ const request = spawnRequest(args)
671
+ const spawn = options.spawn
672
+ if (!spawn) throw new RelayError("method_not_found", "no tool relay_spawn")
673
+ const view = await call<EndpointView>("whoami")
674
+ let started: { name: string; pid: number }
675
+ try {
676
+ const cwd = request.cwd ?? (view.cwd && isAbsolute(view.cwd) && isDirectory(view.cwd) ? view.cwd : undefined)
677
+ started = await spawn({ ...request, as: view.address, ...(view.harness ? { harness: view.harness } : {}), ...(cwd ? { cwd } : {}) })
678
+ } catch (error) {
679
+ return text(error instanceof Error ? error.message : String(error), true)
680
+ }
681
+ return {
682
+ ...text(
683
+ `${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)`,
684
+ ),
685
+ structuredContent: { name: started.name, pid: started.pid },
686
+ }
687
+ }
688
+ case "relay_status": {
689
+ const receipts = await call<MessageReceipts>("receipts", { id: str(args, "id", true) })
690
+ const lines = receiptLines(receipts)
691
+ return {
692
+ ...text([`${receipts.id}:`, ...(lines.length ? lines : ["no deliveries of it are kept any more"])].join("\n")),
693
+ structuredContent: { ...receipts },
694
+ }
695
+ }
569
696
  case "relay_reply": {
570
697
  let id = str(args, "id", true)
571
698
  const body = str(args, "body", true)
@@ -594,7 +721,7 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
594
721
  case "relay_inbox": {
595
722
  const ack = bool(args, "ack") ?? true
596
723
  const { messages } = await call<{ messages: Envelope[] }>("inbox", { wait_s: seconds(args, 0) })
597
- if (!messages.length) return text("no unread messages")
724
+ if (!messages.length) return text(`no unread messages${capped(args.wait_s) ? `; waited ${LIMITS.waitMaxS} s (cap)` : ""}`)
598
725
  for (const message of messages) remember(message)
599
726
  if (ack) await call("ack", { seqs: messages.map((m) => m.seq) })
600
727
  return text(delivered(messages))
@@ -780,9 +907,8 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
780
907
  const protocolVersion = typeof asked === "string" && (MCP_PROTOCOLS as readonly string[]).includes(asked) ? asked : MCP_PROTOCOLS[0]
781
908
  initialized = true
782
909
  const capabilities = params.capabilities as { experimental?: Record<string, unknown> } | undefined
783
- const client = params.clientInfo as { name?: unknown } | undefined
784
910
  const channel = capabilities?.experimental?.["claude/channel"]
785
- channelClient = (typeof channel === "object" && channel !== null) || client?.name === "claude-code"
911
+ channelClient = (typeof channel === "object" && channel !== null) || options.launchedWithChannels === true
786
912
  return respond({
787
913
  protocolVersion,
788
914
  capabilities: { tools: {}, ...(options.channels ? { experimental: { "claude/channel": {} } } : {}) },
@@ -793,10 +919,10 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
793
919
  case "ping":
794
920
  return respond({})
795
921
  case "tools/list":
796
- return respond({ tools: TOOLS })
922
+ return respond({ tools })
797
923
  case "tools/call": {
798
924
  if (!initialized) return write({ jsonrpc: "2.0", id, error: { code: -32002, message: "call initialize first" } })
799
- if (!TOOLS.some((t) => t.name === params.name)) {
925
+ if (!tools.some((t) => t.name === params.name)) {
800
926
  return write({ jsonrpc: "2.0", id, error: { code: -32602, message: `unknown tool ${JSON.stringify(params.name)}` } })
801
927
  }
802
928
  return respond(await callTool(params))