agent-relay 3.0.1 → 3.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 (142) hide show
  1. package/README.md +37 -244
  2. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  3. package/bin/agent-relay-broker-darwin-x64 +0 -0
  4. package/bin/agent-relay-broker-linux-arm64 +0 -0
  5. package/bin/agent-relay-broker-linux-x64 +0 -0
  6. package/dist/index.cjs +342 -60
  7. package/dist/src/cli/commands/core.d.ts +2 -0
  8. package/dist/src/cli/commands/core.d.ts.map +1 -1
  9. package/dist/src/cli/commands/core.js +9 -2
  10. package/dist/src/cli/commands/core.js.map +1 -1
  11. package/dist/src/cli/lib/broker-lifecycle.d.ts.map +1 -1
  12. package/dist/src/cli/lib/broker-lifecycle.js +87 -28
  13. package/dist/src/cli/lib/broker-lifecycle.js.map +1 -1
  14. package/package.json +9 -8
  15. package/packages/acp-bridge/README.md +50 -67
  16. package/packages/acp-bridge/package.json +2 -2
  17. package/packages/config/package.json +1 -1
  18. package/packages/hooks/package.json +4 -4
  19. package/packages/memory/package.json +2 -2
  20. package/packages/policy/package.json +2 -2
  21. package/packages/sdk/README.md +169 -64
  22. package/packages/sdk/dist/__tests__/contract-fixtures.test.js +76 -9
  23. package/packages/sdk/dist/__tests__/contract-fixtures.test.js.map +1 -1
  24. package/packages/sdk/dist/__tests__/facade.test.js +48 -0
  25. package/packages/sdk/dist/__tests__/facade.test.js.map +1 -1
  26. package/packages/sdk/dist/__tests__/integration.test.js +11 -5
  27. package/packages/sdk/dist/__tests__/integration.test.js.map +1 -1
  28. package/packages/sdk/dist/__tests__/unit.test.js +36 -0
  29. package/packages/sdk/dist/__tests__/unit.test.js.map +1 -1
  30. package/packages/sdk/dist/client.d.ts +36 -3
  31. package/packages/sdk/dist/client.d.ts.map +1 -1
  32. package/packages/sdk/dist/client.js +142 -9
  33. package/packages/sdk/dist/client.js.map +1 -1
  34. package/packages/sdk/dist/protocol.d.ts +7 -1
  35. package/packages/sdk/dist/protocol.d.ts.map +1 -1
  36. package/packages/sdk/dist/relay.d.ts +74 -11
  37. package/packages/sdk/dist/relay.d.ts.map +1 -1
  38. package/packages/sdk/dist/relay.js +175 -27
  39. package/packages/sdk/dist/relay.js.map +1 -1
  40. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  41. package/packages/sdk/dist/workflows/runner.js +71 -36
  42. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  43. package/packages/sdk/dist/workflows/types.d.ts +1 -1
  44. package/packages/sdk/dist/workflows/types.d.ts.map +1 -1
  45. package/packages/sdk/package.json +2 -2
  46. package/packages/sdk/src/__tests__/contract-fixtures.test.ts +88 -9
  47. package/packages/sdk/src/__tests__/error-scenarios.test.ts +1 -1
  48. package/packages/sdk/src/__tests__/facade.test.ts +68 -0
  49. package/packages/sdk/src/__tests__/idle-nudge.test.ts +205 -257
  50. package/packages/sdk/src/__tests__/integration.test.ts +11 -5
  51. package/packages/sdk/src/__tests__/orchestration-upgrades.test.ts +277 -13
  52. package/packages/sdk/src/__tests__/swarm-coordinator.test.ts +1 -0
  53. package/packages/sdk/src/__tests__/unit.test.ts +44 -0
  54. package/packages/sdk/src/__tests__/workflow-runner.test.ts +67 -7
  55. package/packages/sdk/src/__tests__/workflow-trajectory.test.ts +4 -5
  56. package/packages/sdk/src/client.ts +195 -14
  57. package/packages/sdk/src/examples/workflows/runner-idle-refactor.yaml +306 -0
  58. package/packages/sdk/src/protocol.ts +7 -2
  59. package/packages/sdk/src/relay.ts +271 -38
  60. package/packages/sdk/src/workflows/runner.ts +73 -42
  61. package/packages/sdk/src/workflows/schema.json +1 -1
  62. package/packages/sdk/src/workflows/types.ts +1 -1
  63. package/packages/sdk/vitest.config.ts +1 -0
  64. package/packages/sdk-py/README.md +89 -102
  65. package/packages/sdk-py/agent_relay/__init__.py +16 -19
  66. package/packages/sdk-py/pyproject.toml +6 -2
  67. package/packages/sdk-py/src/agent_relay/__init__.py +35 -1
  68. package/packages/sdk-py/src/agent_relay/client.py +776 -0
  69. package/packages/sdk-py/src/agent_relay/models.py +27 -0
  70. package/packages/sdk-py/src/agent_relay/protocol.py +114 -0
  71. package/packages/sdk-py/src/agent_relay/relay.py +860 -0
  72. package/packages/sdk-py/tests/test_relay_lifecycle_hooks.py +250 -0
  73. package/packages/telemetry/package.json +1 -1
  74. package/packages/trajectory/package.json +2 -2
  75. package/packages/user-directory/package.json +2 -2
  76. package/packages/utils/package.json +2 -2
  77. package/scripts/postinstall.js +35 -162
  78. package/packages/sdk/.trajectories/active/traj_1771875803391_84ca57b2.json +0 -50
  79. package/packages/sdk/.trajectories/active/traj_1771891934534_06504121.json +0 -50
  80. package/packages/sdk/.trajectories/active/traj_1771891957929_211afc4e.json +0 -50
  81. package/packages/sdk/.trajectories/active/traj_1771891982509_38c84638.json +0 -50
  82. package/packages/sdk/.trajectories/completed/traj_1771875803188_cd6d181c.json +0 -80
  83. package/packages/sdk/.trajectories/completed/traj_1771875803204_f2aeb8c8.json +0 -80
  84. package/packages/sdk/.trajectories/completed/traj_1771875803210_d65f3f1a.json +0 -80
  85. package/packages/sdk/.trajectories/completed/traj_1771875803218_e454a25d.json +0 -80
  86. package/packages/sdk/.trajectories/completed/traj_1771875803223_d7a64815.json +0 -80
  87. package/packages/sdk/.trajectories/completed/traj_1771875803227_7e56da5b.json +0 -80
  88. package/packages/sdk/.trajectories/completed/traj_1771875803235_4fbf93b4.json +0 -80
  89. package/packages/sdk/.trajectories/completed/traj_1771875803243_47931c71.json +0 -80
  90. package/packages/sdk/.trajectories/completed/traj_1771875803258_3816f3fe.json +0 -80
  91. package/packages/sdk/.trajectories/completed/traj_1771875803268_8061140e.json +0 -80
  92. package/packages/sdk/.trajectories/completed/traj_1771875803326_ae6f9c78.json +0 -80
  93. package/packages/sdk/.trajectories/completed/traj_1771875808396_cbde0a6c.json +0 -91
  94. package/packages/sdk/.trajectories/completed/traj_1771875812026_aa2442bb.json +0 -91
  95. package/packages/sdk/.trajectories/completed/traj_1771875815431_c2c656c5.json +0 -91
  96. package/packages/sdk/.trajectories/completed/traj_1771875818645_3a4dbf02.json +0 -91
  97. package/packages/sdk/.trajectories/completed/traj_1771891934403_24923c03.json +0 -80
  98. package/packages/sdk/.trajectories/completed/traj_1771891934421_dca16e24.json +0 -80
  99. package/packages/sdk/.trajectories/completed/traj_1771891934430_057706f7.json +0 -80
  100. package/packages/sdk/.trajectories/completed/traj_1771891934442_faf97382.json +0 -80
  101. package/packages/sdk/.trajectories/completed/traj_1771891934454_5542ecd5.json +0 -80
  102. package/packages/sdk/.trajectories/completed/traj_1771891934464_12202a08.json +0 -80
  103. package/packages/sdk/.trajectories/completed/traj_1771891934487_94378275.json +0 -80
  104. package/packages/sdk/.trajectories/completed/traj_1771891934503_ca728c13.json +0 -80
  105. package/packages/sdk/.trajectories/completed/traj_1771891934519_100af69a.json +0 -80
  106. package/packages/sdk/.trajectories/completed/traj_1771891934536_62ad39d9.json +0 -80
  107. package/packages/sdk/.trajectories/completed/traj_1771891934553_d6798a52.json +0 -80
  108. package/packages/sdk/.trajectories/completed/traj_1771891939537_541c8096.json +0 -91
  109. package/packages/sdk/.trajectories/completed/traj_1771891942985_36ab9a4d.json +0 -91
  110. package/packages/sdk/.trajectories/completed/traj_1771891946453_e8a6e05f.json +0 -91
  111. package/packages/sdk/.trajectories/completed/traj_1771891949838_5de0de84.json +0 -91
  112. package/packages/sdk/.trajectories/completed/traj_1771891957807_0ecfb4f4.json +0 -80
  113. package/packages/sdk/.trajectories/completed/traj_1771891957827_c4539239.json +0 -80
  114. package/packages/sdk/.trajectories/completed/traj_1771891957836_91168b48.json +0 -80
  115. package/packages/sdk/.trajectories/completed/traj_1771891957848_8c5cad0b.json +0 -80
  116. package/packages/sdk/.trajectories/completed/traj_1771891957857_0986b293.json +0 -80
  117. package/packages/sdk/.trajectories/completed/traj_1771891957872_8a3113af.json +0 -80
  118. package/packages/sdk/.trajectories/completed/traj_1771891957884_0bb85208.json +0 -80
  119. package/packages/sdk/.trajectories/completed/traj_1771891957892_86c75e2e.json +0 -80
  120. package/packages/sdk/.trajectories/completed/traj_1771891957907_98ca0e6f.json +0 -80
  121. package/packages/sdk/.trajectories/completed/traj_1771891957918_d9091231.json +0 -80
  122. package/packages/sdk/.trajectories/completed/traj_1771891957931_dcaf77ed.json +0 -80
  123. package/packages/sdk/.trajectories/completed/traj_1771891962931_eb1fdee2.json +0 -91
  124. package/packages/sdk/.trajectories/completed/traj_1771891966262_9061a93f.json +0 -91
  125. package/packages/sdk/.trajectories/completed/traj_1771891969915_1adaba19.json +0 -91
  126. package/packages/sdk/.trajectories/completed/traj_1771891973588_f08b79e9.json +0 -91
  127. package/packages/sdk/.trajectories/completed/traj_1771891982421_f1985bce.json +0 -80
  128. package/packages/sdk/.trajectories/completed/traj_1771891982432_e7a84163.json +0 -80
  129. package/packages/sdk/.trajectories/completed/traj_1771891982447_369b842a.json +0 -80
  130. package/packages/sdk/.trajectories/completed/traj_1771891982469_5fc45199.json +0 -80
  131. package/packages/sdk/.trajectories/completed/traj_1771891982495_454c7cb3.json +0 -80
  132. package/packages/sdk/.trajectories/completed/traj_1771891982514_08098e03.json +0 -80
  133. package/packages/sdk/.trajectories/completed/traj_1771891982526_b351d778.json +0 -80
  134. package/packages/sdk/.trajectories/completed/traj_1771891982533_fa542d83.json +0 -80
  135. package/packages/sdk/.trajectories/completed/traj_1771891982540_18ab24dc.json +0 -80
  136. package/packages/sdk/.trajectories/completed/traj_1771891982544_5b4fa163.json +0 -80
  137. package/packages/sdk/.trajectories/completed/traj_1771891982548_c13f089a.json +0 -80
  138. package/packages/sdk/.trajectories/completed/traj_1771891987510_23f6da1f.json +0 -91
  139. package/packages/sdk/.trajectories/completed/traj_1771891991466_912c2e04.json +0 -91
  140. package/packages/sdk/.trajectories/completed/traj_1771891994891_60604be2.json +0 -91
  141. package/packages/sdk/.trajectories/completed/traj_1771891998370_cfaf9b8b.json +0 -91
  142. package/packages/sdk/bin/agent-relay-broker +0 -0
