agent-relay-server 0.27.2 → 0.28.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/docs/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.21.0",
5
+ "version": "0.27.2",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
@@ -7399,6 +7399,9 @@
7399
7399
  "claimable": {
7400
7400
  "type": "string"
7401
7401
  },
7402
+ "replyExpected": {
7403
+ "type": "string"
7404
+ },
7402
7405
  "from": {
7403
7406
  "type": "string"
7404
7407
  },
@@ -7460,6 +7463,16 @@
7460
7463
  }
7461
7464
  }
7462
7465
  },
7466
+ "409": {
7467
+ "description": "Conflict",
7468
+ "content": {
7469
+ "application/json": {
7470
+ "schema": {
7471
+ "$ref": "#/components/schemas/Error"
7472
+ }
7473
+ }
7474
+ }
7475
+ },
7463
7476
  "422": {
7464
7477
  "description": "Unprocessable entity",
7465
7478
  "content": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.27.2",
3
+ "version": "0.28.0",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "CONTRIBUTING.md"
34
34
  ],
35
35
  "dependencies": {
36
- "agent-relay-sdk": "0.2.16"
36
+ "agent-relay-sdk": "0.2.17"
37
37
  },
38
38
  "scripts": {
39
39
  "prepack": "bun run build:dashboard:bundle >&2",
package/public/index.html CHANGED
@@ -10454,12 +10454,28 @@ function spaceDigits(frac) {
10454
10454
  return frac.split("").join(" ");
10455
10455
  }
10456
10456
  /**
10457
+ * Spell a token one character at a time so the engine reads each symbol on its
10458
+ * own: digits stay as digits, letters are upper-cased so they're heard as the
10459
+ * letter name ("e" → "E" = "ee") rather than a syllable. Used for commit hashes
10460
+ * ("834543e" → "8 3 4 5 4 3 E") and acronyms ("id" → "I D").
10461
+ */
10462
+ function spellOut(token) {
10463
+ return token.split("").map((c) => /[0-9]/.test(c) ? c : c.toUpperCase()).join(" ");
10464
+ }
10465
+ /**
10457
10466
  * Ordered normalization rules. ORDER IS LOAD-BEARING:
10458
10467
  * - URLs/paths first, before anything mangles their slashes/dots.
10459
10468
  * - `~` → "approximately" before number rules consume the digits after it.
10460
10469
  * - number ranges ("5-7") before unit expansion, so the unit attaches once: "5 to 7 seconds".
10470
+ * - versions ("0.27.0") before decimals, so all dots become "dot" uniformly
10471
+ * instead of the decimal rule eating only the first pair and stranding ".0".
10461
10472
  * - decimals before unit expansion and before the sentence splitter sees the dot.
10462
- * - unit expansion last among the number rules.
10473
+ * - commit-hash spelling before nothing in particular, but after decimals so a
10474
+ * real number is never mistaken for a hash.
10475
+ * - "id" acronym expansion (camelCase suffix before the standalone word).
10476
+ * - unit expansion among the number rules.
10477
+ * - interior-dot LAST, so it only mops up dots the structured rules left behind
10478
+ * (identifiers like "branch.landed", "router.ts") — never a decimal or version.
10463
10479
  */
10464
10480
  var SPEECH_RULES = [
10465
10481
  {
@@ -10472,6 +10488,11 @@ var SPEECH_RULES = [
10472
10488
  pattern: /(?:\/[A-Za-z0-9._-]+){2,}\/?/g,
10473
10489
  replace: (m) => " " + m.replace(/[/._-]+/g, " ").trim() + " "
10474
10490
  },
10491
+ {
10492
+ name: "slash",
10493
+ pattern: /(?<=[A-Za-z0-9])\/(?=[A-Za-z0-9])/g,
10494
+ replace: " "
10495
+ },
10475
10496
  {
10476
10497
  name: "func-call",
10477
10498
  pattern: /\b([A-Za-z_$][\w$]*)\(\)/g,
@@ -10487,11 +10508,31 @@ var SPEECH_RULES = [
10487
10508
  pattern: /(\d)\s*[-–—]\s*(?=\d)/g,
10488
10509
  replace: "$1 to "
10489
10510
  },
10511
+ {
10512
+ name: "version",
10513
+ pattern: /\b(v?)(\d+(?:\.\d+){2,})\b/gi,
10514
+ replace: (_m, v, nums) => `${v ? "version " : ""}${nums.replace(/\./g, " dot ")}`
10515
+ },
10490
10516
  {
10491
10517
  name: "decimal",
10492
10518
  pattern: /\b(\d+)\.(\d+)\b/g,
10493
10519
  replace: (_m, i, f) => `${i} point ${spaceDigits(f)}`
10494
10520
  },
10521
+ {
10522
+ name: "hash",
10523
+ pattern: /\b(?=[0-9a-f]{7,40}\b)(?=[0-9a-f]*[a-f])(?=[0-9a-f]*\d)[0-9a-f]{7,40}\b/gi,
10524
+ replace: (m) => spellOut(m)
10525
+ },
10526
+ {
10527
+ name: "id-camel",
10528
+ pattern: /([a-z])(Id|ID)(s)?\b/g,
10529
+ replace: (_m, pre, _acr, plural) => `${pre} I D${plural ? "s" : ""}`
10530
+ },
10531
+ {
10532
+ name: "id-word",
10533
+ pattern: /\b(?:id|Id|ID)(s)?\b/g,
10534
+ replace: (_m, plural) => `I D${plural ? "s" : ""}`
10535
+ },
10495
10536
  {
10496
10537
  name: "byte-unit",
10497
10538
  pattern: /\b(\d+)\s?(kb|mb|gb|tb)\b/gi,
@@ -10506,6 +10547,16 @@ var SPEECH_RULES = [
10506
10547
  name: "s-unit",
10507
10548
  pattern: /\b(\d{1,3})\s?s\b/g,
10508
10549
  replace: (_m, n) => `${n} ${UNIT_WORDS.s}`
10550
+ },
10551
+ {
10552
+ name: "live-adjective",
10553
+ pattern: /\b(is|are|was|were|be|been|being|now|and|stays?|staying|went|go|goes|going|deployed|it's|that's|here's|there's|we're|you're|they're)\s+live\b/gi,
10554
+ replace: (_m, cue) => `${cue} lyve`
10555
+ },
10556
+ {
10557
+ name: "interior-dot",
10558
+ pattern: /\.(?=[A-Za-z0-9])/g,
10559
+ replace: " dot "
10509
10560
  }
10510
10561
  ];
10511
10562
  /**
@@ -12255,6 +12306,7 @@ var useRelayStore = create$1()(persist((set, get) => ({
12255
12306
  view: "overview",
12256
12307
  showOffline: false,
12257
12308
  showBuiltIns: false,
12309
+ showReasoning: false,
12258
12310
  autoRefresh: true,
12259
12311
  voiceTtsEnabled: false,
12260
12312
  voiceTtsLang: "en-US",
@@ -14610,6 +14662,7 @@ var useRelayStore = create$1()(persist((set, get) => ({
14610
14662
  view: state.view,
14611
14663
  showOffline: state.showOffline,
14612
14664
  showBuiltIns: state.showBuiltIns,
14665
+ showReasoning: state.showReasoning,
14613
14666
  autoRefresh: state.autoRefresh,
14614
14667
  voiceTtsEnabled: state.voiceTtsEnabled,
14615
14668
  voiceTtsLang: state.voiceTtsLang,
@@ -128013,15 +128066,20 @@ function BusyIndicator({ blockedLabel, onInterrupt }) {
128013
128066
  })
128014
128067
  });
128015
128068
  }
128016
- function ActivityTrace({ steps }) {
128069
+ function ActivityTrace({ steps, showReasoning }) {
128017
128070
  const [hideTools, setHideTools] = (0, import_react.useState)(false);
128018
- if (!steps.length) return null;
128019
- const toolCount = steps.filter((s) => s.kind === "tool").length;
128071
+ const visible = showReasoning ? steps : steps.filter((s) => s.kind !== "reasoning");
128072
+ if (!visible.length) return null;
128073
+ const toolCount = visible.filter((s) => s.kind === "tool").length;
128020
128074
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
128021
128075
  className: "flex justify-start mb-2",
128022
128076
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
128023
128077
  className: "max-w-[85%] md:max-w-[75%] min-w-0 space-y-1.5",
128024
- children: [steps.map((step) => {
128078
+ children: [visible.map((step) => {
128079
+ if (step.kind === "narration") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
128080
+ className: "text-sm leading-relaxed whitespace-pre-wrap break-words text-foreground/90 min-w-0",
128081
+ children: step.text
128082
+ }, step.id);
128025
128083
  if (step.kind === "reasoning") return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
128026
128084
  className: "flex items-start gap-1.5 text-xs leading-relaxed text-muted-foreground/80",
128027
128085
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Brain, { className: "w-3.5 h-3.5 mt-0.5 shrink-0 opacity-70" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
@@ -128826,7 +128884,7 @@ var MessageBubble = (0, import_react.memo)(function MessageBubble({ msg, peer, o
128826
128884
  function sessionActivityStep(msg) {
128827
128885
  if (msg.kind !== "session") return null;
128828
128886
  const s = msg.payload?.session;
128829
- if (!s || s.type !== "reasoning" && s.type !== "tool") return null;
128887
+ if (!s || s.type !== "narration" && s.type !== "reasoning" && s.type !== "tool") return null;
128830
128888
  return {
128831
128889
  id: msg.id,
128832
128890
  kind: s.type,
@@ -128911,10 +128969,17 @@ function buildTimeline(messages, statusEvents, createdAt, importedHistory = [])
128911
128969
  }
128912
128970
  });
128913
128971
  }
128972
+ const responseKeys = /* @__PURE__ */ new Set();
128973
+ for (const msg of messages) {
128974
+ if (msg.kind !== "session") continue;
128975
+ const s = msg.payload?.session;
128976
+ if (s?.type === "response" && typeof s.turnId === "string") responseKeys.add(`${s.turnId} ${msg.body.trim()}`);
128977
+ }
128914
128978
  for (const msg of messages) {
128915
128979
  if (isReactionEvent(msg)) continue;
128916
128980
  const step = sessionActivityStep(msg);
128917
128981
  if (step) {
128982
+ if (step.kind === "narration" && step.turnId && responseKeys.has(`${step.turnId} ${step.text.trim()}`)) continue;
128918
128983
  raw.push({
128919
128984
  ts: step.ts,
128920
128985
  entry: {
@@ -129112,6 +129177,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129112
129177
  const showError = useRelayStore((s) => s.showError);
129113
129178
  const orchestrators = useRelayStore((s) => s.orchestrators);
129114
129179
  const fetchOrchestrators = useRelayStore((s) => s.fetchOrchestrators);
129180
+ const showReasoning = useRelayStore((s) => s.showReasoning);
129115
129181
  const voiceTtsEnabled = useRelayStore((s) => s.voiceTtsEnabled);
129116
129182
  const setVoiceTtsEnabled = useRelayStore((s) => s.setVoiceTtsEnabled);
129117
129183
  const voiceInputMode = useRelayStore((s) => s.voiceInputMode);
@@ -129689,7 +129755,10 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129689
129755
  onPreviewReferencedPath: previewReferencedFile,
129690
129756
  onPreviewReferencedPathEnd: scheduleCloseReferencedFilePreview
129691
129757
  }, entry.msg.id);
129692
- if (entry.type === "activity") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ActivityTrace, { steps: entry.steps }, `act-${entry.steps[0]?.id ?? entry.timestamp}`);
129758
+ if (entry.type === "activity") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ActivityTrace, {
129759
+ steps: entry.steps,
129760
+ showReasoning
129761
+ }, `act-${entry.steps[0]?.id ?? entry.timestamp}`);
129693
129762
  if (entry.type === "import-boundary") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ImportedHistoryMarker, { history: entry.history }, `import-${entry.history.id}`);
129694
129763
  if (entry.type === "imported-message") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ImportedMessageBubble, {
129695
129764
  entry: entry.entry,
@@ -156013,6 +156082,7 @@ function SettingsView() {
156013
156082
  ]
156014
156083
  })]
156015
156084
  }),
156085
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChatDisplaySettings, {}),
156016
156086
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(WorkspaceSettings, {}),
156017
156087
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StewardSettings, {}),
156018
156088
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VoiceSettings, {})
@@ -156087,6 +156157,29 @@ function WorkspaceSettings() {
156087
156157
  ]
