@agent-relay/dashboard-server 2.0.62 → 2.0.63

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 (62) hide show
  1. package/dist/server.js +185 -93
  2. package/dist/server.js.map +1 -1
  3. package/dist/types/index.d.ts +27 -0
  4. package/dist/types/index.d.ts.map +1 -1
  5. package/out/404.html +1 -1
  6. package/out/_next/static/chunks/873-6b31247a84ec58c2.js +1 -0
  7. package/out/about.html +1 -1
  8. package/out/about.txt +1 -1
  9. package/out/app/onboarding.html +1 -1
  10. package/out/app/onboarding.txt +1 -1
  11. package/out/app.html +1 -1
  12. package/out/app.txt +2 -2
  13. package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +1 -1
  14. package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
  15. package/out/blog/let-them-cook-multi-agent-orchestration.html +1 -1
  16. package/out/blog/let-them-cook-multi-agent-orchestration.txt +1 -1
  17. package/out/blog.html +1 -1
  18. package/out/blog.txt +1 -1
  19. package/out/careers.html +1 -1
  20. package/out/careers.txt +1 -1
  21. package/out/changelog.html +1 -1
  22. package/out/changelog.txt +1 -1
  23. package/out/cloud/link.html +1 -1
  24. package/out/cloud/link.txt +1 -1
  25. package/out/complete-profile.html +1 -1
  26. package/out/complete-profile.txt +1 -1
  27. package/out/connect-repos.html +1 -1
  28. package/out/connect-repos.txt +1 -1
  29. package/out/contact.html +1 -1
  30. package/out/contact.txt +1 -1
  31. package/out/docs.html +1 -1
  32. package/out/docs.txt +1 -1
  33. package/out/history.html +1 -1
  34. package/out/history.txt +1 -1
  35. package/out/index.html +1 -1
  36. package/out/index.txt +2 -2
  37. package/out/login.html +1 -1
  38. package/out/login.txt +1 -1
  39. package/out/metrics.html +1 -1
  40. package/out/metrics.txt +1 -1
  41. package/out/pricing.html +1 -1
  42. package/out/pricing.txt +1 -1
  43. package/out/privacy.html +1 -1
  44. package/out/privacy.txt +1 -1
  45. package/out/providers/setup/claude.html +1 -1
  46. package/out/providers/setup/claude.txt +1 -1
  47. package/out/providers/setup/codex.html +1 -1
  48. package/out/providers/setup/codex.txt +1 -1
  49. package/out/providers/setup/cursor.html +1 -1
  50. package/out/providers/setup/cursor.txt +1 -1
  51. package/out/providers.html +1 -1
  52. package/out/providers.txt +1 -1
  53. package/out/security.html +1 -1
  54. package/out/security.txt +1 -1
  55. package/out/signup.html +1 -1
  56. package/out/signup.txt +1 -1
  57. package/out/terms.html +1 -1
  58. package/out/terms.txt +1 -1
  59. package/package.json +10 -10
  60. package/out/_next/static/chunks/873-609efa769bceebeb.js +0 -1
  61. /package/out/_next/static/{dZJGDdzFNEYs9W9huG7Nx → Ip08bs-aI4i94zrABOaVi}/_buildManifest.js +0 -0
  62. /package/out/_next/static/{dZJGDdzFNEYs9W9huG7Nx → Ip08bs-aI4i94zrABOaVi}/_ssgManifest.js +0 -0
package/dist/server.js CHANGED
@@ -318,7 +318,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
318
318
  const options = typeof portOrOptions === 'number'
319
319
  ? { port: portOrOptions, dataDir: dataDirArg, teamDir: teamDirArg, dbPath: dbPathArg }
320
320
  : portOrOptions;
321
- const { port, dataDir, teamDir, dbPath, enableSpawner, projectRoot, tmuxSession, onMarkSpawning, onClearSpawning, verbose } = options;
321
+ const { port, dataDir, teamDir, dbPath, enableSpawner, projectRoot, tmuxSession, onMarkSpawning, onClearSpawning, verbose, spawnManager: externalSpawnManager } = options;
322
322
  // Debug logging helper - only logs when verbose is true or VERBOSE env var is set
