agent-relay 11.11.0 → 12.1.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.
Files changed (49) hide show
  1. package/README.md +64 -0
  2. package/dist/cli/agent-relay-mcp.d.ts.map +1 -1
  3. package/dist/cli/agent-relay-mcp.js +206 -54
  4. package/dist/cli/agent-relay-mcp.js.map +1 -1
  5. package/dist/cli/commands/core.d.ts +8 -0
  6. package/dist/cli/commands/core.d.ts.map +1 -1
  7. package/dist/cli/commands/core.js +4 -0
  8. package/dist/cli/commands/core.js.map +1 -1
  9. package/dist/cli/commands/fleet-agent.d.ts +5 -1
  10. package/dist/cli/commands/fleet-agent.d.ts.map +1 -1
  11. package/dist/cli/commands/fleet-agent.js +114 -26
  12. package/dist/cli/commands/fleet-agent.js.map +1 -1
  13. package/dist/cli/commands/fleet.d.ts.map +1 -1
  14. package/dist/cli/commands/fleet.js +93 -13
  15. package/dist/cli/commands/fleet.js.map +1 -1
  16. package/dist/cli/commands/integration-recipient.d.ts +21 -0
  17. package/dist/cli/commands/integration-recipient.d.ts.map +1 -0
  18. package/dist/cli/commands/integration-recipient.js +115 -0
  19. package/dist/cli/commands/integration-recipient.js.map +1 -0
  20. package/dist/cli/commands/integration.d.ts +3 -0
  21. package/dist/cli/commands/integration.d.ts.map +1 -1
  22. package/dist/cli/commands/integration.js +69 -30
  23. package/dist/cli/commands/integration.js.map +1 -1
  24. package/dist/cli/commands/node.js +4 -0
  25. package/dist/cli/commands/node.js.map +1 -1
  26. package/dist/cli/commands/status.d.ts +1 -1
  27. package/dist/cli/commands/status.d.ts.map +1 -1
  28. package/dist/cli/commands/status.js +7 -3
  29. package/dist/cli/commands/status.js.map +1 -1
  30. package/dist/cli/lib/broker-lifecycle.d.ts +2 -0
  31. package/dist/cli/lib/broker-lifecycle.d.ts.map +1 -1
  32. package/dist/cli/lib/broker-lifecycle.js +232 -154
  33. package/dist/cli/lib/broker-lifecycle.js.map +1 -1
  34. package/dist/cli/lib/broker-process-identity.d.ts +30 -0
  35. package/dist/cli/lib/broker-process-identity.d.ts.map +1 -0
  36. package/dist/cli/lib/broker-process-identity.js +229 -0
  37. package/dist/cli/lib/broker-process-identity.js.map +1 -0
  38. package/dist/cli/lib/sdk-client.d.ts.map +1 -1
  39. package/dist/cli/lib/sdk-client.js +8 -0
  40. package/dist/cli/lib/sdk-client.js.map +1 -1
  41. package/dist/cli/lib/sdk-command.d.ts.map +1 -1
  42. package/dist/cli/lib/sdk-command.js +20 -2
  43. package/dist/cli/lib/sdk-command.js.map +1 -1
  44. package/dist/cli/lib/spawn-lifecycle.d.ts +12 -0
  45. package/dist/cli/lib/spawn-lifecycle.d.ts.map +1 -0
  46. package/dist/cli/lib/spawn-lifecycle.js +89 -0
  47. package/dist/cli/lib/spawn-lifecycle.js.map +1 -0
  48. package/dist/index.cjs +156 -23
  49. package/package.json +9 -9
package/README.md CHANGED
@@ -48,6 +48,70 @@ agent-relay node agent release <name>
48
48
 
49
49
  For AI SDK native harnesses, attach renders structured activity, text, tools, approvals, files, usage, and lifecycle events. Add `--json` for NDJSON, `--reasoning` for reasoning events, or `--diagnostics` for sidecar diagnostics. Native harness `drive` is line-oriented and acknowledged; native harness `passthrough` is unsupported because no terminal stream exists. PTY attach behavior is unchanged.
50
50
 