156088
156158
  });
156089
156159
  }
156160
+ function ChatDisplaySettings() {
156161
+ const showReasoning = useRelayStore((s) => s.showReasoning);
156162
+ const set = useRelayStore((s) => s.set);
156163
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("section", {
156164
+ className: "space-y-3 rounded-lg border p-4",
156165
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
156166
+ className: "flex items-center justify-between gap-2",
156167
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
156168
+ className: "flex items-center gap-2",
156169
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Brain, { className: "w-4 h-4" }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", {
156170
+ className: "text-sm font-semibold",
156171
+ children: "Show reasoning details"
156172
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
156173
+ className: "text-xs text-muted-foreground",
156174
+ children: "Show the agent's internal thinking inline in chat, alongside its narration and tool steps. Off by default — narration is always shown either way."
156175
+ })] })]
156176
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Switch, {
156177
+ checked: showReasoning,
156178
+ onCheckedChange: (v) => set({ showReasoning: v })
156179
+ })]
156180
+ })
156181
+ });
156182
+ }
156090
156183
  function VoiceSettings() {
156091
156184
  const voiceTtsEnabled = useRelayStore((s) => s.voiceTtsEnabled);
156092
156185
  const setVoiceTtsEnabled = useRelayStore((s) => s.setVoiceTtsEnabled);
@@ -164034,6 +164127,16 @@ if ("serviceWorker" in navigator) {
164034
164127
  }
164035
164128
  }
