agent-relay 2.3.11 → 2.3.13

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 (81) hide show
  1. package/install.sh +32 -0
  2. package/package.json +21 -21
  3. package/packages/acp-bridge/package.json +2 -2
  4. package/packages/bridge/package.json +7 -7
  5. package/packages/broker-sdk/README.md +32 -0
  6. package/packages/broker-sdk/dist/__tests__/unit.test.js +70 -2
  7. package/packages/broker-sdk/dist/__tests__/unit.test.js.map +1 -1
  8. package/packages/broker-sdk/dist/client.d.ts +2 -0
  9. package/packages/broker-sdk/dist/client.d.ts.map +1 -1
  10. package/packages/broker-sdk/dist/client.js +10 -0
  11. package/packages/broker-sdk/dist/client.js.map +1 -1
  12. package/packages/broker-sdk/dist/protocol.d.ts +4 -0
  13. package/packages/broker-sdk/dist/protocol.d.ts.map +1 -1
  14. package/packages/broker-sdk/dist/relay.d.ts +10 -0
  15. package/packages/broker-sdk/dist/relay.d.ts.map +1 -1
  16. package/packages/broker-sdk/dist/relay.js +53 -0
  17. package/packages/broker-sdk/dist/relay.js.map +1 -1
  18. package/packages/broker-sdk/dist/relaycast.d.ts +10 -0
  19. package/packages/broker-sdk/dist/relaycast.d.ts.map +1 -1
  20. package/packages/broker-sdk/dist/relaycast.js +40 -0
  21. package/packages/broker-sdk/dist/relaycast.js.map +1 -1
  22. package/packages/broker-sdk/dist/workflows/coordinator.d.ts +1 -0
  23. package/packages/broker-sdk/dist/workflows/coordinator.d.ts.map +1 -1
  24. package/packages/broker-sdk/dist/workflows/coordinator.js +239 -7
  25. package/packages/broker-sdk/dist/workflows/coordinator.js.map +1 -1
  26. package/packages/broker-sdk/dist/workflows/index.d.ts +1 -0
  27. package/packages/broker-sdk/dist/workflows/index.d.ts.map +1 -1
  28. package/packages/broker-sdk/dist/workflows/index.js +1 -0
  29. package/packages/broker-sdk/dist/workflows/index.js.map +1 -1
  30. package/packages/broker-sdk/dist/workflows/run.d.ts +3 -1
  31. package/packages/broker-sdk/dist/workflows/run.d.ts.map +1 -1
  32. package/packages/broker-sdk/dist/workflows/run.js +4 -0
  33. package/packages/broker-sdk/dist/workflows/run.js.map +1 -1
  34. package/packages/broker-sdk/dist/workflows/runner.d.ts +9 -0
  35. package/packages/broker-sdk/dist/workflows/runner.d.ts.map +1 -1
  36. package/packages/broker-sdk/dist/workflows/runner.js +203 -14
  37. package/packages/broker-sdk/dist/workflows/runner.js.map +1 -1
  38. package/packages/broker-sdk/dist/workflows/trajectory.d.ts +80 -0
  39. package/packages/broker-sdk/dist/workflows/trajectory.d.ts.map +1 -0
  40. package/packages/broker-sdk/dist/workflows/trajectory.js +362 -0
  41. package/packages/broker-sdk/dist/workflows/trajectory.js.map +1 -0
  42. package/packages/broker-sdk/dist/workflows/types.d.ts +15 -1
  43. package/packages/broker-sdk/dist/workflows/types.d.ts.map +1 -1
  44. package/packages/broker-sdk/package.json +2 -2
  45. package/packages/broker-sdk/src/__tests__/swarm-coordinator.test.ts +356 -0
  46. package/packages/broker-sdk/src/__tests__/unit.test.ts +92 -1
  47. package/packages/broker-sdk/src/__tests__/workflow-trajectory.test.ts +408 -0
  48. package/packages/broker-sdk/src/client.ts +15 -0
  49. package/packages/broker-sdk/src/protocol.ts +5 -0
  50. package/packages/broker-sdk/src/relay.ts +59 -0
  51. package/packages/broker-sdk/src/relaycast.ts +42 -0
  52. package/packages/broker-sdk/src/workflows/README.md +64 -0
  53. package/packages/broker-sdk/src/workflows/coordinator.ts +246 -8
  54. package/packages/broker-sdk/src/workflows/index.ts +1 -0
  55. package/packages/broker-sdk/src/workflows/run.ts +9 -1
  56. package/packages/broker-sdk/src/workflows/runner.ts +249 -14
  57. package/packages/broker-sdk/src/workflows/schema.json +13 -1
  58. package/packages/broker-sdk/src/workflows/trajectory.ts +507 -0
  59. package/packages/broker-sdk/src/workflows/types.ts +31 -1
  60. package/packages/broker-sdk/tsconfig.json +1 -0
  61. package/packages/broker-sdk/vitest.config.ts +9 -0
  62. package/packages/config/package.json +2 -2
  63. package/packages/continuity/package.json +2 -2
  64. package/packages/daemon/package.json +12 -12
  65. package/packages/hooks/package.json +4 -4
  66. package/packages/mcp/package.json +5 -5
  67. package/packages/memory/package.json +2 -2
  68. package/packages/policy/package.json +2 -2
  69. package/packages/protocol/package.json +1 -1
  70. package/packages/resiliency/package.json +1 -1
  71. package/packages/sdk/package.json +3 -3
  72. package/packages/sdk-py/src/agent_relay/builder.py +4 -0
  73. package/packages/sdk-py/src/agent_relay/types.py +15 -0
  74. package/packages/spawner/package.json +1 -1
  75. package/packages/state/package.json +1 -1
  76. package/packages/storage/package.json +2 -2
  77. package/packages/telemetry/package.json +1 -1
  78. package/packages/trajectory/package.json +2 -2
  79. package/packages/user-directory/package.json +2 -2
  80. package/packages/utils/package.json +3 -3
  81. package/packages/wrapper/package.json +5 -5