51
+ ### Local operation during a Relaycast outage
52
+
53
+ ```bash
54
+ agent-relay node up --local-only
55
+ agent-relay node status
56
+ agent-relay node agent spawn claude --runtime pty
57
+ agent-relay node agent attach <name> --mode view
58
+ ```
59
+
60
+ `--local-only` deliberately starts a **DEGRADED** broker without waiting for
61
+ Relaycast. Startup, `/health`, `/api/status`, and `node status` report that mode.
62
+ The authenticated `/api/session` exposes `operation_mode: "local_only"` and
63
+ `degraded: true`; its existing `mode` still describes persistence.
64
+ The standalone broker accepts `init --local-only --persist`; SDK callers can
65
+ set `AGENT_RELAY_LOCAL_ONLY=1` and enable persistence. The API must bind to a
66
+ loopback IP, and the mode supports a single workspace key.
67
+
68
+ Local spawn, terminal view/input, and the durable automatic delivery queue
69
+ remain available. `POST /api/send` accepts only a worker currently running on
70
+ this broker; channel, cross-workspace, and remote destinations are rejected.
71
+ It reports `delivery_status: "queued_local"`, `local: true`, and
72
+ `relaycast_published: false`. Acceptance means the work was saved, not that an
73
+ agent has read it. Pending work survives restart and waits for an absent local
74
+ recipient to respawn without exhausting retries. A restarted recipient gets a
75
+ fresh transport retry budget. Explicit release and exhausted transport failures
76
+ while the recipient is present still use the dead-letter lifecycle. Manual-flush mode is
77
+ unavailable and returns `capability_disabled` explicitly.
78
+
79
+ Fleet routing, worker presence, remote terminal attachment, node capability
80
+ providers, and injected Relaycast messaging tools are disabled. Local agents
81
+ receive a degraded-mode notice. Their model provider and any tools they
82
+ configure themselves still have their own connectivity requirements.
83
+
84
+ When a workspace key is configured through the normal workspace selection,
85
+ the broker retries an independent audit connection in the background. Audit
86
+ endpoints require HTTPS, with HTTP allowed only for literal loopback IPs used
87
+ in local development. Audit records never follow HTTP redirects. Queued
88
+ local delivery records are persisted in `state-<name>.local-outbox.json` beside
89
+ broker state, then reconciled as `local.delivery.queued` events under a separate
90
+ broker audit identity when Relaycast responds. These events contain the
91
+ original delivery/event IDs, sender, recipient, body, and queue timestamp.
92
+ They record local acceptance, not model completion. They are **audit replay**,
93
+ not re-sent messages: replay must never execute the work twice or address a
94
+ local worker name on another machine. Reconciliation is at least once; consumers
95
+ can deduplicate by `event_id` after an ambiguous response or crash.
96
+
97
+ `node status` reports the reconciliation backlog and the last connection
98
+ result. Without a workspace key, local work still runs and records remain on
99
+ disk with no upload destination; no workspace is created. A nonempty unscoped
100
+ backlog cannot acquire a destination on restart. Keep its state directory and
101
+ continue without a key, or select a different state directory for new work
102
+ with a configured destination. The outbox
103
+ is bounded to 10,000 records / 32 MiB and rejects new sends when full. A digest
104
+ pins a configured backlog to its original workspace key and Relaycast base URL;
105
+ restore that configuration to drain it before rotating keys or changing the
106
+ destination. Corrupt outboxes cause an explicit startup failure rather than
107
+ being discarded. Preserve the state directory until reconciliation completes.
108
+
109
+ Recovery never silently enables fleet capabilities. Stop the broker and start
110
+ normally (without the flag or environment opt-in) to enable them; a normal
111
+ restart drains retained audit backlog only when its configured destination and
112
+ digest match; an unscoped backlog remains on disk. Restart local workers as needed
113
+ to give them Relaycast messaging tools and registered identities.
114
+
51
115
  ### Workspace binding and recovery
52
116
 
53
117
  `agent-relay up` and `agent-relay node up` resolve the workspace through one