164036
164129
 
164130
+ .text-foreground\/90 {
164131
+ color: var(--foreground);
164132
+ }
164133
+
164134
+ @supports (color: color-mix(in lab, red, red)) {
164135
+ .text-foreground\/90 {
164136
+ color: color-mix(in oklab, var(--foreground) 90%, transparent);
164137
+ }
164138
+ }
164139
+
164037
164140
  .text-green-400 {
164038
164141
  color: var(--color-green-400);
164039
164142
  }
@@ -211,9 +211,19 @@ function isPersistedRelayMessage(message: Message): boolean {
211
211
  return Number.isSafeInteger(message.id) && message.id > 0;
212
212
  }
213
213
 
214
+ // #283 — one-line nudge that replaces the reply-scaffold footer for notification-class
215
+ // (replyExpected:false) messages. Deliberately tiny so a bloated context can't drown the
216
+ // no-reply rule established at session start. Shared with the Claude delivery path.
217
+ export const NOTIFICATION_NUDGE = "↪ Notification — no reply needed.";
218
+
219
+ // A notification is a persisted message the server marked replyExpected:false.
220
+ export function isNotificationMessage(message: Message): boolean {
221
+ return isPersistedRelayMessage(message) && message.replyExpected === false;
222
+ }
223
+
214
224
  function latestReplyableMessage(messages: Message[]): Message | undefined {
215
225
  return messages
216
- .filter((message) => isPersistedRelayMessage(message) && !isMemoryInjection(message) && !isReactionNotification(message))
226
+ .filter((message) => isPersistedRelayMessage(message) && !isMemoryInjection(message) && !isReactionNotification(message) && message.replyExpected !== false)
217
227
  .at(-1);
218
228
  }