@@ -0,0 +1,408 @@
1
+ /**
2
+ * WorkflowTrajectory unit tests.
3
+ *
4
+ * Tests trajectory recording, chapter management, reflections, decisions,
5
+ * confidence computation, and the disabled/enabled toggle.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
9
+ import { existsSync, readFileSync, readdirSync, rmSync, mkdirSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+ import { WorkflowTrajectory, type StepOutcome } from '../workflows/trajectory.js';
13
+
14
+ // ── Test helpers ─────────────────────────────────────────────────────────────
15
+
16
+ let tmpDir: string;
17
+
18
+ function makeTmpDir(): string {
19
+ const dir = path.join(os.tmpdir(), `wf-traj-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
20
+ mkdirSync(dir, { recursive: true });
21
+ return dir;
22
+ }
23
+
24
+ function readTrajectoryFile(dir: string): any {
25
+ const activeDir = path.join(dir, '.trajectories', 'active');
26
+ if (!existsSync(activeDir)) return null;
27
+
28
+ const files = readdirSync(activeDir);
29
+ const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
30
+ if (jsonFiles.length === 0) return null;
31
+
32
+ return JSON.parse(readFileSync(path.join(activeDir, jsonFiles[0]), 'utf-8'));
33
+ }
34
+
35
+ function readCompletedTrajectoryFile(dir: string): any {
36
+ const completedDir = path.join(dir, '.trajectories', 'completed');
37
+ if (!existsSync(completedDir)) return null;
38
+
39
+ const files = readdirSync(completedDir);
40
+ const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
41
+ if (jsonFiles.length === 0) return null;
42
+
43
+ return JSON.parse(readFileSync(path.join(completedDir, jsonFiles[0]), 'utf-8'));
44
+ }
45
+
46
+ // ── Tests ────────────────────────────────────────────────────────────────────
47
+
48
+ describe('WorkflowTrajectory', () => {
49
+ beforeEach(() => {
50
+ tmpDir = makeTmpDir();
51
+ });
52
+
53
+ afterEach(() => {
54
+ try {
55
+ rmSync(tmpDir, { recursive: true, force: true });
56
+ } catch {
57
+ // cleanup best-effort
58
+ }
59
+ });
60
+
61
+ // ── Disabled mode ──────────────────────────────────────────────────────
62
+
63
+ describe('disabled', () => {
64
+ it('should not create files when trajectories is false', async () => {
65
+ const traj = new WorkflowTrajectory(false, 'run-1', tmpDir);
66
+ await traj.start('test-workflow', 3);
67
+
68
+ expect(traj.isEnabled()).toBe(false);
69
+ expect(traj.getTrajectoryId()).toBeNull();
70
+ expect(existsSync(path.join(tmpDir, '.trajectories'))).toBe(false);
71
+ });
72
+
73
+ it('should not create files when enabled is false', async () => {
74
+ const traj = new WorkflowTrajectory({ enabled: false }, 'run-1', tmpDir);
75
+ await traj.start('test-workflow', 3);
76
+
77
+ expect(traj.isEnabled()).toBe(false);
78
+ });
79
+
80
+ it('should be enabled by default', () => {
81
+ const traj = new WorkflowTrajectory(undefined, 'run-1', tmpDir);
82
+ expect(traj.isEnabled()).toBe(true);
83
+ });
84
+ });
85
+
86
+ // ── Lifecycle ──────────────────────────────────────────────────────────
87
+
88
+ describe('lifecycle', () => {
89
+ it('should create a trajectory file on start', async () => {
90
+ const traj = new WorkflowTrajectory({}, 'run-abc', tmpDir);
91
+ await traj.start('my-workflow', 5);
92
+
93
+ expect(traj.getTrajectoryId()).toBeTruthy();
94
+ expect(traj.getTrajectoryId()!.startsWith('traj_')).toBe(true);
95
+
96
+ const data = readTrajectoryFile(tmpDir);
97
+ expect(data).toBeTruthy();
98
+ expect(data.status).toBe('active');
99
+ expect(data.task.title).toContain('my-workflow');
100
+ expect(data.agents).toHaveLength(1);
101
+ expect(data.agents[0].name).toBe('orchestrator');
102
+ });
103
+
104
+ it('should create Planning chapter on start', async () => {
105
+ const traj = new WorkflowTrajectory({}, 'run-abc', tmpDir);
106
+ await traj.start('my-workflow', 3, '3 parallel tracks, 2 barriers');
107
+
108
+ const data = readTrajectoryFile(tmpDir);
109
+ expect(data.chapters).toHaveLength(1);
110
+ expect(data.chapters[0].title).toBe('Planning');
111
+ expect(data.chapters[0].events.length).toBeGreaterThanOrEqual(1);
112
+ });
113
+
114
+ it('should complete trajectory and move to completed dir', async () => {
115
+ const traj = new WorkflowTrajectory({}, 'run-abc', tmpDir);
116
+ await traj.start('my-workflow', 2);
117
+ await traj.complete('All done', 0.95);
118
+
119
+ const active = readTrajectoryFile(tmpDir);
120
+ expect(active).toBeNull(); // Moved out of active
121
+
122
+ const completed = readCompletedTrajectoryFile(tmpDir);
123
+ expect(completed).toBeTruthy();
124
+ expect(completed.status).toBe('completed');
125
+ expect(completed.retrospective.summary).toBe('All done');
126
+ expect(completed.retrospective.confidence).toBe(0.95);
127
+ });
128
+
129
+ it('should abandon trajectory and move to completed dir', async () => {
130
+ const traj = new WorkflowTrajectory({}, 'run-abc', tmpDir);
131
+ await traj.start('my-workflow', 2);
132
+ await traj.abandon('Something went wrong');
133
+
134
+ const completed = readCompletedTrajectoryFile(tmpDir);
135
+ expect(completed).toBeTruthy();
136
+ expect(completed.status).toBe('abandoned');
137
+ });
138
+ });
139
+
140
+ // ── Step events ────────────────────────────────────────────────────────
141
+
142
+ describe('step events', () => {
143
+ it('should record step started', async () => {
144
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
145
+ await traj.start('wf', 2);
146
+ await traj.stepStarted(
147
+ { name: 'build', agent: 'builder', task: 'Build it' },
148
+ 'builder-agent',
149
+ );
150
+
151
+ const data = readTrajectoryFile(tmpDir);
152
+ expect(data.agents).toHaveLength(2); // orchestrator + builder-agent
153
+ const events = data.chapters.flatMap((c: any) => c.events);
154
+ expect(events.some((e: any) => e.content.includes('build'))).toBe(true);
155
+ });
156
+
157
+ it('should record step completed', async () => {
158
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
159
+ await traj.start('wf', 1);
160
+ await traj.stepCompleted(
161
+ { name: 'test', agent: 'tester', task: 'Run tests' },
162
+ 'All tests passing',
163
+ 1,
164
+ );
165
+
166
+ const data = readTrajectoryFile(tmpDir);
167
+ const events = data.chapters.flatMap((c: any) => c.events);
168
+ expect(events.some((e: any) => e.type === 'finding')).toBe(true);
169
+ });
170
+
171
+ it('should record step failed', async () => {
172
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
173
+ await traj.start('wf', 1);
174
+ await traj.stepFailed(
175
+ { name: 'deploy', agent: 'deployer', task: 'Deploy' },
176
+ 'Connection refused',
177
+ 1,
178
+ 3,
179
+ );
180
+
181
+ const data = readTrajectoryFile(tmpDir);
182
+ const events = data.chapters.flatMap((c: any) => c.events);
183
+ expect(events.some((e: any) => e.type === 'error')).toBe(true);
184
+ });
185
+
186
+ it('should record step skipped', async () => {
187
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
188
+ await traj.start('wf', 2);
189
+ await traj.stepSkipped(
190
+ { name: 'integration', agent: 'tester', task: 'Test' },
191
+ 'Upstream failed',
192
+ );
193
+
194
+ const data = readTrajectoryFile(tmpDir);
195
+ const events = data.chapters.flatMap((c: any) => c.events);
196
+ expect(events.some((e: any) => e.content.includes('Skipped'))).toBe(true);
197
+ });
198
+ });
199
+
200
+ // ── Chapters ───────────────────────────────────────────────────────────
201
+
202
+ describe('chapters', () => {
203
+ it('should create track chapters', async () => {
204
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
205
+ await traj.start('wf', 3);
206
+ await traj.beginTrack('backend');
207
+
208
+ const data = readTrajectoryFile(tmpDir);
209
+ expect(data.chapters.length).toBeGreaterThanOrEqual(2);
210
+ expect(data.chapters.some((c: any) => c.title === 'Execution: backend')).toBe(true);
211
+ });
212
+
213
+ it('should create convergence chapters', async () => {
214
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
215
+ await traj.start('wf', 3);
216
+ await traj.beginConvergence('all-tracks-done');
217
+
218
+ const data = readTrajectoryFile(tmpDir);
219
+ expect(data.chapters.some((c: any) => c.title === 'Convergence: all-tracks-done')).toBe(true);
220
+ });
221
+
222
+ it('should close previous chapter when opening new one', async () => {
223
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
224
+ await traj.start('wf', 3);
225
+ await traj.beginTrack('track-a');
226
+ await traj.beginTrack('track-b');
227
+
228
+ const data = readTrajectoryFile(tmpDir);
229
+ // Planning chapter should have endedAt
230
+ expect(data.chapters[0].endedAt).toBeTruthy();
231
+ // First track chapter should have endedAt
232
+ expect(data.chapters[1].endedAt).toBeTruthy();
233
+ });
234
+ });
235
+
236
+ // ── Reflections ────────────────────────────────────────────────────────
237
+
238
+ describe('reflections', () => {
239
+ it('should record reflect events', async () => {
240
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
241
+ await traj.start('wf', 2);
242
+ await traj.reflect('All parallel tracks complete', 0.85, ['step-a: completed', 'step-b: completed']);
243
+
244
+ const data = readTrajectoryFile(tmpDir);
245
+ const events = data.chapters.flatMap((c: any) => c.events);
246
+ const reflection = events.find((e: any) => e.type === 'reflection');
247
+ expect(reflection).toBeTruthy();
248
+ expect(reflection.significance).toBe('high');
249
+ expect(reflection.raw.confidence).toBe(0.85);
250
+ expect(reflection.raw.focalPoints).toHaveLength(2);
251
+ });
252
+
253
+ it('should synthesize and reflect at convergence', async () => {
254
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
255
+ await traj.start('wf', 3);
256
+
257
+ const outcomes: StepOutcome[] = [
258
+ { name: 'step-a', agent: 'a', status: 'completed', attempts: 1 },
259
+ { name: 'step-b', agent: 'b', status: 'completed', attempts: 2 },
260
+ ];
261
+
262
+ await traj.synthesizeAndReflect('backend-ready', outcomes, ['step-c']);
263
+
264
+ const data = readTrajectoryFile(tmpDir);
265
+ // Should have a convergence chapter
266
+ expect(data.chapters.some((c: any) => c.title.includes('Convergence'))).toBe(true);
267
+ const events = data.chapters.flatMap((c: any) => c.events);
268
+ const reflection = events.find((e: any) => e.type === 'reflection');
269
+ expect(reflection).toBeTruthy();
270
+ expect(reflection.content).toContain('backend-ready');
271
+ expect(reflection.content).toContain('step-b'); // retried
272
+ });
273
+ });
274
+
275
+ // ── Decisions ──────────────────────────────────────────────────────────
276
+
277
+ describe('decisions', () => {
278
+ it('should record decisions', async () => {
279
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
280
+ await traj.start('wf', 1);
281
+ await traj.decide('How to handle failure', 'retry', 'Transient error detected');
282
+
283
+ const data = readTrajectoryFile(tmpDir);
284
+ const events = data.chapters.flatMap((c: any) => c.events);
285
+ const decision = events.find((e: any) => e.type === 'decision');
286
+ expect(decision).toBeTruthy();
287
+ expect(decision.raw.chosen).toBe('retry');
288
+ });
289
+
290
+ it('should skip decisions when autoDecisions is false', async () => {
291
+ const traj = new WorkflowTrajectory({ autoDecisions: false }, 'run-1', tmpDir);
292
+ await traj.start('wf', 1);
293
+ await traj.decide('How to handle failure', 'retry', 'Transient error');
294
+
295
+ const data = readTrajectoryFile(tmpDir);
296
+ const events = data.chapters.flatMap((c: any) => c.events);
297
+ expect(events.filter((e: any) => e.type === 'decision')).toHaveLength(0);
298
+ });
299
+ });
300
+
301
+ // ── Confidence computation ─────────────────────────────────────────────
302
+
303
+ describe('computeConfidence', () => {
304
+ it('should return 1.0 for all first-attempt verified completions', () => {
305
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
306
+ const outcomes: StepOutcome[] = [
307
+ { name: 'a', agent: 'a', status: 'completed', attempts: 1, verificationPassed: true },
308
+ { name: 'b', agent: 'b', status: 'completed', attempts: 1, verificationPassed: true },
309
+ ];
310
+ expect(traj.computeConfidence(outcomes)).toBe(1.0);
311
+ });
312
+
313
+ it('should return lower confidence for retried steps', () => {
314
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
315
+ const outcomes: StepOutcome[] = [
316
+ { name: 'a', agent: 'a', status: 'completed', attempts: 1, verificationPassed: true },
317
+ { name: 'b', agent: 'b', status: 'completed', attempts: 3, verificationPassed: true },
318
+ ];
319
+ const confidence = traj.computeConfidence(outcomes);
320
+ expect(confidence).toBeLessThan(1.0);
321
+ expect(confidence).toBeGreaterThan(0.5);
322
+ });
323
+
324
+ it('should return lower confidence for failed steps', () => {
325
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
326
+ const outcomes: StepOutcome[] = [
327
+ { name: 'a', agent: 'a', status: 'completed', attempts: 1 },
328
+ { name: 'b', agent: 'b', status: 'failed', attempts: 3 },
329
+ ];
330
+ const confidence = traj.computeConfidence(outcomes);
331
+ expect(confidence).toBeLessThan(0.5);
332
+ });
333
+
334
+ it('should return 0.7 for empty outcomes', () => {
335
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
336
+ expect(traj.computeConfidence([])).toBe(0.7);
337
+ });
338
+ });
339
+
340
+ // ── Synthesis helpers ──────────────────────────────────────────────────
341
+
342
+ describe('buildSynthesis', () => {
343
+ it('should produce meaningful synthesis text', () => {
344
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
345
+ const outcomes: StepOutcome[] = [
346
+ { name: 'step-a', agent: 'a', status: 'completed', attempts: 1 },
347
+ { name: 'step-b', agent: 'b', status: 'completed', attempts: 2 },
348
+ { name: 'step-c', agent: 'c', status: 'failed', attempts: 3, error: 'timeout' },
349
+ ];
350
+
351
+ const synthesis = traj.buildSynthesis('barrier-1', outcomes, ['step-d']);
352
+ expect(synthesis).toContain('barrier-1');
353
+ expect(synthesis).toContain('2/3 steps completed');
354
+ expect(synthesis).toContain('step-c'); // failed
355
+ expect(synthesis).toContain('step-b'); // retried
356
+ expect(synthesis).toContain('step-d'); // unblocked
357
+ });
358
+
359
+ it('should note all-first-attempt when no retries', () => {
360
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
361
+ const outcomes: StepOutcome[] = [
362
+ { name: 'a', agent: 'a', status: 'completed', attempts: 1 },
363
+ { name: 'b', agent: 'b', status: 'completed', attempts: 1 },
364
+ ];
365
+
366
+ const synthesis = traj.buildSynthesis('done', outcomes);
367
+ expect(synthesis).toContain('All steps completed on first attempt');
368
+ });
369
+ });
370
+
371
+ describe('buildRunSummary', () => {
372
+ it('should produce run summary with stats', () => {
373
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
374
+ const outcomes: StepOutcome[] = [
375
+ { name: 'a', agent: 'a', status: 'completed', attempts: 1 },
376
+ { name: 'b', agent: 'b', status: 'completed', attempts: 2 },
377
+ { name: 'c', agent: 'c', status: 'failed', attempts: 3 },
378
+ { name: 'd', agent: 'd', status: 'skipped', attempts: 1 },
379
+ ];
380
+
381
+ const summary = traj.buildRunSummary(outcomes);
382
+ expect(summary).toContain('2/4 steps passed');
383
+ expect(summary).toContain('1 failed');
384
+ expect(summary).toContain('1 skipped');
385
+ expect(summary).toContain('retries');
386
+ });
387
+ });
388
+
389
+ // ── Non-blocking behavior ──────────────────────────────────────────────
390
+
391
+ describe('non-blocking', () => {
392
+ it('should not throw on flush errors', async () => {
393
+ // Use a path that will fail (read-only or invalid)
394
+ const traj = new WorkflowTrajectory({}, 'run-1', '/dev/null/impossible-path');
395
+ // Should not throw
396
+ await expect(traj.start('wf', 1)).resolves.not.toThrow();
397
+ });
398
+
399
+ it('should handle all methods gracefully when not started', async () => {
400
+ const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
401
+ // Don't call start — all methods should be no-ops
402
+ await expect(traj.stepStarted({ name: 'x', agent: 'a', task: 't' }, 'a')).resolves.not.toThrow();
403
+ await expect(traj.reflect('test', 0.5)).resolves.not.toThrow();
404
+ await expect(traj.decide('q', 'c', 'r')).resolves.not.toThrow();
405
+ await expect(traj.complete('done', 0.9)).resolves.not.toThrow();
406
+ });
407
+ });
408
+ });
@@ -35,6 +35,8 @@ export interface SpawnPtyInput {
35
35
  args?: string[];
36
36
  channels?: string[];
37
37
  task?: string;
38
+ /** Silence duration in seconds before emitting agent_idle (0 = disabled, default: 30). */
39
+ idleThresholdSecs?: number;
38
40
  }