@@ -1 +1 @@
1
- {"version":3,"file":"agent-relay-mcp.d.ts","sourceRoot":"","sources":["../../src/cli/agent-relay-mcp.ts"],"names":[],"mappings":";AAOA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA4CpE,OAAO,KAAK,EAEV,0BAA0B,EAC1B,SAAS,EAET,mBAAmB,EACnB,aAAa,EACb,aAAa,EAEd,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,0BAA0B,EAAE,MAAM,gBAAgB,CAAC;AAEjE,eAAO,MAAM,uBAAuB,QACuC,CAAC;AA4I5E,eAAO,MAAM,4BAA4B,unEAgCF,CAAC;AAWxC,KAAK,2BAA2B,GAAG;IACjC,OAAO,EAAE,mBAAmB,CAAC;IAC7B,UAAU,EAAE,aAAa,CAAC;IAC1B,QAAQ,EAAE,MAAM,aAAa,CAAC;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,eAAe,CAAC,EAAE,SAAS,CAAC;IAC5B,gFAAgF;IAChF,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC,CAAC;AAcF;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CASrE;AAgCD,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAOjE;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAMnF;AAsID,wBAAsB,uBAAuB,CAAC,EAC5C,OAAO,EACP,UAAU,EACV,QAAQ,EACR,IAAI,EACJ,IAAI,EACJ,OAAO,EACP,QAAQ,EACR,cAAc,EACd,eAAe,EACf,kBAAkB,EAClB,eAAe,EACf,qBAAqB,GACtB,EAAE,2BAA2B,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CA+FhE;AAurBD,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,0BAA0B,GAAG,SAAS,CA6MxF;AASD,wBAAsB,4BAA4B,CAChD,OAAO,EAAE,0BAA0B,GAClC,OAAO,CAAC,0BAA0B,CAAC,CA0BrC;AAED,wBAAsB,uBAAuB,CAAC,OAAO,EAAE,0BAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAShG;AAED,wBAAgB,cAAc,IAAI,0BAA0B,CAsC3D"}
1
+ {"version":3,"file":"agent-relay-mcp.d.ts","sourceRoot":"","sources":["../../src/cli/agent-relay-mcp.ts"],"names":[],"mappings":";AAOA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAmDpE,OAAO,KAAK,EAEV,0BAA0B,EAC1B,SAAS,EAET,mBAAmB,EACnB,aAAa,EACb,aAAa,EAEd,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,0BAA0B,EAAE,MAAM,gBAAgB,CAAC;AAEjE,eAAO,MAAM,uBAAuB,QACuC,CAAC;AA2T5E,eAAO,MAAM,4BAA4B,unEAgCF,CAAC;AAWxC,KAAK,2BAA2B,GAAG;IACjC,OAAO,EAAE,mBAAmB,CAAC;IAC7B,UAAU,EAAE,aAAa,CAAC;IAC1B,QAAQ,EAAE,MAAM,aAAa,CAAC;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,eAAe,CAAC,EAAE,SAAS,CAAC;IAC5B,gFAAgF;IAChF,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC,CAAC;AAcF;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CASrE;AAgCD,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAOjE;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAMnF;AAsID,wBAAsB,uBAAuB,CAAC,EAC5C,OAAO,EACP,UAAU,EACV,QAAQ,EACR,IAAI,EACJ,IAAI,EACJ,OAAO,EACP,QAAQ,EACR,cAAc,EACd,eAAe,EACf,kBAAkB,EAClB,eAAe,EACf,qBAAqB,GACtB,EAAE,2BAA2B,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CA+FhE;AAysBD,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,0BAA0B,GAAG,SAAS,CA6MxF;AASD,wBAAsB,4BAA4B,CAChD,OAAO,EAAE,0BAA0B,GAClC,OAAO,CAAC,0BAA0B,CAAC,CA0BrC;AAED,wBAAsB,uBAAuB,CAAC,OAAO,EAAE,0BAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAShG;AAED,wBAAgB,cAAc,IAAI,0BAA0B,CAsC3D"}
@@ -6,11 +6,12 @@ import { isDeepStrictEqual } from 'node:util';
6
6
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
8
  import { ListToolsRequestSchema, SubscribeRequestSchema, UnsubscribeRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
9
- import { AgentRelay, RELAYCAST_SDK_VERSION, createAgentClient, createObserverToken, createRealtimeClient, createWorkspaceClient, isInvalidAgentTokenError, } from '@agent-relay/sdk';
9
+ import { AgentRelay, RELAYCAST_SDK_VERSION, createAgentClient, createObserverToken, createRealtimeClient, createWorkspaceClient, isInvalidAgentTokenError, safeRelayErrorMessage, } from '@agent-relay/sdk';
10
10
  import { z } from 'zod';
11
11
  import { declaredWorkforceMetadata } from './lib/registration-metadata.js';
12
12
  import { DEFAULT_AGENT_REGISTRATION_TIMEOUT_MS, withAgentRegistrationDeadline, withDeadline, } from './lib/agent-registration.js';
13
13
  import { attributableReleaseReason } from './lib/release-reason.js';
14
+ import { sanitizedSpawnReceipt, spawnPlacementReceipt, } from './lib/spawn-lifecycle.js';
14
15
  import { initTelemetry, shutdown as shutdownTelemetry } from './telemetry/index.js';
15
16
  import { RealtimeResourceBridge, SubscriptionManager, registerResourceDefinitions } from './mcp/resources.js';
16
17
  import { jsonContent, jsonResult, textContent } from './mcp/tool-results.js';
@@ -28,11 +29,30 @@ const EXIT_AFTER_TASK_INSTRUCTION = '## Post-task exit\n' +
28
29
  function withExitAfterTaskInstruction(task) {
29
30
  return `${task}\n\n${EXIT_AFTER_TASK_INSTRUCTION}`;
30
31
  }
31
- const PERSONA_SPAWN_TIMEOUT_MS = 130_000;
32
- const PERSONA_SPAWN_POLL_MS = 250;
33
- const PERSONA_SPAWN_TIMEOUT_MESSAGE = 'Persona spawn timed out before broker registration and harness readiness.';
34
- const PERSONA_SPAWN_SUCCESS_STATUSES = new Set(['completed', 'succeeded', 'success']);
35
- const PERSONA_SPAWN_FAILURE_STATUSES = new Set(['failed', 'error', 'cancelled', 'canceled']);
32
+ const VERIFIED_SPAWN_TIMEOUT_MS = 130_000;
33
+ const VERIFIED_SPAWN_POLL_MS = 250;
34
+ const VERIFIED_SPAWN_TIMEOUT_MESSAGE = 'Spawn timed out before broker registration and harness readiness.';
35
+ const VERIFIED_SPAWN_MISSING_READY_MESSAGE = 'Spawn completed without broker registration and harness readiness proof. The resolved spawn handler must honor top-level verify_ready and return spawned:true and ready:true. For a broker handler, use a release containing Relay PR #1708.';
36
+ const VERIFIED_SPAWN_SUCCESS_STATUSES = new Set(['completed', 'succeeded', 'success']);
37
+ const VERIFIED_SPAWN_FAILURE_STATUSES = new Set(['failed', 'error', 'cancelled', 'canceled', 'denied']);
38
+ class VerifiedSpawnError extends Error {
39
+ code;
40
+ invocationId;
41
+ state;
42
+ dispatchState;
43
+ node;
44
+ receipt;
45
+ constructor(message, context) {
46
+ super(message);
47
+ this.name = 'VerifiedSpawnError';
48
+ this.code = context.code ?? (context.state === 'failed' ? 'spawn_failed' : 'spawn_unconfirmed');
49
+ this.state = context.state;
50
+ this.dispatchState = context.dispatchState ?? 'unknown';
51
+ this.invocationId = context.invocationId;
52
+ this.node = context.node;
53
+ this.receipt = context.receipt;
54
+ }
55
+ }
36
56
  function recordValue(value) {
37
57
  return value !== null && typeof value === 'object' && !Array.isArray(value)
38
58
  ? value
@@ -47,9 +67,59 @@ function invocationRef(value) {
47
67
  const invocationId = invocationText(record, 'invocationId', 'invocation_id');
48
68
  if (!invocationId)
49
69
  return undefined;
70
+ const node = invocationText(record, 'handlerNodeId', 'handler_node_id') ??
71
+ invocationText(record, 'dispatchedNodeId', 'dispatched_node_id');
50
72
  return {
51
73
  invocationId,
52
74
  actionName: invocationText(record, 'actionName', 'action_name') ?? 'spawn',
75
+ ...(node ? { node } : {}),
76
+ dispatchState: dispatchStateFor(record),
77
+ };
78
+ }
79
+ function spawnReceipt(value) {
80
+ return spawnPlacementReceipt(value);
81
+ }
82
+ function dispatchStateFor(value, fallback = 'unknown') {
83
+ const state = spawnReceipt(value).dispatchState;
84
+ return state === 'dispatched' || state === 'not_dispatched' ? state : fallback;
85
+ }
86
+ /**
87
+ * Dispatch evidence for a *later* invocation read, given the dispatch state
88
+ * already frozen from the ack. `spawnReceipt`/`dispatchStateFor` treats a
89
+ * terminal status (e.g. `completed`) as dispatch evidence on its own — valid
90
+ * for the ack itself, where a synchronous handler really can report a
91
+ * terminal status immediately. It is not valid here: once the ack is known
92
+ * `not_dispatched` (a `pending`/`queued` ack with no node id), a later poll
93
+ * observing `completed`/`invoked` must not retroactively manufacture
94
+ * `dispatched` from that status. A node id appearing on the later record is
95
+ * real, new routing evidence and is trusted; a bare status change is not.
96
+ */
97
+ function dispatchStateForRecord(record, ackDispatchState) {
98
+ if (ackDispatchState === 'dispatched')
99
+ return 'dispatched';
100
+ const nodeId = invocationText(record, 'dispatchedNodeId', 'dispatched_node_id') ??
101
+ invocationText(record, 'handlerNodeId', 'handler_node_id');
102
+ return nodeId ? 'dispatched' : ackDispatchState;
103
+ }
104
+ function terminalSpawnFailureResult(invocation) {
105
+ const placement = spawnReceipt(invocation);
106
+ if (placement.state !== 'failed')
107
+ return undefined;
108
+ return {
109
+ ...jsonContent({
110
+ ok: false,
111
+ error: {
112
+ code: 'spawn_failed',
113
+ state: 'failed',
114
+ ...(placement.invocationId ? { invocationId: placement.invocationId } : {}),
115
+ dispatchState: dispatchStateFor(invocation),
116
+ receipt: sanitizedSpawnReceipt(invocation),
117
+ message: typeof invocation.error === 'string' && invocation.error.trim()
118
+ ? safeRelayErrorMessage(invocation.error)
119
+ : 'Fleet spawn invocation reported a terminal failure.',
120
+ },
121
+ }),
122
+ isError: true,
53
123
  };
54
124
  }
55
125
  function nestedPersonaSpawnRef(invocation) {
@@ -64,13 +134,13 @@ function nestedPersonaSpawnRef(invocation) {
64
134
  async function getInvocationBeforeDeadline(actions, current, deadline) {
65
135
  const remainingMs = deadline - Date.now();
66
136
  if (remainingMs <= 0)
67
- throw new Error(PERSONA_SPAWN_TIMEOUT_MESSAGE);
137
+ throw new Error(VERIFIED_SPAWN_TIMEOUT_MESSAGE);
68
138
  let timeout;
69
139
  try {
70
140
  return await Promise.race([
71
141
  actions.getInvocation(current.actionName, current.invocationId),
72
142
  new Promise((_resolve, reject) => {
73
- timeout = setTimeout(() => reject(new Error(PERSONA_SPAWN_TIMEOUT_MESSAGE)), remainingMs);
143
+ timeout = setTimeout(() => reject(new Error(VERIFIED_SPAWN_TIMEOUT_MESSAGE)), remainingMs);
74
144
  }),
75
145
  ]);
76
146
  }
@@ -89,46 +159,104 @@ async function pollInvocation(actions, current, deadline) {
89
159
  return await getInvocationBeforeDeadline(actions, current, deadline);
90
160
  }
91
161
  catch (error) {
92
- if (isInvocationAuthorizationError(error))
93
- throw error;
162
+ if (isInvocationAuthorizationError(error)) {
163
+ throw new VerifiedSpawnError(`Spawn confirmation could not be read after acceptance (${error instanceof Error ? error.message : String(error)}). The invocation may still be running; do not retry without checking it first. Invocation: ${current.invocationId}.`, {
164
+ code: 'spawn_unconfirmed',
165
+ state: 'unconfirmed_may_be_running',
166
+ invocationId: current.invocationId,
167
+ dispatchState: current.dispatchState,
168
+ ...(current.node ? { node: current.node } : {}),
169
+ });
170
+ }
94
171
  if (Date.now() >= deadline) {
95
- throw new Error(PERSONA_SPAWN_TIMEOUT_MESSAGE, { cause: error });
172
+ throw new VerifiedSpawnError(`${VERIFIED_SPAWN_TIMEOUT_MESSAGE} The invocation may still be running; do not retry without checking it first. Invocation: ${current.invocationId}.`, {
173
+ state: 'unconfirmed_may_be_running',
174
+ invocationId: current.invocationId,
175
+ dispatchState: current.dispatchState,
176
+ ...(current.node ? { node: current.node } : {}),
177
+ receipt: { status: 'unknown' },
178
+ });
96
179
  }
97
- await new Promise((resolve) => setTimeout(resolve, PERSONA_SPAWN_POLL_MS));
180
+ await new Promise((resolve) => setTimeout(resolve, VERIFIED_SPAWN_POLL_MS));
98
181
  }
99
182
  }
100
183
  }
101
- async function waitForPersonaSpawn(actions, ackValue, timeoutMs = PERSONA_SPAWN_TIMEOUT_MS) {
184
+ function missingVerifiedSpawnProof(record, ack, invocationId) {
185
+ const handler = invocationText(record, 'handlerNodeId', 'handler_node_id') ??
186
+ invocationText(record, 'dispatchedNodeId', 'dispatched_node_id') ??
187
+ invocationText(ack, 'handlerNodeId', 'handler_node_id') ??
188
+ 'unknown';
189
+ return `${VERIFIED_SPAWN_MISSING_READY_MESSAGE} Resolved handler node: ${handler}; invocation: ${invocationId}.`;
190
+ }
191
+ async function waitForVerifiedSpawn(actions, ackValue, timeoutMs = VERIFIED_SPAWN_TIMEOUT_MS) {
102
192
  const ack = recordValue(ackValue);
103
193
  let current = invocationRef(ack);
104
- if (!current)
105
- throw new Error('Persona spawn did not return an invocation id.');
194
+ if (!current) {
195
+ throw new VerifiedSpawnError('Spawn returned no invocation id; the dispatch is unconfirmed and may still be running. Do not retry blindly.', { state: 'unconfirmed_may_be_running', dispatchState: dispatchStateFor(ack) });
196
+ }
106
197
  const deadline = Date.now() + timeoutMs;
107
198
  const followed = new Set([`${current.actionName}\u001f${current.invocationId}`]);
108
199
  for (;;) {
109
200
  const invocation = await pollInvocation(actions, current, deadline);
110
201
  const record = recordValue(invocation);
111
202
  const status = invocationText(record, 'status')?.toLowerCase();
112
- if (status && PERSONA_SPAWN_SUCCESS_STATUSES.has(status)) {
203
+ if (status && VERIFIED_SPAWN_SUCCESS_STATUSES.has(status)) {
113
204
  const nested = nestedPersonaSpawnRef(record);
114
205
  if (nested) {
115
206
  const key = `${nested.actionName}\u001f${nested.invocationId}`;
116
207
  if (followed.has(key)) {
117
- throw new Error('Persona spawn returned a cyclic nested invocation.');
208
+ throw new VerifiedSpawnError('Persona spawn returned a cyclic nested invocation.', {
209
+ code: 'spawn_failed',
210
+ state: 'failed',
211
+ dispatchState: dispatchStateForRecord(record, current.dispatchState),
212
+ invocationId: current.invocationId,
213
+ receipt: record,
214
+ });
118
215
  }
119
216
  followed.add(key);
120
217
  current = nested;
121
218
  continue;
122
219
  }
220
+ const output = recordValue(record.output);
221
+ if (output.spawned !== true || output.ready !== true) {
222
+ throw new VerifiedSpawnError(missingVerifiedSpawnProof(record, ack, current.invocationId), {
223
+ code: 'spawn_failed',
224
+ state: 'failed',
225
+ dispatchState: dispatchStateForRecord(record, current.dispatchState),
226
+ invocationId: current.invocationId,
227
+ node: invocationText(record, 'handlerNodeId', 'handler_node_id') ??
228
+ invocationText(record, 'dispatchedNodeId', 'dispatched_node_id') ??
229
+ current.node,
230
+ receipt: record,
231
+ });
232
+ }
123
233
  return invocation;
124
234
  }
125
- if (status && PERSONA_SPAWN_FAILURE_STATUSES.has(status)) {
126
- throw new Error(invocationText(record, 'error') ?? `Persona spawn ${status}.`);
235
+ if (status && VERIFIED_SPAWN_FAILURE_STATUSES.has(status)) {
236
+ throw new VerifiedSpawnError(invocationText(record, 'error') ?? `Spawn ${status}.`, {
237
+ code: 'spawn_failed',
238
+ state: 'failed',
239
+ dispatchState: dispatchStateForRecord(record, current.dispatchState),
240
+ invocationId: current.invocationId,
241
+ node: invocationText(record, 'handlerNodeId', 'handler_node_id') ??
242
+ invocationText(record, 'dispatchedNodeId', 'dispatched_node_id') ??
243
+ current.node,
244
+ receipt: record,
245
+ });
127
246
  }
128
247
  if (Date.now() >= deadline) {
129
- throw new Error(PERSONA_SPAWN_TIMEOUT_MESSAGE);
248
+ throw new VerifiedSpawnError(`${VERIFIED_SPAWN_TIMEOUT_MESSAGE} The invocation may still be running; do not retry without checking it first. Invocation: ${current.invocationId}.`, {
249
+ code: 'spawn_unconfirmed',
250
+ state: 'unconfirmed_may_be_running',
251
+ dispatchState: dispatchStateForRecord(record, current.dispatchState),
252
+ invocationId: current.invocationId,
253
+ node: invocationText(record, 'handlerNodeId', 'handler_node_id') ??
254
+ invocationText(record, 'dispatchedNodeId', 'dispatched_node_id') ??
255
+ current.node,
256
+ receipt: record,
257
+ });
130
258
  }
131
- await new Promise((resolve) => setTimeout(resolve, PERSONA_SPAWN_POLL_MS));
259
+ await new Promise((resolve) => setTimeout(resolve, VERIFIED_SPAWN_POLL_MS));
132
260
  }
133
261
  }
134
262
  export const AGENT_RELAY_MCP_INSTRUCTIONS = `You are an AI agent in a collaborative workspace powered by Agent Relay. You can communicate with other agents using these MCP tools:
@@ -435,12 +563,6 @@ export async function registerAgentWithRebind({ session, setSession, getRelay, n
435
563
  warnings,
436
564
  };
437
565
  }
438
- function requireSpawnActions(client) {
439
- if (!client.actions) {
440
- throw new Error('spawn requires an agent-scoped Relaycast actions client.');
441
- }
442
- return client.actions;
443
- }
444
566
  function validateSpawnRequest({ cli, persona, model, sessionRef, cwd, personaCwd }) {
445
567
  if (Boolean(cli) === Boolean(persona)) {
446
568
  throw new Error('spawn requires exactly one of `cli` or `persona`.');
@@ -463,7 +585,7 @@ function buildSpawnActionInput({ name, cli, persona, task, cwd, personaCwd, work
463
585
  const registryCwd = personaCwd ?? cwd;
464
586
  return {
465
587
  name,
466
- ...(cli ? { cli } : { persona, capability: 'spawn:persona' }),
588
+ ...(cli ? { cli, verify_ready: true } : { persona, capability: 'spawn:persona' }),
467
589
  ...(task ? { task } : {}),
468
590
  ...(persona && registryCwd ? { cwd: registryCwd } : {}),
469
591
  ...(workerCwd ? { worker_cwd: workerCwd } : {}),
@@ -474,14 +596,31 @@ function buildSpawnActionInput({ name, cli, persona, task, cwd, personaCwd, work
474
596
  ...(selectedChannels ? { channels: selectedChannels } : {}),
475
597
  };
476
598
  }
477
- async function invokeVerifiedPersonaSpawn(session, asIdentity, baseUrl, actionInput) {
599
+ async function invokeVerifiedSpawn(session, asIdentity, baseUrl, actionInput) {
478
600
  const agentToken = asIdentity ? session.agents.get(asIdentity)?.agentToken : session.agentToken;
479
601
  if (!agentToken) {
480
- throw new Error('Persona spawn requires a registered agent identity.');
602
+ throw new Error('Spawn requires a registered agent identity.');
481
603
  }
482
604
  const commands = new AgentRelay({ agentToken, baseUrl }).messaging.commands;
483
605
  const invocation = await commands.invoke('spawn', actionInput);
484
- return waitForPersonaSpawn(commands, invocation);
606
+ return waitForVerifiedSpawn(commands, invocation);
607
+ }
608
+ function verifiedSpawnErrorResult(error) {
609
+ return {
610
+ ...jsonContent({
611
+ ok: false,
612
+ error: {
613
+ code: error.code,
614
+ state: error.state,
615
+ dispatchState: error.dispatchState,
616
+ ...(error.invocationId ? { invocationId: error.invocationId } : {}),
617
+ ...(error.node ? { node: error.node } : {}),
618
+ ...(error.receipt ? { receipt: sanitizedSpawnReceipt(error.receipt) } : {}),
619
+ message: safeRelayErrorMessage(error),
620
+ },
621
+ }),
622
+ isError: true,
623
+ };
485
624
  }
486
625
  /**
487
626
  * Read the agent record back and confirm the supplied metadata is on it.
@@ -798,7 +937,7 @@ function registerAgentRelayTools(server, getRelay, getAgentClient, getSession, s
798
937
  '"spawn an opus claude agent" → cli:"claude", model:"claude-opus-4-8"; ' +
799
938
  '"spawn a sonnet claude agent" → cli:"claude", model:"claude-sonnet-4-6". ' +
800
939
  'Do NOT use the built-in Agent/Task tool for relay workers. ' +
801
- 'Returns the spawn record for the new worker, including the name it registered under. The worker boots asynchronously, so a successful return means the spawn was accepted, not that the worker is ready watch for its messages or poll "list_agents" to confirm it came online.',
940
+ 'Returns the registered worker name. Raw CLI spawns return only after the broker confirms registration and harness readiness. Persona spawns follow their registered handler completion contract.',
802
941
  inputSchema: {
803
942
  name: z.string().describe('Worker agent name'),
804
943
  cli: z
@@ -825,26 +964,32 @@ function registerAgentRelayTools(server, getRelay, getAgentClient, getSession, s
825
964
  },
826
965
  outputSchema: jsonResult,
827
966
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
828
- }, async ({ name, cli, task, channel, persona, model, spawn_mode, exit_after_task }) => jsonContent(await getRelay().agents.spawn({
829
- name,
830
- cli,
831
- task: exit_after_task ||
832
- spawn_mode === 'task_exit' ||
833
- spawn_mode === 'task-exit' ||
834
- spawn_mode === 'single_shot' ||
835
- spawn_mode === 'single-shot'
836
- ? withExitAfterTaskInstruction(task)
837
- : task,
838
- channel,
839
- persona,
840
- // SpawnAgentRequest has no top-level model field; pass via metadata
841
- // so the broker can extract it and forward --model to the launched CLI.
842
- metadata: model ? { model } : undefined,
843
- })));
967
+ }, async ({ name, cli, task, channel, persona, model, spawn_mode, exit_after_task }) => {
968
+ const invocation = await getRelay().agents.spawn({
969
+ name,
970
+ cli,
971
+ task: exit_after_task ||
972
+ spawn_mode === 'task_exit' ||
973
+ spawn_mode === 'task-exit' ||
974
+ spawn_mode === 'single_shot' ||
975
+ spawn_mode === 'single-shot'
976
+ ? withExitAfterTaskInstruction(task)
977
+ : task,
978
+ channel,
979
+ persona,
980
+ // SpawnAgentRequest has no top-level model field; pass via metadata
981
+ // so the broker can extract it and forward --model to the launched CLI.
982
+ metadata: model ? { model } : undefined,
983
+ });
984
+ const failure = terminalSpawnFailureResult(invocation);
985
+ if (failure)
986
+ return failure;
987
+ return jsonContent({ ...invocation, placement: spawnReceipt(invocation) });
988
+ });
844
989
  server.registerTool('spawn', {
845
990
  title: 'Spawn Agent',
846
991
  description: 'Invoke the fleet spawn action with either a raw `cli` or an AgentWorkforce `persona` name/path. ' +
847
- 'Persona requests route to a node exposing `spawn:persona` (for example, `defineWorkforcePersonaSpawnNode` from `@agentworkforce/local-surface`) and return only after broker registration and harness readiness are verified. Raw CLI requests retain asynchronous acknowledgement behavior.',
992
+ 'Persona requests route to a node exposing `spawn:persona` (for example, `defineWorkforcePersonaSpawnNode` from `@agentworkforce/local-surface`). Both forms return only after broker registration and harness readiness are verified.',
848
993
  inputSchema: {
849
994
  name: z.string().describe('Agent name'),
850
995
  cli: z
@@ -885,7 +1030,6 @@ function registerAgentRelayTools(server, getRelay, getAgentClient, getSession, s
885
1030
  openWorldHint: true,
886
1031
  },
887
1032
  }, async ({ name, cli, persona, task, cwd, persona_cwd, worker_cwd, channel, channels, model, organization, project, workstream, role, objective, session_ref, target_node, as, }) => {
888
- const actions = requireSpawnActions(getAgentClient(as));
889
1033
  const request = {
890
1034
  name,
891
1035
  cli,
@@ -907,10 +1051,18 @@ function registerAgentRelayTools(server, getRelay, getAgentClient, getSession, s
907
1051
  };
908
1052
  validateSpawnRequest(request);
909
1053
  const actionInput = buildSpawnActionInput(request);
910
- const invocation = persona
911
- ? await invokeVerifiedPersonaSpawn(getSession(), as, baseUrl, actionInput)
912
- : await actions.invoke('spawn', actionInput);
913
- return jsonContent({ invocation });
1054
+ try {
1055
+ const invocation = await invokeVerifiedSpawn(getSession(), as, baseUrl, actionInput);
1056
+ return jsonContent({
1057
+ invocation,
1058
+ placement: { state: 'ready', ...spawnReceipt(recordValue(invocation)) },
1059
+ });
1060
+ }
1061
+ catch (error) {
1062
+ if (error instanceof VerifiedSpawnError)
1063
+ return verifiedSpawnErrorResult(error);
1064
+ throw error;
1065
+ }
914
1066
  });
915
1067
  server.registerTool('remove_agent', {
916
1068
  title: 'Remove Agent',