219
229
 
@@ -316,6 +326,9 @@ export function providerMessageText(messages: Message[]): string {
316
326
  "If you already delivered the useful response through Relay, do not send a separate status-only confirmation.",
317
327
  "If multiple messages arrived together, cover them in one reply instead of answering each line separately.",
318
328
  ].join("\n"));
329
+ } else if (messages.some(isNotificationMessage)) {
330
+ // #283 — pure notification batch: no scaffold, just the one-line no-reply nudge.
331
+ sections.push(NOTIFICATION_NUDGE);
319
332
  }
320
333
  return sections.join("\n\n");
321
334
  }
@@ -78,6 +78,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
78
78
  subject: "Your branch landed",
79
79
  body: `✅ ${branchLabel} landed on \`${base}\`${shaLabel}${subjectLabel}.${continueLabel}`,
80
80
  payload,
81
+ replyExpected: false,
81
82
  });
82
83
  }
83
84
 
@@ -93,6 +94,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
93
94
  subject: `Merged to ${base}`,
94
95
  body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.`,
95
96
  payload,
97
+ replyExpected: false,
96
98
  });
97
99
  }
98
100
  }
package/src/db.ts CHANGED
@@ -226,6 +226,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
226
226
  body TEXT NOT NULL,
227
227
  thread_id INTEGER,
228
228
  reply_to INTEGER REFERENCES messages(id),
229
+ reply_expected INTEGER NOT NULL DEFAULT 1,
229
230
  claimable INTEGER NOT NULL DEFAULT 0,
230
231
  claimed_by TEXT,
231
232
  claimed_at INTEGER,
@@ -857,6 +858,9 @@ export function initDb(path: string = "agent-relay.db"): Database {
857
858
  db.run("ALTER TABLE messages ADD COLUMN thread_id INTEGER");
858
859
  db.run("ALTER TABLE messages ADD COLUMN reply_to INTEGER REFERENCES messages(id)");
859
860
  }
861
+ if (!colNames.includes("reply_expected")) {
862
+ db.run("ALTER TABLE messages ADD COLUMN reply_expected INTEGER NOT NULL DEFAULT 1");
863
+ }
860
864
  if (!colNames.includes("claimable")) {
861
865
  db.run("ALTER TABLE messages ADD COLUMN claimable INTEGER NOT NULL DEFAULT 0");
862
866
  db.run("ALTER TABLE messages ADD COLUMN claimed_by TEXT");
@@ -1292,6 +1296,9 @@ function rowToMessage(row: any): Message {
1292
1296
  body: row.body,
1293
1297
  threadId: row.thread_id ?? undefined,
1294
1298
  replyTo: row.reply_to ?? undefined,
1299
+ // Default (true) stays absent to match the `claimable` idiom and keep notification-free
1300
+ // messages byte-identical on the wire; only an explicit notification surfaces false (#283).
1301
+ replyExpected: row.reply_expected === 0 ? false : undefined,
1295
1302
  claimable: row.claimable === 1 ? true : undefined,
1296
1303
  claimedBy: row.claimed_by ?? undefined,
1297
1304
  claimedAt: row.claimed_at ?? undefined,
@@ -3794,12 +3801,12 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
3794
3801
 
3795
3802
  const insert = db.query(`