323
323
  const isVerbose = verbose || process.env.VERBOSE === 'true';
324
324
  const debug = (message) => {
@@ -483,9 +483,12 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
483
483
  // Use detectWorkspacePath to find the actual repo directory in cloud workspaces
484
484
  const workspacePath = detectWorkspacePath(projectRoot || dataDir);
485
485
  console.log(`[dashboard] Workspace path: ${workspacePath}`);
486
- // Pass dashboard port to spawner so spawned agents can call spawn/release APIs for nested spawning
487
- // Also pass spawn tracking callbacks so messages can be queued before HELLO completes
488
- const spawner = enableSpawner
486
+ // When an external SpawnManager is provided (from the daemon), use it for read operations
487
+ // (logs, worker listing, hasWorker) and route spawn/release through the SDK client.
488
+ // This solves relay-pty binary resolution issues in npx/global installs.
489
+ // Fall back to creating a local AgentSpawner only when no SpawnManager is provided.
490
+ const useExternalSpawnManager = !!externalSpawnManager;
491
+ const spawner = enableSpawner && !useExternalSpawnManager
489
492
  ? new AgentSpawner({
490
493
  projectRoot: workspacePath,
491
494
  tmuxSession,
@@ -494,8 +497,16 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
494
497
  onClearSpawning,
495
498
  })
496
499
  : undefined;
500
+ // spawnReader provides read-only access to spawner state.
501
+ // Uses the external SpawnManager when available (daemon co-located),
502
+ // otherwise falls back to the local AgentSpawner.
503
+ const spawnReader = externalSpawnManager || spawner;
504
+ if (useExternalSpawnManager) {
505
+ console.log('[dashboard] Using daemon SpawnManager for spawn operations (SDK-routed)');
506
+ }
497
507
  // Initialize cloud persistence and memory monitoring if enabled (RELAY_CLOUD_ENABLED=true)
498
- if (spawner) {
508
+ // Only needed for local AgentSpawner; daemon's SpawnManager handles its own cloud persistence
509
+ if (spawner && !useExternalSpawnManager) {
499
510
  // Use workspace ID from env or generate from project root
500
511
  const workspaceId = process.env.RELAY_WORKSPACE_ID ||
501
512
  crypto.createHash('sha256').update(projectRoot || dataDir).digest('hex').slice(0, 36);
@@ -506,28 +517,27 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
506
517
  }).catch((err) => {
507
518
  console.warn('[dashboard] Failed to initialize cloud persistence:', err);
508
519
  });
509
- // Initialize memory monitoring for cloud deployments
510
- // Memory monitoring is enabled by default when cloud is enabled
511
- if (process.env.RELAY_CLOUD_ENABLED === 'true' || process.env.RELAY_MEMORY_MONITORING === 'true') {
512
- try {
513
- const memoryMonitor = getMemoryMonitor({
514
- checkIntervalMs: 10000, // Check every 10 seconds
515
- enableTrendAnalysis: true,
516
- enableProactiveAlerts: true,
517
- });
518
- memoryMonitor.start();
519
- console.log('[dashboard] Memory monitoring enabled');
520
- // Register existing workers with memory monitor
521
- const workers = spawner.getActiveWorkers();
522
- for (const worker of workers) {
523
- if (worker.pid) {
524
- memoryMonitor.register(worker.name, worker.pid);
525
- }
520
+ }
521
+ // Initialize memory monitoring for cloud deployments
522
+ if (spawnReader && (process.env.RELAY_CLOUD_ENABLED === 'true' || process.env.RELAY_MEMORY_MONITORING === 'true')) {
523
+ try {
524
+ const memoryMonitor = getMemoryMonitor({
525
+ checkIntervalMs: 10000, // Check every 10 seconds
526
+ enableTrendAnalysis: true,
527
+ enableProactiveAlerts: true,
528
+ });
529
+ memoryMonitor.start();
530
+ console.log('[dashboard] Memory monitoring enabled');
531
+ // Register existing workers with memory monitor
532
+ const workers = spawnReader.getActiveWorkers();
533
+ for (const worker of workers) {
534
+ if (worker.pid) {
535
+ memoryMonitor.register(worker.name, worker.pid);
526
536
  }
527
537
  }
528
- catch (err) {
529
- console.warn('[dashboard] Failed to initialize memory monitoring:', err);
530
- }
538
+ }
539
+ catch (err) {
540
+ console.warn('[dashboard] Failed to initialize memory monitoring:', err);
531
541
  }
532
542
  }
533
543
  process.on('uncaughtException', (err) => {
@@ -1162,8 +1172,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1162
1172
  }
1163
1173
  }
1164
1174
  // Check spawner's active workers (they have accurate team info for spawned agents)
1165
- if (spawner) {
1166
- const activeWorkers = spawner.getActiveWorkers();
1175
+ if (spawnReader) {
1176
+ const activeWorkers = spawnReader.getActiveWorkers();
1167
1177
  for (const worker of activeWorkers) {
1168
1178
  if (worker.team === teamName) {
1169
1179
  members.add(worker.name);
@@ -1466,8 +1476,16 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1466
1476
  // Helper to check if an agent name is internal/system (should be hidden from UI)
1467
1477
  // Convention: agent names starting with __ are internal (e.g., __spawner__, __DashboardBridge__)
1468
1478
  const isInternalAgent = (name) => {
1479
+ if (name === '__cli_sender__')
1480
+ return false;
1469
1481
  return name.startsWith('__');
1470
1482
  };
1483
+ // Display-name remapping for CLI sender (used across message and history endpoints)
1484
+ const remapAgentName = (name) => {
1485
+ if (name === '__cli_sender__')
1486
+ return 'CLI';
1487
+ return name;
1488
+ };
1471
1489
  const buildThreadSummaryMap = (rows) => {
1472
1490
  const summaries = new Map();
1473
1491
  for (const row of rows) {
@@ -1514,6 +1532,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1514
1532
  let attachments;
1515
1533
  let channel;
1516
1534
  let effectiveFrom = row.from;
1535
+ let effectiveTo = row.to;
1517
1536
  if (row.data && typeof row.data === 'object') {
1518
1537
  if ('attachments' in row.data) {
1519
1538
  attachments = row.data.attachments;
@@ -1527,9 +1546,11 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1527
1546
  effectiveFrom = row.data.senderName;
1528
1547
  }
1529
1548
  }
1549
+ effectiveFrom = remapAgentName(effectiveFrom);
1550
+ effectiveTo = remapAgentName(effectiveTo);
1530
1551
  return {
1531
1552
  from: effectiveFrom,
1532
- to: row.to,
1553
+ to: effectiveTo,
1533
1554
  content: row.body,
1534
1555
  timestamp: new Date(row.ts).toISOString(),
1535
1556
  id: row.id,
@@ -1776,8 +1797,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1776
1797
  }
1777
1798
  }
1778
1799
  // Mark spawned agents with isSpawned flag and team
1779
- if (spawner) {
1780
- const activeWorkers = spawner.getActiveWorkers();
1800
+ if (spawnReader) {
1801
+ const activeWorkers = spawnReader.getActiveWorkers();
1781
1802
  for (const worker of activeWorkers) {
1782
1803
  const agent = agentsMap.get(worker.name);
1783
1804
  if (agent) {
@@ -2186,7 +2207,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2186
2207
  };
2187
2208
  // Helper to subscribe to an agent (async to handle spawn timing)
2188
2209
  const subscribeToAgent = async (agentName) => {
2189
- let isSpawned = spawner?.hasWorker(agentName) ?? false;
2210
+ let isSpawned = spawnReader?.hasWorker(agentName) ?? false;
2190
2211
  const isDaemon = isDaemonConnected(agentName);
2191
2212
  // Check if agent exists (either spawned or daemon-connected)
2192
2213
  if (!isSpawned && !isDaemon) {
@@ -2203,14 +2224,14 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2203
2224
  // poll to handle race condition between spawn API returning and
2204
2225
  // WebSocket connection. This is common for setup agents (__setup__*).
2205
2226
  // Longer timeout for CLI auth flows (Cursor, etc.) which can take time to initialize.
2206
- if (!isSpawned && isDaemon && spawner) {
2227
+ if (!isSpawned && isDaemon && spawnReader) {
2207
2228
  const isSetupAgent = agentName.startsWith('__setup__');
2208
2229
  const maxWaitMs = isSetupAgent ? 90000 : 5000; // 90s for setup agents (CLI auth can be slow), 5s otherwise
2209
2230
  const pollIntervalMs = 100;
2210
2231
  const startTime = Date.now();
2211
2232
  while (Date.now() - startTime < maxWaitMs) {
2212
2233
  await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
2213
- isSpawned = spawner.hasWorker(agentName);
2234
+ isSpawned = spawnReader.hasWorker(agentName);
2214
2235
  if (isSpawned) {
2215
2236
  console.log(`[dashboard] Agent ${agentName} appeared in spawner after ${Date.now() - startTime}ms`);
2216
2237
  break;
@@ -2228,9 +2249,9 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2228
2249
  }
2229
2250
  logSubscriptions.get(agentName).add(ws);
2230
2251
  debug(`[dashboard] Client subscribed to logs for: ${agentName} (spawned: ${isSpawned}, daemon: ${isDaemon})`);
2231
- if (isSpawned && spawner) {
2252
+ if (isSpawned && spawnReader) {
2232
2253
  // Send initial log history for spawned agents (5000 lines to match xterm scrollback capacity)
2233
- const lines = spawner.getWorkerOutput(agentName, 5000);
2254
+ const lines = spawnReader.getWorkerOutput(agentName, 5000);
2234
2255
  ws.send(JSON.stringify({
2235
2256
  type: 'history',
2236
2257
  agent: agentName,
@@ -2326,8 +2347,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2326
2347
  return;
2327
2348
  }
2328
2349
  // Check if this is a spawned agent (we can only send input to spawned agents)
2329
- if (spawner?.hasWorker(agentName)) {
2330
- const success = spawner.sendWorkerInput(agentName, msg.data);
2350
+ if (spawnReader?.hasWorker(agentName)) {
2351
+ const success = spawnReader.sendWorkerInput(agentName, msg.data);
2331
2352
  if (!success) {
2332
2353
  console.warn(`[dashboard] Failed to send input to agent ${agentName}`);
2333
2354
  }
@@ -2438,10 +2459,17 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2438
2459
  }
2439
2460
  });
2440
2461
  };
2441
- // Helper to broadcast channel messages to all presence clients
2442
- // This is used by fallback relay clients to forward messages to cloud-connected users
2462
+ // Helper to broadcast channel messages to all connected clients
2463
+ // Broadcasts to both main wss (local mode) and wssPresence (cloud mode)
2443
2464
  const broadcastChannelMessage = (message) => {
2444
2465
  const payload = JSON.stringify(message);
2466
+ // Broadcast to main WebSocket clients (local mode)
2467
+ wss.clients.forEach((client) => {
2468
+ if (client.readyState === WebSocket.OPEN) {
2469
+ client.send(payload);
2470
+ }
2471
+ });
2472
+ // Also broadcast to presence WebSocket clients (cloud mode)
2445
2473
  wssPresence.clients.forEach((client) => {
2446
2474
  if (client.readyState === WebSocket.OPEN) {
2447
2475
  client.send(payload);
@@ -2829,8 +2857,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2829
2857
  try {
2830
2858
  const availableAgents = [];
2831
2859
  // Get spawned agents from spawner
2832
- if (spawner) {
2833
- const activeWorkers = spawner.getActiveWorkers();
2860
+ if (spawnReader) {
2861
+ const activeWorkers = spawnReader.getActiveWorkers();
2834
2862
  for (const worker of activeWorkers) {
2835
2863
  availableAgents.push({
2836
2864
  id: worker.name,
@@ -3234,6 +3262,9 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
3234
3262
  if (workspaceId && data?._workspaceId && data._workspaceId !== workspaceId) {
3235
3263
  return false;
3236
3264
  }
3265
+ // Filter out internal/system agents (e.g., __system__, __spawner__)
3266
+ if (isInternalAgent(m.from))
3267
+ return false;
3237
3268
  // Accept message if it has _isChannelMessage flag OR if it's addressed to a channel
3238
3269
  return Boolean(data?._isChannelMessage) || (m.to && m.to.startsWith('#'));
3239
3270
  });
@@ -3960,8 +3991,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
3960
3991
  try {
3961
3992
  const agents = [];
3962
3993
  // Get metrics from spawner's active workers
3963
- if (spawner) {
3964
- const activeWorkers = spawner.getActiveWorkers();
3994
+ if (spawnReader) {
3995
+ const activeWorkers = spawnReader.getActiveWorkers();
3965
3996
  for (const worker of activeWorkers) {
3966
3997
  // Get memory and CPU usage
3967
3998
  let rssBytes = 0;
@@ -4059,8 +4090,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4059
4090
  const totalCrashes24h = 0;
4060
4091
  let totalAlerts24h = 0;
4061
4092
  // Get spawned agent count
4062
- if (spawner) {
4063
- const workers = spawner.getActiveWorkers();
4093
+ if (spawnReader) {
4094
+ const workers = spawnReader.getActiveWorkers();
4064
4095
  agentCount = workers.length;
4065
4096
  // Check for high memory usage
4066
4097
  for (const worker of workers) {
@@ -4293,8 +4324,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4293
4324
  }
4294
4325
  const result = messages.map(m => ({
4295
4326
  id: m.id,
4296
- from: m.from,
4297
- to: m.to,
4327
+ from: remapAgentName(m.from),
4328
+ to: remapAgentName(m.to),
4298
4329
  content: m.body,
4299
4330
  timestamp: new Date(m.ts).toISOString(),
4300
4331
  thread: m.thread,
@@ -4329,8 +4360,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4329
4360
  // Skip messages from/to internal system agents (e.g., __spawner__)
4330
4361
  if (isInternalAgent(msg.from) || isInternalAgent(msg.to))
4331
4362
  continue;
4332
- // Create normalized key (sorted participants)
4333
- const participants = [msg.from, msg.to].sort();
4363
+ // Create normalized key (sorted participants, with display names)
4364
+ const participants = [remapAgentName(msg.from), remapAgentName(msg.to)].sort();
4334
4365
  const key = participants.join(':');
4335
4366
  const existing = conversationMap.get(key);
4336
4367
  if (existing) {
@@ -4438,19 +4469,19 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4438
4469
  * - raw: If 'true', return raw output instead of cleaned lines
4439
4470
  */
4440
4471
  app.get('/api/logs/:name', (req, res) => {
4441
- if (!spawner) {
4472
+ if (!spawnReader) {
4442
4473
  return res.status(503).json({ error: 'Spawner not enabled' });
4443
4474
  }
4444
4475
  const { name } = req.params;
4445
4476
  const limit = req.query.limit ? parseInt(req.query.limit, 10) : 500;
4446
4477
  const raw = req.query.raw === 'true';
4447
4478
  // Check if worker exists
4448
- if (!spawner.hasWorker(name)) {
4479
+ if (!spawnReader.hasWorker(name)) {
4449
4480
  return res.status(404).json({ error: `Agent ${name} not found` });
4450
4481
  }
4451
4482
  try {
4452
4483
  if (raw) {
4453
- const output = spawner.getWorkerRawOutput(name);
4484
+ const output = spawnReader.getWorkerRawOutput(name);
4454
4485
  res.json({
4455
4486
  name,
4456
4487
  raw: true,
@@ -4459,7 +4490,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4459
4490
  });
4460
4491
  }
4461
4492
  else {
4462
- const lines = spawner.getWorkerOutput(name, limit);
4493
+ const lines = spawnReader.getWorkerOutput(name, limit);
4463
4494
  res.json({
4464
4495
  name,
4465
4496
  raw: false,
@@ -4478,11 +4509,11 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4478
4509
  * GET /api/logs - List all agents with available logs
4479
4510
  */
4480
4511
  app.get('/api/logs', (req, res) => {
4481
- if (!spawner) {
4512
+ if (!spawnReader) {
4482
4513
  return res.status(503).json({ error: 'Spawner not enabled' });
4483
4514
  }
4484
4515
  try {
4485
- const workers = spawner.getActiveWorkers();
4516
+ const workers = spawnReader.getActiveWorkers();
4486
4517
  const agents = workers.map(w => ({
4487
4518
  name: w.name,
4488
4519
  cli: w.cli,
@@ -4513,7 +4544,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4513
4544
  * Body: { name: string, cli?: string, task?: string, team?: string, spawnerName?, cwd?, interactive?, shadowMode?, shadowAgent?, shadowOf?, shadowTriggers?, shadowSpeakOn? }
4514
4545
  */
4515
4546
  app.post('/api/spawn', async (req, res) => {
4516
- if (!spawner) {
4547
+ if (!spawner && !useExternalSpawnManager) {
4517
4548
  return res.status(503).json({
4518
4549
  success: false,
4519
4550
  error: 'Spawner not enabled. Start dashboard with enableSpawner: true',
@@ -4527,23 +4558,56 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4527
4558
  });
4528
4559
  }
4529
4560
  try {
4530
- const request = {
4531
- name,
4532
- cli,
4533
- task,
4534
- team: team || undefined, // Optional team name
4535
- spawnerName: spawnerName || undefined, // For policy enforcement
4536
- cwd: cwd || undefined, // Working directory
4537
- interactive, // Disables auto-accept for auth setup flows
4538
- shadowMode,
4539
- shadowAgent,
4540
- shadowOf,
4541
- shadowTriggers,
4542
- shadowSpeakOn,
4543
- userId: typeof userId === 'string' ? userId : undefined,
4544
- includeWorkflowConventions: true, // Cloud opts into ACK/DONE workflow conventions
4545
- };
4546
- const result = await spawner.spawn(request);
4561
+ let result;
4562
+ if (useExternalSpawnManager) {
4563
+ // Route spawn through SDK → daemon socket → SpawnManager
4564
+ // This ensures relay-pty binary resolution works regardless of install method
4565
+ const client = await getRelayClient('Dashboard');
4566
+ if (!client) {
4567
+ return res.status(503).json({
4568
+ success: false,
4569
+ error: 'Not connected to relay daemon',
4570
+ });
4571
+ }
4572
+ // spawnerName/userId/includeWorkflowConventions need SDK >= 2.2.0 types
4573
+ // Cast removed once SDK is republished with these fields
4574
+ result = await client.spawn({
4575
+ name,
4576
+ cli,
4577
+ task,
4578
+ team: team || undefined,
4579
+ cwd: cwd || undefined,
4580
+ interactive,
4581
+ shadowMode,
4582
+ shadowAgent,
4583
+ shadowOf,
4584
+ shadowTriggers,
4585
+ shadowSpeakOn,
4586
+ spawnerName: spawnerName || undefined,
4587
+ userId: typeof userId === 'string' ? userId : undefined,
4588
+ includeWorkflowConventions: true,
4589
+ });
4590
+ }
4591
+ else {
4592
+ // Fall back to local AgentSpawner (standalone mode)
4593
+ const request = {
4594
+ name,
4595
+ cli,
4596
+ task,
4597
+ team: team || undefined,
4598
+ spawnerName: spawnerName || undefined,
4599
+ cwd: cwd || undefined,
4600
+ interactive,
4601
+ shadowMode,
4602
+ shadowAgent,
4603
+ shadowOf,
4604
+ shadowTriggers,
4605
+ shadowSpeakOn,
4606
+ userId: typeof userId === 'string' ? userId : undefined,
4607
+ includeWorkflowConventions: true,
4608
+ };
4609
+ result = await spawner.spawn(request);
4610
+ }
4547
4611
  if (result.success) {
4548
4612
  // Broadcast update to WebSocket clients
4549
4613
  broadcastData().catch(() => { });
@@ -4573,7 +4637,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4573
4637
  * Body: { cli?: string }
4574
4638
  */
4575
4639
  app.post('/api/spawn/architect', async (req, res) => {
4576
- if (!spawner) {
4640
+ if (!spawner && !useExternalSpawnManager) {
4577
4641
  return res.status(503).json({
4578
4642
  success: false,
4579
4643
  error: 'Spawner not enabled. Start dashboard with enableSpawner: true',
@@ -4581,7 +4645,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4581
4645
  }
4582
4646
  const { cli = 'claude' } = req.body;
4583
4647
  // Check if Architect already exists
4584
- const activeWorkers = spawner.getActiveWorkers();
4648
+ const activeWorkers = spawnReader?.getActiveWorkers() || [];
4585
4649
  if (activeWorkers.some(w => w.name.toLowerCase() === 'architect')) {
4586
4650
  return res.status(409).json({
4587
4651
  success: false,
@@ -4659,12 +4723,27 @@ Then output: \`->relay-file:all\`
4659
4723
 
4660
4724
  Start by greeting the project leads and asking for status updates.`;
4661
4725
  try {
4662
- const result = await spawner.spawn({
4663
- name: 'Architect',
4664
- cli,
4665
- task: architectPrompt,
4666
- includeWorkflowConventions: true, // Cloud opts into ACK/DONE workflow conventions
4667
- });
4726
+ let result;
4727
+ if (useExternalSpawnManager) {
4728
+ const client = await getRelayClient('Dashboard');
4729
+ if (!client) {
4730
+ return res.status(503).json({ success: false, error: 'Not connected to relay daemon' });
4731
+ }
4732
+ result = await client.spawn({
4733
+ name: 'Architect',
4734
+ cli,
4735
+ task: architectPrompt,
4736
+ includeWorkflowConventions: true,
4737
+ });
4738
+ }
4739
+ else {
4740
+ result = await spawner.spawn({
4741
+ name: 'Architect',
4742
+ cli,
4743
+ task: architectPrompt,
4744
+ includeWorkflowConventions: true,
4745
+ });
4746
+ }
4668
4747
  if (result.success) {
4669
4748
  broadcastData().catch(() => { });
4670
4749
  }
@@ -4694,8 +4773,8 @@ Start by greeting the project leads and asking for status updates.`;
4694
4773
  // Collect agents from all available sources
4695
4774
  const agentsByName = new Map();
4696
4775
  // Source 1: Spawner's active workers (authoritative for spawned agents)
4697
- if (spawner) {
4698
- for (const worker of spawner.getActiveWorkers()) {
4776
+ if (spawnReader) {
4777
+ for (const worker of spawnReader.getActiveWorkers()) {
4699
4778
  agentsByName.set(worker.name, {
4700
4779
  name: worker.name,
4701
4780
  cli: worker.cli,
@@ -4742,7 +4821,7 @@ Start by greeting the project leads and asking for status updates.`;
4742
4821
  agents,
4743
4822
  // Include source info for debugging
4744
4823
  sources: {
4745
- spawnerEnabled: !!spawner,
4824
+ spawnerEnabled: !!spawnReader,
4746
4825
  daemonAgentsFile: fs.existsSync(agentsPath),
4747
4826
  },
4748
4827
  });
@@ -4751,7 +4830,7 @@ Start by greeting the project leads and asking for status updates.`;
4751
4830
  * DELETE /api/spawned/:name - Release a spawned agent
4752
4831
  */
4753
4832
  app.delete('/api/spawned/:name', async (req, res) => {
4754
- if (!spawner) {
4833
+ if (!spawner && !useExternalSpawnManager) {
4755
4834
  return res.status(503).json({
4756
4835
  success: false,
4757
4836
  error: 'Spawner not enabled',
@@ -4759,7 +4838,19 @@ Start by greeting the project leads and asking for status updates.`;
4759
4838
  }
4760
4839
  const { name } = req.params;
4761
4840
  try {
4762
- const released = await spawner.release(name);
4841
+ let released;
4842
+ if (useExternalSpawnManager) {
4843
+ // Route release through SDK → daemon socket → SpawnManager
4844
+ const client = await getRelayClient('Dashboard');
4845
+ if (!client) {
4846
+ return res.status(503).json({ success: false, error: 'Not connected to relay daemon' });
4847
+ }
4848
+ const result = await client.release(name);
4849
+ released = result.success;
4850
+ }
4851
+ else {
4852
+ released = await spawner.release(name);
4853
+ }
4763
4854
  if (released) {
4764
4855
  broadcastData().catch(() => { });
4765
4856
  // Broadcast agent_released event to activity feed
@@ -4792,7 +4883,7 @@ Start by greeting the project leads and asking for status updates.`;
4792
4883
  * This is useful for breaking agents out of stuck loops without terminating them.
4793
4884
  */
4794
4885
  app.post('/api/agents/by-name/:name/interrupt', (req, res) => {
4795
- if (!spawner) {
4886
+ if (!spawnReader) {
4796
4887
  return res.status(503).json({
4797
4888
  success: false,
4798
4889
  error: 'Spawner not enabled',
@@ -4800,7 +4891,7 @@ Start by greeting the project leads and asking for status updates.`;
4800
4891
  }
4801
4892
  const { name } = req.params;
4802
4893
  // Check if agent exists
4803
- if (!spawner.hasWorker(name)) {
4894
+ if (!spawnReader.hasWorker(name)) {
4804
4895
  return res.status(404).json({
4805
4896
  success: false,
4806
4897
  error: `Agent ${name} not found or not spawned`,
@@ -4809,7 +4900,7 @@ Start by greeting the project leads and asking for status updates.`;
4809
4900
  try {
4810
4901
  // Send ESC ESC sequence to interrupt the agent
4811
4902
  // ESC = 0x1b in hexadecimal
4812
- const success = spawner.sendWorkerInput(name, '\x1b\x1b');
4903
+ const success = spawnReader.sendWorkerInput(name, '\x1b\x1b');
4813
4904
  if (success) {
4814
4905
  console.log(`[api] Sent interrupt (ESC ESC) to agent ${name}`);
4815
4906
  res.json({
@@ -5162,7 +5253,7 @@ Start by greeting the project leads and asking for status updates.`;
5162
5253
  */
5163
5254
  app.get('/api/fleet/servers', async (_req, res) => {
5164
5255
  const servers = [];
5165
- const localAgents = spawner?.getActiveWorkers() || [];
5256
+ const localAgents = spawnReader?.getActiveWorkers() || [];
5166
5257
  const agentStatuses = await loadAgentStatuses();
5167
5258
  let hasBridgeProjects = false;
5168
5259
  // Check for bridge connections first
@@ -5246,7 +5337,7 @@ Start by greeting the project leads and asking for status updates.`;
5246
5337
  * GET /api/fleet/stats - Get aggregate fleet statistics
5247
5338
  */
5248
5339
  app.get('/api/fleet/stats', async (_req, res) => {
5249
- const localAgents = spawner?.getActiveWorkers() || [];
5340
+ const localAgents = spawnReader?.getActiveWorkers() || [];
5250
5341
  const agentStatuses = await loadAgentStatuses();
5251
5342
  const totalAgents = localAgents.length;
5252
5343
  let onlineAgents = 0;
@@ -5551,8 +5642,9 @@ Start by greeting the project leads and asking for status updates.`;
5551
5642
  const listenCallback = async () => {
5552
5643
  console.log(`Dashboard running at http://${host || 'localhost'}:${availablePort} (build: cloud-channels-v2)`);
5553
5644
  console.log(`Monitoring: ${dataDir}`);
5554
- // Set the dashboard port on spawner so spawned agents can use the API for nested spawns
5555
- if (spawner) {
5645
+ // Set the dashboard port on local spawner so spawned agents can use the API for nested spawns
5646
+ // Not needed when using external SpawnManager (daemon handles this)
5647
+ if (spawner && !useExternalSpawnManager) {
5556
5648
  spawner.setDashboardPort(availablePort);
5557
5649
  }
5558
5650
  // Start health worker on separate thread for reliable health checks