@@ -33,7 +33,7 @@ import {
33
33
  type SendMessageInput,
34
34
  type SpawnPtyInput,
35
35
  } from './client.js';
36
- import type { AgentRuntime, BrokerEvent, BrokerStatus, RestartPolicy } from './protocol.js';
36
+ import type { AgentRuntime, BrokerEvent, BrokerStatus, HeadlessProvider, RestartPolicy } from './protocol.js';
37
37
  import {
38
38
  followLogs as followLogsFromFile,
39
39
  getLogs as getLogsFromFile,
@@ -87,7 +87,47 @@ export interface DeliveryState {
87
87
  updatedAt: number;
88
88
  }
89
89
 
90
- export interface SpawnOptions {
90
+ export interface SpawnLifecycleContext {
91
+ name: string;
92
+ cli: string;
93
+ channels: string[];
94
+ task?: string;
95
+ }
96
+
97
+ export interface SpawnLifecycleSuccessContext extends SpawnLifecycleContext {
98
+ runtime: AgentRuntime;
99
+ }
100
+
101
+ export interface SpawnLifecycleErrorContext extends SpawnLifecycleContext {
102
+ error: unknown;
103
+ }
104
+
105
+ export interface SpawnLifecycleHooks {
106
+ onStart?: (context: SpawnLifecycleContext) => void | Promise<void>;
107
+ onSuccess?: (context: SpawnLifecycleSuccessContext) => void | Promise<void>;
108
+ onError?: (context: SpawnLifecycleErrorContext) => void | Promise<void>;
109
+ }
110
+
111
+ export interface ReleaseLifecycleContext {
112
+ name: string;
113
+ reason?: string;
114
+ }
115
+
116
+ export interface ReleaseLifecycleErrorContext extends ReleaseLifecycleContext {
117
+ error: unknown;
118
+ }
119
+
120
+ export interface ReleaseLifecycleHooks {
121
+ onStart?: (context: ReleaseLifecycleContext) => void | Promise<void>;
122
+ onSuccess?: (context: ReleaseLifecycleContext) => void | Promise<void>;
123
+ onError?: (context: ReleaseLifecycleErrorContext) => void | Promise<void>;
124
+ }
125
+
126
+ export interface ReleaseOptions extends ReleaseLifecycleHooks {
127
+ reason?: string;
128
+ }
129
+
130
+ export interface SpawnOptions extends SpawnLifecycleHooks {
91
131
  args?: string[];
92
132
  channels?: string[];
93
133
  model?: string;
@@ -119,7 +159,7 @@ export interface Agent {
119
159
  exitSignal?: string;
120
160
  /** Set when the agent requests exit via /exit. Available after `onAgentExitRequested` fires. */
121
161
  exitReason?: string;
122
- release(reason?: string): Promise<void>;
162
+ release(reasonOrOptions?: string | ReleaseOptions): Promise<void>;
123
163
  waitForReady(timeoutMs?: number): Promise<void>;
124
164
  /** Wait for the agent process to exit on its own.
125
165
  * @param timeoutMs — optional timeout in ms. Resolves with `"timeout"` if exceeded,
@@ -152,14 +192,16 @@ export interface HumanHandle {
152
192
  }
153
193
 
154
194
  export interface AgentSpawner {
155
- spawn(options?: {
156
- name?: string;
157
- args?: string[];
158
- channels?: string[];
159
- task?: string;
160
- model?: string;
161
- cwd?: string;
162
- }): Promise<Agent>;
195
+ spawn(options?: SpawnerSpawnOptions): Promise<Agent>;
196
+ }
197
+
198
+ export interface SpawnerSpawnOptions extends SpawnLifecycleHooks {
199
+ name?: string;
200
+ args?: string[];
201
+ channels?: string[];
202
+ task?: string;
203
+ model?: string;
204
+ cwd?: string;
163
205
  }
164
206
 
165
207
  export type EventHook<T> = ((value: T) => void) | null;
@@ -173,6 +215,17 @@ export interface AgentRelayOptions {
173
215
  env?: NodeJS.ProcessEnv;
174
216
  requestTimeoutMs?: number;
175
217
  shutdownTimeoutMs?: number;
218
+ /**
219
+ * Name for the auto-created Relaycast workspace.
220
+ * If omitted, a random name is generated.
221
+ * Ignored when RELAY_API_KEY is already set in env or process.env.
222
+ */
223
+ workspaceName?: string;
224
+ /**
225
+ * Base URL for the Relaycast API.
226
+ * Defaults to RELAYCAST_BASE_URL env var or https://api.relaycast.dev.
227
+ */
228
+ relaycastBaseUrl?: string;
176
229
  }
177
230
 
178
231
  type OutputListener = {
@@ -195,6 +248,19 @@ export class AgentRelay {
195
248
  onAgentExitRequested: EventHook<{ name: string; reason: string }> = null;
196
249
  onAgentIdle: EventHook<{ name: string; idleSecs: number }> = null;
197
250
 
251
+ // ── Public accessors ────────────────────────────────────────────────────
252
+
253
+ /** The resolved Relaycast workspace API key (available after first spawn). */
254
+ get workspaceKey(): string | undefined {
255
+ return this.relayApiKey;
256
+ }
257
+
258
+ /** Observer URL for the auto-created workspace (available after first spawn). */
259
+ get observerUrl(): string | undefined {
260
+ if (!this.relayApiKey) return undefined;
261
+ return `https://observer.relaycast.dev/?key=${this.relayApiKey}`;
262
+ }
263
+
198
264
  // Shorthand spawners
199
265
  readonly codex: AgentSpawner;
200
266
  readonly claude: AgentSpawner;
@@ -202,6 +268,9 @@ export class AgentRelay {
202
268
 
203
269
  private readonly clientOptions: AgentRelayClientOptions;
204
270
  private readonly defaultChannels: string[];
271
+ private readonly workspaceName?: string;
272
+ private readonly relaycastBaseUrl?: string;
273
+ private relayApiKey?: string;
205
274
  private client?: AgentRelayClient;
206
275
  private startPromise?: Promise<AgentRelayClient>;
207
276
  private unsubEvent?: () => void;
@@ -225,6 +294,8 @@ export class AgentRelay {
225
294
 
226
295
  constructor(options: AgentRelayOptions = {}) {
227
296
  this.defaultChannels = options.channels ?? ['general'];
297
+ this.workspaceName = options.workspaceName;
298
+ this.relaycastBaseUrl = options.relaycastBaseUrl;
228
299
  this.clientOptions = {
229
300
  binaryPath: options.binaryPath,
230
301
  binaryArgs: options.binaryArgs,
@@ -267,7 +338,7 @@ export class AgentRelay {
267
338
 
268
339
  // ── Spawning ────────────────────────────────────────────────────────────
269
340
 
270
- async spawnPty(input: SpawnPtyInput): Promise<Agent> {
341
+ async spawnPty(input: SpawnPtyInput & SpawnLifecycleHooks): Promise<Agent> {
271
342
  const client = await this.ensureStarted();
272
343
  if (!input.channels || input.channels.length === 0) {
273
344
  console.warn(
@@ -276,26 +347,52 @@ export class AgentRelay {
276
347
  );
277
348
  }
278
349
  const channels = input.channels ?? ['general'];
279
- const result = await client.spawnPty({
350
+ const lifecycleContext: SpawnLifecycleContext = {
280
351
  name: input.name,
281
352
  cli: input.cli,
282
- args: input.args,
283
353
  channels,
284
354
  task: input.task,
285
- model: input.model,
286
- cwd: input.cwd,
287
- team: input.team,
288
- shadowOf: input.shadowOf,
289
- shadowMode: input.shadowMode,
290
- idleThresholdSecs: input.idleThresholdSecs,
291
- restartPolicy: input.restartPolicy,
292
- });
293
- this.readyAgents.delete(result.name);
294
- this.messageReadyAgents.delete(result.name);
295
- this.exitedAgents.delete(result.name);
296
- this.idleAgents.delete(result.name);
355
+ };
356
+ await this.invokeLifecycleHook(input.onStart, lifecycleContext, `spawnPty("${input.name}") onStart`);
357
+ let result: { name: string; runtime: AgentRuntime };
358
+ try {
359
+ result = await client.spawnPty({
360
+ name: input.name,
361
+ cli: input.cli,
362
+ args: input.args,
363
+ channels,
364
+ task: input.task,
365
+ model: input.model,
366
+ cwd: input.cwd,
367
+ team: input.team,
368
+ shadowOf: input.shadowOf,
369
+ shadowMode: input.shadowMode,
370
+ idleThresholdSecs: input.idleThresholdSecs,
371
+ restartPolicy: input.restartPolicy,
372
+ });
373
+ } catch (error) {
374
+ await this.invokeLifecycleHook(
375
+ input.onError,
376
+ {
377
+ ...lifecycleContext,
378
+ error,
379
+ },
380
+ `spawnPty("${input.name}") onError`
381
+ );
382
+ throw error;
383
+ }
384
+ this.resetAgentLifecycleState(result.name);
297
385
  const agent = this.makeAgent(result.name, result.runtime, channels);
298
386
  this.knownAgents.set(agent.name, agent);
387
+ await this.invokeLifecycleHook(
388
+ input.onSuccess,
389
+ {
390
+ ...lifecycleContext,
391
+ name: result.name,
392
+ runtime: result.runtime,
393
+ },
394
+ `spawnPty("${input.name}") onSuccess`
395
+ );
299
396
  return agent;
300
397
  }
301
398
 
@@ -313,6 +410,9 @@ export class AgentRelay {
313
410
  shadowMode: options?.shadowMode,
314
411
  idleThresholdSecs: options?.idleThresholdSecs,
315
412
  restartPolicy: options?.restartPolicy,
413
+ onStart: options?.onStart,
414
+ onSuccess: options?.onSuccess,
415
+ onError: options?.onError,
316
416
  });
317
417
  }
318
418
 
@@ -322,7 +422,7 @@ export class AgentRelay {
322
422
  if (waitForMessage) {
323
423
  return this.waitForAgentMessage(name, timeoutMs ?? 60_000);
324
424
  }
325
- return this.waitForAgentReady(name, timeoutMs ?? 30_000);
425
+ return this.waitForAgentReady(name, timeoutMs ?? 60_000);
326
426
  }
327
427
 
328
428
  // ── Human source ────────────────────────────────────────────────────────
@@ -551,7 +651,7 @@ export class AgentRelay {
551
651
  * The agent's CLI may not yet be ready to receive messages.
552
652
  * Use `waitForAgentMessage()` for full readiness.
553
653
  */
554
- async waitForAgentReady(name: string, timeoutMs = 30_000): Promise<Agent> {
654
+ async waitForAgentReady(name: string, timeoutMs = 60_000): Promise<Agent> {
555
655
  const client = await this.ensureStarted();
556
656
  const existing = this.knownAgents.get(name);
557
657
  if (existing && this.readyAgents.has(name)) {
@@ -750,14 +850,56 @@ export class AgentRelay {
750
850
  }
751
851
  }
752
852
 
853
+ /**
854
+ * Ensure a Relaycast workspace API key is available.
855
+ * Resolution order:
856
+ * 1. Already resolved (cached from a previous call)
857
+ * 2. RELAY_API_KEY in options.env
858
+ * 3. RELAY_API_KEY in process.env
859
+ * 4. Auto-create a fresh workspace via the Relaycast REST API
860
+ */
861
+ private async ensureRelaycastApiKey(): Promise<void> {
862
+ if (this.relayApiKey) return;
863
+
864
+ const envKey = this.clientOptions.env?.RELAY_API_KEY ?? process.env.RELAY_API_KEY;
865
+ if (envKey) {
866
+ this.relayApiKey = envKey;
867
+ // Ensure the broker subprocess inherits the full process env + the key.
868
+ // Without this, spawning with an explicit binaryPath but no env option
869
+ // would cause the broker to start with an empty environment (no PATH,
870
+ // no RELAY_API_KEY), making connect_relay() hang and triggering the
871
+ // hello-handshake timeout.
872
+ if (!this.clientOptions.env) {
873
+ this.clientOptions.env = { ...process.env, RELAY_API_KEY: envKey };
874
+ } else if (!this.clientOptions.env.RELAY_API_KEY) {
875
+ this.clientOptions.env.RELAY_API_KEY = envKey;
876
+ }
877
+ return;
878
+ }
879
+
880
+ // No API key in env — broker will create/select its own workspace.
881
+ // Ensure the broker process inherits the full environment (PATH, etc.)
882
+ // so it can connect to Relaycast. The actual workspace key will be
883
+ // read from the broker's hello_ack response in ensureStarted().
884
+ if (!this.clientOptions.env) {
885
+ this.clientOptions.env = { ...process.env };
886
+ }
887
+ }
888
+
753
889
  private async ensureStarted(): Promise<AgentRelayClient> {
754
890
  if (this.client) return this.client;
755
891
  if (this.startPromise) return this.startPromise;
756
892
 
757
- this.startPromise = AgentRelayClient.start(this.clientOptions)
893
+ this.startPromise = this.ensureRelaycastApiKey()
894
+ .then(() => AgentRelayClient.start(this.clientOptions))
758
895
  .then((c) => {
759
896
  this.client = c;
760
897
  this.startPromise = undefined;
898
+ // Use the workspace key the broker actually connected with.
899
+ // This ensures SDK and workers are always on the same workspace.
900
+ if (c.workspaceKey) {
901
+ this.relayApiKey = c.workspaceKey;
902
+ }
761
903
  this.wireEvents(c);
762
904
  return c;
763
905
  })
@@ -919,11 +1061,34 @@ export class AgentRelay {
919
1061
  },
920
1062
  exitCode: undefined,
921
1063
  exitSignal: undefined,
922
- async release(reason?: string) {
1064
+ async release(reasonOrOptions?: string | ReleaseOptions) {
1065
+ const releaseOptions = relay.normalizeReleaseOptions(reasonOrOptions);
1066
+ const releaseContext: ReleaseLifecycleContext = {
1067
+ name,
1068
+ reason: releaseOptions.reason,
1069
+ };
923
1070
  const client = await relay.ensureStarted();
924
- await client.release(name, reason);
1071
+ await relay.invokeLifecycleHook(releaseOptions.onStart, releaseContext, `release("${name}") onStart`);
1072
+ try {
1073
+ await client.release(name, releaseOptions.reason);
1074
+ await relay.invokeLifecycleHook(
1075
+ releaseOptions.onSuccess,
1076
+ releaseContext,
1077
+ `release("${name}") onSuccess`
1078
+ );
1079
+ } catch (error) {
1080
+ await relay.invokeLifecycleHook(
1081
+ releaseOptions.onError,
1082
+ {
1083
+ ...releaseContext,
1084
+ error,
1085
+ },
1086
+ `release("${name}") onError`
1087
+ );
1088
+ throw error;
1089
+ }
925
1090
  },
926
- async waitForReady(timeoutMs = 30_000) {
1091
+ async waitForReady(timeoutMs = 60_000) {
927
1092
  await relay.waitForAgentReady(name, timeoutMs);
928
1093
  },
929
1094
  waitForExit(timeoutMs?: number) {
@@ -1046,17 +1211,13 @@ export class AgentRelay {
1046
1211
  private createSpawner(cli: string, defaultName: string, runtime: AgentRuntime): AgentSpawner {
1047
1212
  return {
1048
1213
  spawn: async (options?) => {
1049
- const client = await this.ensureStarted();
1050
1214
  const name = options?.name ?? defaultName;
1051
1215
  const channels = options?.channels ?? ['general'];
1052
1216
  const args = options?.args ?? [];
1053
1217
 
1054
1218
  const task = options?.task;
1055
- let result: { name: string; runtime: AgentRuntime };
1056
- if (runtime === 'headless_claude') {
1057
- result = await client.spawnHeadlessClaude({ name, args, channels, task });
1058
- } else {
1059
- result = await client.spawnPty({
1219
+ if (runtime === 'pty') {
1220
+ return this.spawnPty({
1060
1221
  name,
1061
1222
  cli,
1062
1223
  args,
@@ -1064,13 +1225,85 @@ export class AgentRelay {
1064
1225
  task,
1065
1226
  model: options?.model,
1066
1227
  cwd: options?.cwd,
1228
+ onStart: options?.onStart,
1229
+ onSuccess: options?.onSuccess,
1230
+ onError: options?.onError,
1067
1231
  });
1068
1232
  }
1069
1233
 
1234
+ const client = await this.ensureStarted();
1235
+ const lifecycleContext: SpawnLifecycleContext = {
1236
+ name,
1237
+ cli,
1238
+ channels,
1239
+ task,
1240
+ };
1241
+ await this.invokeLifecycleHook(options?.onStart, lifecycleContext, `spawn("${name}") onStart`);
1242
+ let result: { name: string; runtime: AgentRuntime };
1243
+ try {
1244
+ result = await client.spawnProvider({
1245
+ name,
1246
+ provider: cli as HeadlessProvider,
1247
+ transport: 'headless',
1248
+ args,
1249
+ channels,
1250
+ task,
1251
+ });
1252
+ } catch (error) {
1253
+ await this.invokeLifecycleHook(
1254
+ options?.onError,
1255
+ {
1256
+ ...lifecycleContext,
1257
+ error,
1258
+ },
1259
+ `spawn("${name}") onError`
1260
+ );
1261
+ throw error;
1262
+ }
1263
+
1264
+ this.resetAgentLifecycleState(result.name);
1070
1265
  const agent = this.makeAgent(result.name, result.runtime, channels);
1071
1266
  this.knownAgents.set(agent.name, agent);
1267
+ await this.invokeLifecycleHook(
1268
+ options?.onSuccess,
1269
+ {
1270
+ ...lifecycleContext,
1271
+ name: result.name,
1272
+ runtime: result.runtime,
1273
+ },
1274
+ `spawn("${name}") onSuccess`
1275
+ );
1072
1276
  return agent;
1073
1277
  },
1074
1278
  };
1075
1279
  }
1280
+
1281
+ private async invokeLifecycleHook<T>(
1282
+ hook: ((context: T) => void | Promise<void>) | undefined,
1283
+ context: T,
1284
+ label: string
1285
+ ): Promise<void> {
1286
+ if (!hook) {
1287
+ return;
1288
+ }
1289
+ try {
1290
+ await hook(context);
1291
+ } catch (error) {
1292
+ console.warn(`[AgentRelay] ${label} hook threw`, error);
1293
+ }
1294
+ }
1295
+
1296
+ private resetAgentLifecycleState(name: string): void {
1297
+ this.readyAgents.delete(name);
1298
+ this.messageReadyAgents.delete(name);
1299
+ this.exitedAgents.delete(name);
1300
+ this.idleAgents.delete(name);
1301
+ }
1302
+
1303
+ private normalizeReleaseOptions(reasonOrOptions?: string | ReleaseOptions): ReleaseOptions {
1304
+ if (typeof reasonOrOptions === 'string' || reasonOrOptions === undefined) {
1305
+ return { reason: reasonOrOptions };
1306
+ }
1307
+ return reasonOrOptions;
1308
+ }
1076
1309
  }
@@ -4,7 +4,7 @@
4
4
  * persists state to DB, and supports pause/resume/abort with retries.
5
5
  */
6
6
 
7
- import { spawn as cpSpawn } from 'node:child_process';
7
+ import { spawn as cpSpawn, execFileSync } from 'node:child_process';
8
8
  import { randomBytes } from 'node:crypto';
9
9
  import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
10
10
  import type { WriteStream } from 'node:fs';
@@ -103,6 +103,31 @@ interface StepState {
103
103
  agent?: Agent;
104
104
  }
105
105
 
106
+ // ── CLI resolution ───────────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * Resolve `cursor` to the concrete cursor agent binary available in PATH.
110
+ * Prefers `cursor-agent` over `agent`. Falls back to `agent` if neither
111
+ * `cursor-agent` nor a real cursor IDE CLI is found.
112
+ * Result is memoized after the first call to avoid repeated sync PATH lookups.
113
+ */
114
+ let _resolvedCursorCli: 'cursor-agent' | 'agent' | undefined;
115
+ function resolveCursorCli(): 'cursor-agent' | 'agent' {
116
+ if (_resolvedCursorCli !== undefined) return _resolvedCursorCli;
117
+ const candidates: Array<'cursor-agent' | 'agent'> = ['cursor-agent', 'agent'];
118
+ for (const candidate of candidates) {
119
+ try {
120
+ execFileSync('which', [candidate], { stdio: 'ignore' });
121
+ _resolvedCursorCli = candidate;
122
+ return candidate;
123
+ } catch {
124
+ // not in PATH, try next
125
+ }
126
+ }
127
+ _resolvedCursorCli = 'agent'; // last-resort default
128
+ return _resolvedCursorCli;
129
+ }
130
+
106
131
  // ── WorkflowRunner ──────────────────────────────────────────────────────────
107
132
 
108
133
  export class WorkflowRunner {
@@ -223,6 +248,21 @@ export class WorkflowRunner {
223
248
 
224
249
  this.relayApiKey = apiKey;
225
250
  this.relayApiKeyAutoCreated = true;
251
+
252
+ // Best-effort: push the key to a co-running dashboard (agent-relay up) so it
253
+ // can make Relaycast API calls without any file or manual env var setup.
254
+ const dashboardPort = process.env.AGENT_RELAY_DASHBOARD_PORT || '3888';
255
+ fetch(`http://127.0.0.1:${dashboardPort}/api/relay-config`, {
256
+ method: 'POST',
257
+ headers: { 'content-type': 'application/json' },
258
+ body: JSON.stringify({ apiKey }),
259
+ }).then((res) => {
260
+ if (!res.ok) {
261
+ console.warn(`[WorkflowRunner] dashboard key push failed: HTTP ${res.status}`);
262
+ }
263
+ }).catch(() => {
264
+ // Dashboard not running — silently ignore.
265
+ });
226
266
  }
227
267
 
228
268
  private getRelayEnv(): NodeJS.ProcessEnv | undefined {
@@ -1102,8 +1142,7 @@ export class WorkflowRunner {
1102
1142
  this.log('API key resolved');
1103
1143
  if (this.relayApiKeyAutoCreated && this.relayApiKey) {
1104
1144
  this.log(`Workspace created — follow this run in Relaycast:`);
1105
- this.log(` RELAY_API_KEY=${this.relayApiKey}`);
1106
- this.log(` Observer: https://observer.relaycast.dev (paste key above)`);
1145
+ this.log(` Observer: https://observer.relaycast.dev/?key=${this.relayApiKey}`);
1107
1146
  this.log(` Channel: ${channel}`);
1108
1147
  }
1109
1148
 
@@ -1237,30 +1276,6 @@ export class WorkflowRunner {
1237
1276
  await this.runPreflightChecks(workflow.preflight, runId);
1238
1277
  }
1239
1278
 
1240
- // Pre-register all interactive agent steps with Relaycast before execution.
1241
- // This warms the broker's token cache so spawn_agent calls are instant cache
1242
- // hits rather than blocking on individual HTTP registrations per spawn.
1243
- // Agent names use the run ID prefix (deterministic) so we can predict them.
1244
- if (this.relay && !isResume) {
1245
- const agentPreflight = workflow.steps
1246
- .filter((s) => s.type !== 'deterministic' && s.type !== 'worktree' && s.agent)
1247
- .map((s) => {
1248
- const agentDef = agentMap.get(s.agent!);
1249
- return agentDef && agentDef.interactive !== false
1250
- ? { name: `${s.name}-${runId.slice(0, 8)}`, cli: agentDef.cli }
1251
- : null;
1252
- })
1253
- .filter((e): e is { name: string; cli: AgentCli } => e !== null);
1254
-
1255
- if (agentPreflight.length > 0) {
1256
- this.log(`Pre-registering ${agentPreflight.length} agents with Relaycast...`);
1257
- await this.relay.preflightAgents(agentPreflight).catch((err: Error) => {
1258
- this.log(`[preflight-agents] warning: ${err.message} — continuing without pre-registration`);
1259
- });
1260
- this.log('Agent pre-registration complete');
1261
- }
1262
- }
1263
-
1264
1279
  this.log(`Executing ${workflow.steps.length} steps (pattern: ${config.swarm.pattern})`);
1265
1280
  await this.executeSteps(workflow, stepStates, agentMap, config.errorHandling, runId);
1266
1281
 
@@ -1785,9 +1800,6 @@ export class WorkflowRunner {
1785
1800
  await this.persistStepOutput(runId, step.name, output);
1786
1801
 
1787
1802
  this.emit({ type: 'step:completed', runId, stepName: step.name, output });
1788
- this.postToChannel(
1789
- `**[${step.name}]** Completed (deterministic)\n${output.slice(0, 500)}${output.length > 500 ? '\n...(truncated)' : ''}`
1790
- );
1791
1803
  } catch (err) {
1792
1804
  const errorMsg = err instanceof Error ? err.message : String(err);
1793
1805
  this.postToChannel(`**[${step.name}]** Failed: ${errorMsg}`);
@@ -2081,9 +2093,6 @@ export class WorkflowRunner {
2081
2093
  await this.persistStepOutput(runId, step.name, output);
2082
2094
 
2083
2095
  this.emit({ type: 'step:completed', runId, stepName: step.name, output });
2084
- this.postToChannel(
2085
- `**[${step.name}]** Completed\n${output.slice(0, 500)}${output.length > 500 ? '\n...(truncated)' : ''}`
2086
- );
2087
2096
  await this.trajectory?.stepCompleted(step, output, attempt + 1);
2088
2097
  return;
2089
2098
  } catch (err) {
@@ -2136,6 +2145,13 @@ export class WorkflowRunner {
2136
2145
  return { cmd: 'aider', args: ['--message', task, '--yes-always', '--no-git', ...extraArgs] };
2137
2146
  case 'goose':
2138
2147
  return { cmd: 'goose', args: ['run', '--text', task, '--no-session', ...extraArgs] };
2148
+ case 'cursor-agent':
2149
+ case 'agent':
2150
+ return { cmd: cli, args: ['--force', '-p', task, ...extraArgs] };
2151
+ case 'cursor':
2152
+ // Should not reach here after resolveAgentDef resolves to agent/cursor-agent,
2153
+ // but handle as fallback.
2154
+ return { cmd: resolveCursorCli(), args: ['--force', '-p', task, ...extraArgs] };
2139
2155
  }
2140
2156
  }
2141
2157
 
@@ -2144,13 +2160,16 @@ export class WorkflowRunner {
2144
2160
  * Explicit fields on the definition always win over preset-inferred defaults.
2145
2161
  */
2146
2162
  private static resolveAgentDef(def: AgentDefinition): AgentDefinition {
2147
- if (!def.preset) return def;
2163
+ // Resolve "cursor" alias to whichever cursor agent binary is in PATH
2164
+ const resolvedCli: AgentCli = def.cli === 'cursor' ? resolveCursorCli() : def.cli;
2165
+
2166
+ if (!def.preset) return resolvedCli !== def.cli ? { ...def, cli: resolvedCli } : def;
2148
2167
  const nonInteractivePresets: AgentPreset[] = ['worker', 'reviewer', 'analyst'];
2149
2168
  const defaults: Partial<AgentDefinition> = nonInteractivePresets.includes(def.preset)
2150
2169
  ? { interactive: false }
2151
2170
  : {};
2152
2171
  // Explicit fields on the def always win
2153
- return { ...defaults, ...def } as AgentDefinition;
2172
+ return { ...defaults, ...def, cli: resolvedCli } as AgentDefinition;
2154
2173
  }
2155
2174
 
2156
2175
  /**
@@ -2386,10 +2405,6 @@ export class WorkflowRunner {
2386
2405
  }
2387
2406
 
2388
2407
  // Deterministic name: step name + first 8 chars of run ID.
2389
- // This matches the names pre-registered in preflightAgents(), so the broker
2390
- // hits its token cache instantly instead of making a fresh Relaycast HTTP call.
2391
- // On retry the broker may suffix a UUID (409 conflict) — that's fine, the agent
2392
- // still works, just without the cache benefit.
2393
2408
  let agentName = `${step.name}-${(this.currentRunId ?? this.generateShortId()).slice(0, 8)}`;
2394
2409
 
2395
2410
  // Only inject delegation guidance for lead/coordinator agents, not spokes/workers.
@@ -2628,8 +2643,19 @@ export class WorkflowRunner {
2628
2643
  ): Promise<'exited' | 'timeout' | 'released'> {
2629
2644
  const nudgeConfig = this.currentConfig?.swarm.idleNudge;
2630
2645
  if (!nudgeConfig) {
2631
- // No nudge config backward compatible simple wait
2632
- return agent.waitForExit(timeoutMs);
2646
+ // Idle = done: race exit against idle. Whichever fires first completes the step.
2647
+ const result = await Promise.race([
2648
+ agent.waitForExit(timeoutMs).then((r) => ({ kind: 'exit' as const, result: r })),
2649
+ agent.waitForIdle(timeoutMs).then((r) => ({ kind: 'idle' as const, result: r })),
2650
+ ]);
2651
+ if (result.kind === 'idle' && result.result === 'idle') {
2652
+ this.log(`[${step.name}] Agent "${agent.name}" went idle — treating as complete`);
2653
+ this.postToChannel(`**[${step.name}]** Agent \`${agent.name}\` idle — treating as complete`);
2654
+ await agent.release();
2655
+ return 'released';
2656
+ }
2657
+ // Exit won the race, or idle returned 'exited'/'timeout' — pass through.
2658
+ return result.result as 'exited' | 'timeout' | 'released';
2633
2659
  }
2634
2660
 
2635
2661
  const nudgeAfterMs = nudgeConfig.nudgeAfterMs ?? 120_000;
@@ -3209,7 +3235,8 @@ export class WorkflowRunner {
3209
3235
  // Includes block-element chars (▗▖▘▝) used in the Claude Code header bar.
3210
3236
  const SPINNER =
3211
3237
  '\\u2756\\u2738\\u2739\\u273a\\u273b\\u273c\\u273d\\u2731\\u2732\\u2733\\u2734\\u2735\\u2736\\u2737\\u2743\\u2745\\u2746\\u25d6\\u25d7\\u25d8\\u25d9\\u2022\\u25cf\\u25cb\\u25a0\\u25a1\\u25b6\\u25c0\\u23f5\\u23f6\\u23f7\\u23f8\\u23f9\\u25e2\\u25e3\\u25e4\\u25e5\\u2597\\u2596\\u2598\\u259d\\u2bc8\\u2bc7\\u2bc5\\u2bc6\\u00b7' +
3212
- '\\u2590\\u258c\\u2588\\u2584\\u2580\\u259a\\u259e'; // additional block elements
3238
+ '\\u2590\\u258c\\u2588\\u2584\\u2580\\u259a\\u259e' + // additional block elements
3239
+ '\\u2b21\\u2b22'; // hex-hollow ⬡ and hex-filled ⬢ (Cursor "Generating" spinner)
3213
3240
  const spinnerRe = new RegExp(`[${SPINNER}]`, 'gu');
3214
3241
  const spinnerClassRe = new RegExp(`^[\\s${SPINNER}]*$`, 'u');
3215
3242
 
@@ -3227,6 +3254,9 @@ export class WorkflowRunner {
3227
3254
  // regardless of the specific word used (Thinking, Cascading, Flibbertigibbeting, etc.)
3228
3255
  const thinkingLineRe = new RegExp(`^[\\s${SPINNER}]*\\s*\\w[\\w\\s]*\\u2026\\s*$`, 'u');
3229
3256
  const cursorOnlyRe = /^[\s❯⎿›»◀▶←→↑↓⟨⟩⟪⟫·]+$/u;
3257
+ // Cursor Agent TUI lines: generating animations, pasted text indicators, UI chrome
3258
+ const cursorAgentRe =
3259
+ /^(?:Cursor Agent|[\s⬡⬢]*Generating[.\s]|\[Pasted text|Auto-run all|Add a follow-up|ctrl\+c to stop|shift\+tab|Auto$|\/\s*commands|@\s*files|!\s*shell|follow-ups?\s|The user ha)/iu;
3230
3260
  const slashCommandRe = /^\/\w+\s*$/u;
3231
3261
  const mcpJsonKvRe =
3232
3262
  /^\s*"(?:type|method|params|result|id|jsonrpc|tool|name|arguments|content|role|metadata)"\s*:/u;
@@ -3270,6 +3300,7 @@ export class WorkflowRunner {
3270
3300
  if (uiHintRe.test(trimmed)) continue;
3271
3301
  if (thinkingLineRe.test(trimmed)) continue;
3272
3302
  if (cursorOnlyRe.test(trimmed)) continue;
3303
+ if (cursorAgentRe.test(trimmed)) continue;
3273
3304
  if (slashCommandRe.test(trimmed)) continue;
3274
3305
  if (!meaningfulContentRe.test(trimmed)) continue;
3275
3306
 
@@ -176,7 +176,7 @@
176
176
  },
177
177
  "AgentCli": {
178
178
  "type": "string",
179
- "enum": ["claude", "codex", "gemini", "aider", "goose", "opencode", "droid"]
179
+ "enum": ["claude", "codex", "gemini", "aider", "goose", "opencode", "droid", "cursor", "cursor-agent", "agent"]
180
180
  },
181
181
  "AgentConstraints": {
182
182
  "type": "object",
@@ -115,7 +115,7 @@ export interface AgentDefinition {
115
115
  preset?: AgentPreset;
116
116
  }
117
117
 
118
- export type AgentCli = 'claude' | 'codex' | 'gemini' | 'aider' | 'goose' | 'opencode' | 'droid';
118
+ export type AgentCli = 'claude' | 'codex' | 'gemini' | 'aider' | 'goose' | 'opencode' | 'droid' | 'cursor' | 'cursor-agent' | 'agent';
119
119
 
120
120
  /** Resource and behavioral constraints for an agent. */
121
121
  export interface AgentConstraints {