3796
3803
  INSERT INTO messages (
3797
- from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable,
3804
+ from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, reply_expected, claimable,
3798
3805
  idempotency_key, delivery_status, queued_at, max_age_seconds, resolved_to_agent,
3799
3806
  payload, meta, created_at, occurred_at
3800
3807
  )
3801
3808
  VALUES (
3802
- $from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $claimable,
3809
+ $from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $replyExpected, $claimable,
3803
3810
  $idempotencyKey, $deliveryStatus, $queuedAt, $maxAgeSeconds, $resolvedToAgent,
3804
3811
  $payload, $meta, $now, $occurredAt
3805
3812
  )
@@ -3833,6 +3840,9 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
3833
3840
  $body: input.body,
3834
3841
  $threadId: threadId,
3835
3842
  $replyTo: input.replyTo ?? null,
3843
+ // Server-owned reply obligation (#283): true by default; only an explicit false marks
3844
+ // a notification. Stored 0/1 so the footer renderer + reply tracker key off one column.
3845
+ $replyExpected: input.replyExpected === false ? 0 : 1,
3836
3846
  $claimable: claimable ? 1 : 0,
3837
3847
  $idempotencyKey: input.idempotencyKey ?? null,
3838
3848
  $deliveryStatus: deliveryStatus,