39
41
 
40
42
  export interface SpawnHeadlessClaudeInput {
@@ -170,6 +172,7 @@ export class AgentRelayClient {
170
172
  const result = await this.requestOk<{ name: string; runtime: AgentRuntime }>("spawn_agent", {
171
173
  agent,
172
174
  ...(input.task != null ? { initial_task: input.task } : {}),
175
+ ...(input.idleThresholdSecs != null ? { idle_threshold_secs: input.idleThresholdSecs } : {}),
173
176
  });
174
177
  return result;
175
178
  }
@@ -486,10 +489,22 @@ function isExplicitPath(binaryPath: string): boolean {
486
489
 
487
490
  function resolveDefaultBinaryPath(): string {
488
491
  const exe = process.platform === "win32" ? "agent-relay.exe" : "agent-relay";
492
+ const brokerExe = process.platform === "win32" ? "agent-relay-broker.exe" : "agent-relay-broker";
493
+
494
+ // 1. Check for bundled broker binary in SDK package (npm install)
489
495
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
490
496
  const bundled = path.resolve(moduleDir, "..", "bin", exe);
491
497
  if (fs.existsSync(bundled)) {
492
498
  return bundled;
493
499
  }
500
+
501
+ // 2. Check for standalone broker binary in ~/.agent-relay/bin/ (install.sh)
502
+ const homeDir = process.env.HOME || process.env.USERPROFILE || "";
503
+ const standaloneBroker = path.join(homeDir, ".agent-relay", "bin", brokerExe);
504
+ if (fs.existsSync(standaloneBroker)) {
505
+ return standaloneBroker;
506
+ }
507
+
508
+ // 3. Fall back to agent-relay on PATH (may be Node CLI — will fail for broker ops)
494
509
  return "agent-relay";
495
510
  }
@@ -201,6 +201,11 @@ export type BrokerEvent =
201
201
  name: string;
202
202
  sender: string;
203
203
  owner_chain: string[];
204
+ }
205
+ | {
206
+ kind: "agent_idle";
207
+ name: string;
208
+ idle_secs: number;
204
209
  };
205
210
 
206
211
  export type BrokerToSdk =
@@ -76,6 +76,10 @@ export interface Agent {
76
76
  * @param timeoutMs — optional timeout in ms. Resolves with `"timeout"` if exceeded,
77
77
  * `"exited"` if the agent exited naturally, or `"released"` if released externally. */
78
78
  waitForExit(timeoutMs?: number): Promise<"exited" | "timeout" | "released">;
79
+ /** Wait for the agent to go idle (no PTY output for the configured threshold).
80
+ * @param timeoutMs — optional timeout in ms. Resolves with `"idle"` when first idle event fires,
81
+ * `"timeout"` if timeoutMs elapses first, or `"exited"` if the agent exits. */
82
+ waitForIdle(timeoutMs?: number): Promise<"idle" | "timeout" | "exited">;
79
83
  sendMessage(input: {
80
84
  to: string;
81
85
  text: string;
@@ -128,6 +132,7 @@ export class AgentRelay {
128
132
  onAgentReady: EventHook<Agent> = null;
129
133
  onWorkerOutput: EventHook<{ name: string; stream: string; chunk: string }> = null;
130
134
  onDeliveryUpdate: EventHook<BrokerEvent> = null;
135
+ onAgentIdle: EventHook<{ name: string; idleSecs: number }> = null;
131
136
 
132
137
  // Shorthand spawners
133
138
  readonly codex: AgentSpawner;
@@ -145,6 +150,11 @@ export class AgentRelay {
145
150
  { resolve: (reason: "exited" | "released") => void; token: number }
146
151
  >();
147
152
  private exitResolverSeq = 0;
153
+ private readonly idleResolvers = new Map<
154
+ string,
155
+ { resolve: (reason: "idle" | "timeout" | "exited") => void; token: number }
156
+ >();
157
+ private idleResolverSeq = 0;
148
158
  private readonly relaycastByName = new Map<string, RelaycastApi>();
149
159
 
150
160
  constructor(options: AgentRelayOptions = {}) {
@@ -176,6 +186,7 @@ export class AgentRelay {
176
186
  args: input.args,
177
187
  channels,
178
188
  task: input.task,
189
+ idleThresholdSecs: input.idleThresholdSecs,
179
190
  });
180
191
  const agent = this.makeAgent(result.name, result.runtime, channels);
181
192
  this.knownAgents.set(agent.name, agent);
@@ -326,6 +337,10 @@ export class AgentRelay {
326
337
  entry.resolve("released");
327
338
  }
328
339
  this.exitResolvers.clear();
340
+ for (const entry of this.idleResolvers.values()) {
341
+ entry.resolve("exited");
342
+ }
343
+ this.idleResolvers.clear();
329
344
  }
330
345
 
331
346
  // ── Private helpers ─────────────────────────────────────────────────────
@@ -403,6 +418,8 @@ export class AgentRelay {
403
418
  this.knownAgents.delete(event.name);
404
419
  this.exitResolvers.get(event.name)?.resolve("released");
405
420
  this.exitResolvers.delete(event.name);
421
+ this.idleResolvers.get(event.name)?.resolve("exited");
422
+ this.idleResolvers.delete(event.name);
406
423
  break;
407
424
  }
408
425
  case "agent_exited": {
@@ -416,6 +433,8 @@ export class AgentRelay {
416
433
  this.knownAgents.delete(event.name);
417
434
  this.exitResolvers.get(event.name)?.resolve("exited");
418
435
  this.exitResolvers.delete(event.name);
436
+ this.idleResolvers.get(event.name)?.resolve("exited");
437
+ this.idleResolvers.delete(event.name);
419
438
  break;
420
439
  }
421
440
  case "worker_ready": {
@@ -435,6 +454,16 @@ export class AgentRelay {
435
454
  });
436
455
  break;
437
456
  }
457
+ case "agent_idle": {
458
+ this.onAgentIdle?.({
459
+ name: event.name,
460
+ idleSecs: event.idle_secs,
461
+ });
462
+ // Resolve idle waiters
463
+ this.idleResolvers.get(event.name)?.resolve("idle");
464
+ this.idleResolvers.delete(event.name);
465
+ break;
466
+ }
438
467
  }
439
468
  if (event.kind.startsWith("delivery_")) {
440
469
  this.onDeliveryUpdate?.(event);
@@ -491,6 +520,36 @@ export class AgentRelay {
491
520
  }
492
521
  });
493
522
  },
523
+ waitForIdle(timeoutMs?: number) {
524
+ return new Promise<"idle" | "timeout" | "exited">((resolve) => {
525
+ if (!relay.knownAgents.has(name)) {
526
+ resolve("exited");
527
+ return;
528
+ }
529
+ if (timeoutMs === 0) {
530
+ resolve("timeout");
531
+ return;
532
+ }
533
+ let timer: ReturnType<typeof setTimeout> | undefined;
534
+ const token = ++relay.idleResolverSeq;
535
+ relay.idleResolvers.set(name, {
536
+ resolve(reason) {
537
+ if (timer) clearTimeout(timer);
538
+ resolve(reason);
539
+ },
540
+ token,
541
+ });
542
+ if (timeoutMs !== undefined) {
543
+ timer = setTimeout(() => {
544
+ const current = relay.idleResolvers.get(name);
545
+ if (current?.token === token) {
546
+ relay.idleResolvers.delete(name);
547
+ }
548
+ resolve("timeout");
549
+ }, timeoutMs);
550
+ }
551
+ });
552
+ },
494
553
  async sendMessage(input) {
495
554
  const client = await relay.ensureStarted();
496
555
  let result: Awaited<ReturnType<typeof client.sendMessage>>;
@@ -126,6 +126,48 @@ export class RelaycastApi {
126
126
  }
127
127
  }
128
128
 
129
+ /** Create a channel. No-op if it already exists. */
130
+ async createChannel(name: string, topic?: string): Promise<void> {
131
+ const agent = await this.ensure();
132
+ try {
133
+ await agent.channels.create({ name, ...(topic ? { topic } : {}) });
134
+ } catch (err) {
135
+ // Ignore "already exists" errors
136
+ if (err instanceof RelayError && err.code === "channel_already_exists") {
137
+ return;
138
+ }
139
+ throw err;
140
+ }
141
+ }
142
+
143
+ /** Join a channel. Idempotent. */
144
+ async joinChannel(name: string): Promise<void> {
145
+ const agent = await this.ensure();
146
+ await agent.channels.join(name);
147
+ }
148
+
149
+ /** Invite another agent to a channel. */
150
+ async inviteToChannel(channel: string, agentName: string): Promise<void> {
151
+ const agent = await this.ensure();
152
+ await agent.channels.invite(channel, agentName);
153
+ }
154
+
155
+ /** Register an external agent in the workspace (e.g., a spawned workflow agent).
156
+ * Uses the workspace API key to register, not an agent token.
157
+ * No-op if the agent already exists. */
158
+ async registerExternalAgent(name: string, persona?: string): Promise<void> {
159
+ const apiKey = await this.resolveApiKey();
160
+ const relay = new RelayCast({ apiKey, baseUrl: this.baseUrl });
161
+ try {
162
+ await relay.agents.register({ name, type: "agent", ...(persona ? { persona } : {}) });
163
+ } catch (err) {
164
+ if (err instanceof RelayError && err.code === "agent_already_exists") {
165
+ return;
166
+ }
167
+ throw err;
168
+ }
169
+ }
170
+
129
171
  /** Fetch message history from a channel. */
130
172
  async getMessages(
131
173
  channel: string,