@@ -4318,6 +4328,9 @@ export function pollMessages(query: PollQuery): Message[] {
4318
4328
  }
4319
4329
 
4320
4330
  function messageRequiresReply(message: Message): boolean {
4331
+ // Server-owned notification flag (#283) wins over every kind/sender heuristic below: an
4332
+ // explicit replyExpected:false is a fire-and-forget message that must never become an obligation.
4333
+ if (message.replyExpected === false) return false;
4321
4334
  if (message.kind === "system" || message.kind === "control" || message.kind === "session") return false;
4322
4335
  if (message.from === "user") return true;
4323
4336
  if (message.kind === "task" || message.kind === "channel.event") return true;
package/src/mcp.ts CHANGED
@@ -247,19 +247,19 @@ const TOOLS: ToolDefinition[] = [
247
247
  },
248
248
  {
249
249
  name: "relay_spawn_agent",
250
- description: "Spawn a long-living provider agent through Relay's orchestrator. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).",
250
+ description: "Spawn a long-living provider agent through Relay's orchestrator, optionally handing it its first task via `prompt` in the same call. Defaults to your own host (override with orchestratorId) and returns the resolved agent id once it registers. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).",
251
251
  requiredScopes: ["command:spawn"],
252
252
  inputSchema: {
253
253
  type: "object",
254
254
  properties: {
255
255
  provider: { type: "string", enum: SPAWN_PROVIDERS },
256
- orchestratorId: { type: "string" },
257
- cwd: { type: "string" },
256
+ orchestratorId: { type: "string", description: "Target host. Defaults to the host that owns cwd, else YOUR OWN host — only set it to spawn onto a different machine." },
257
+ cwd: { type: "string", description: "Working directory for the agent. Must resolve within the target orchestrator's base directory (enforced server-side)." },
258
258
  label: { type: "string" },
259
259
  model: { type: "string" },
260
260
  effort: { type: "string", enum: VALID_EFFORTS },
261
261
  approvalMode: { type: "string", enum: APPROVAL_MODES },
262
- prompt: { type: "string" },
262
+ prompt: { type: "string", description: "Initial task/message delivered to the agent on launch — spawn and hand it its first instruction in one call (no separate follow-up message needed)." },
263
263
  systemPromptAppend: { type: "string" },
264
264
  profile: { type: "string", description: "Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota)." },
265
265
  tags: { type: "array", items: { type: "string" } },
@@ -267,6 +267,7 @@ const TOOLS: ToolDefinition[] = [
267
267
  providerArgs: { type: "array", items: { type: "string" } },
268
268
  policyName: { type: "string" },
269
269
  spawnRequestId: { type: "string" },
270
+ waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000, description: "How long to wait for the spawned agent to register before returning, so the response carries its resolved agent id (default 8000; 0 = return immediately with just spawnRequestId)." },
270
271
  },
271
272
  required: ["provider"],
272
273
  additionalProperties: false,
@@ -485,7 +486,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
485
486
  else if (name === "relay_agent_status") result = relayAgentStatus(args);
486
487
  else if (name === "relay_find_agents") result = relayFindAgents(auth, args);
487
488
  else if (name === "relay_whoami") result = relayWhoami(auth);
488
- else if (name === "relay_spawn_agent") result = relaySpawnAgent(auth, args);
489
+ else if (name === "relay_spawn_agent") result = await relaySpawnAgent(auth, args);
489
490
  else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
490
491
  else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);
491
492
  else if (name === "relay_workspace_list") result = relayWorkspaceList(auth, args);
@@ -763,10 +764,12 @@ function relayFindAgents(auth: McpAuthContext, args: Record<string, unknown>): R
763
764
  return { agents, count: agents.length };
764
765
  }
765
766
 
766
- function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
767
+ async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
767
768
  const provider = enumField(args.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider;
768
769
  const cwd = optionalString(args.cwd, "cwd", 500);
769
- const orchestrator = selectSpawnOrchestrator(provider, optionalString(args.orchestratorId, "orchestratorId", 200), cwd);
770
+ const callerId = callerAgentId(auth);
771
+ const preferHost = callerId ? getAgent(callerId)?.machine : undefined;
772
+ const orchestrator = selectSpawnOrchestrator(provider, optionalString(args.orchestratorId, "orchestratorId", 200), cwd, preferHost);
770
773
  const resolvedCwd = cwd || orchestrator.baseDir;
771
774
  if (cwd && !isPathWithinBase(cwd, orchestrator.baseDir)) {
772
775
  throw new ValidationError(`cwd must be within orchestrator base directory: ${orchestrator.baseDir}`);
@@ -781,7 +784,6 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
781
784
  // #221 runtime gate (belt; the coarse `command:spawn` scope is enforced in callTool, and is
782
785
  // granted only to agents whose profile sets maxSpawnedAgents>0 and never to children).
783
786
  // Server/admin tokens have no caller identity → unrestricted by design.
784
- const callerId = callerAgentId(auth);
785
787
  if (callerId) {
786
788
  const me = getAgent(callerId);
787
789
  if (me?.spawnedBy) {
@@ -841,7 +843,27 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
841
843
  }),
842
844
  });
843
845
  emitCommand(command);
844
- return { ok: true, orchestratorId: orchestrator.id, provider, command };
846
+
847
+ // #255: resolve the spawned agent id once it registers. Spawn is a fire-and-forget command
848
+ // over the bus; the child registers back to THIS relay (same DB) with meta.spawnRequestId set,
849
+ // so a bounded poll links the request to the agent without a separate relay_find_agents round
850
+ // trip. waitForRegistrationMs:0 opts out (pure fire-and-forget); the default is short because
851
+ // isolated-worktree spawns register near-instantly (symlinked deps).
852
+ const waitMs = Math.min(optionalNonNegativeInt(args.waitForRegistrationMs, "waitForRegistrationMs") ?? 8000, 30000);
853
+ const agentId = waitMs > 0 ? await waitForSpawnedAgent(spawnRequestId, waitMs) : null;
854
+ return { ok: true, spawnRequestId, orchestratorId: orchestrator.id, provider, agentId, registered: agentId !== null, command };
855
+ }
856
+
857
+ // Poll the agents table for the child that registers with this spawnRequestId (#255). Returns
858
+ // the resolved agent id, or null on timeout (the caller still has spawnRequestId to poll later).
859
+ async function waitForSpawnedAgent(spawnRequestId: string, timeoutMs: number, pollMs = 300): Promise<string | null> {
860
+ const deadline = Date.now() + timeoutMs;
861
+ for (;;) {
862
+ const match = listAgents().find((a) => a.meta?.spawnRequestId === spawnRequestId);
863
+ if (match) return match.id;
864
+ if (Date.now() >= deadline) return null;
865
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
866
+ }
845
867
  }
846
868
 
847
869
  function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
@@ -1062,7 +1084,12 @@ function policyStatusPayload(policy: NonNullable<ReturnType<typeof getSpawnPolic
1062
1084
  };
1063
1085
  }
1064
1086
 
1065
- function selectSpawnOrchestrator(provider: SpawnProvider, orchestratorId?: string, cwd?: string): NonNullable<ReturnType<typeof getOrchestrator>> {
1087
+ function selectSpawnOrchestrator(
1088
+ provider: SpawnProvider,
1089
+ orchestratorId?: string,
1090
+ cwd?: string,
1091
+ preferHost?: string,
1092
+ ): NonNullable<ReturnType<typeof getOrchestrator>> {
1066
1093
  if (orchestratorId) {
1067
1094
  const orchestrator = getOrchestrator(orchestratorId);
1068
1095
  if (!orchestrator) throw new McpNotFoundError(`orchestrator ${orchestratorId} not found`);
@@ -1075,6 +1102,14 @@ function selectSpawnOrchestrator(provider: SpawnProvider, orchestratorId?: strin
1075
1102
  const match = candidates.find((item) => isPathWithinBase(cwd, item.baseDir));
1076
1103
  if (match) return match;
1077
1104
  }
1105
+ // #255: with neither an explicit id nor a cwd to pin the host, default to the CALLER's own
1106
+ // host instead of silently grabbing candidates[0] (a foreign host whose baseDir would then
1107
+ // reject the caller's cwd — the footgun the spawn recipe warned about). An agent's `machine`
1108
+ // is its OS hostname; match it against the orchestrator hostname (or id, defensively).
1109
+ if (preferHost) {
1110
+ const own = candidates.find((item) => item.hostname === preferHost || item.id === preferHost);
1111
+ if (own) return own;
1112
+ }
1078
1113
  const orchestrator = candidates[0];
1079
1114
  if (!orchestrator) throw new McpNotFoundError(`no orchestrator available for provider: ${provider}`);
1080
1115
  return orchestrator;
@@ -1327,6 +1362,12 @@ function optionalPositiveInt(value: unknown, field: string): number | undefined
1327
1362
  return value;
1328
1363
  }
1329
1364
 
1365
+ function optionalNonNegativeInt(value: unknown, field: string): number | undefined {
1366
+ if (value === undefined || value === null) return undefined;
1367
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new ValidationError(`${field} must be a non-negative integer`);
1368
+ return value;
1369
+ }
1370
+
1330
1371
  function optionalFutureTimestamp(value: unknown, field: string): number | undefined {
1331
1372
  const timestamp = optionalPositiveInt(value, field);
1332
1373
  if (timestamp !== undefined && timestamp <= Date.now()) throw new ValidationError(`${field} must be a future unix timestamp in milliseconds`);
package/src/notify.ts CHANGED
@@ -10,6 +10,13 @@ export interface SystemNotifyOptions {
10
10
  kind?: MessageKind;
11
11
  /** Sender id; defaults to "system". */
12
12
  from?: string;
13
+ /**
14
+ * #283 — set false for a fire-and-forget notification (merge notice, lifecycle event): the
15
+ * server suppresses the reply-scaffold footer and the reply-obligation tracker skips it.
16
+ * Omit (default true) for system messages that genuinely want the agent to act/answer
17
+ * (steward task assignments, conflict handoffs).
18
+ */
19
+ replyExpected?: boolean;
13
20
  }
14
21
 
15
22
  /**
@@ -25,6 +32,7 @@ export function notifySystemMessage(to: string, opts: SystemNotifyOptions): Mess
25
32
  subject: opts.subject,
26
33
  body: opts.body,
27
34
  payload: opts.payload,
35
+ replyExpected: opts.replyExpected,
28
36
  });
29
37
  emitNewMessage(msg);
30
38
  return msg;
package/src/routes.ts CHANGED
@@ -520,6 +520,9 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
520
520
  if (body.claimable !== undefined && typeof body.claimable !== "boolean") {
521
521
  throw new ValidationError("claimable must be a boolean");
522
522
  }
523
+ if (body.replyExpected !== undefined && typeof body.replyExpected !== "boolean") {
524
+ throw new ValidationError("replyExpected must be a boolean");
525
+ }
523
526
 
524
527
  const input: SendMessageInput = {
525
528
  from: cleanString(body.from, "from", { required: true, max: 200 })!,
@@ -527,6 +530,7 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
527
530
  body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
528
531
  kind: kind as SendMessageInput["kind"] | undefined,
529
532
  replyTo: cleanPositiveId(body.replyTo, "replyTo"),
533
+ replyExpected: body.replyExpected as boolean | undefined,
530
534
  claimable: body.claimable as boolean | undefined,
531
535
  idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }),
532
536
  };