agent-relay 1.3.1 → 1.3.3

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 (202) hide show
  1. package/.trajectories/active/traj_3yx9dy148mge.json +42 -0
  2. package/.trajectories/completed/2026-01/traj_1g7yx6qtg4ai.json +49 -0
  3. package/.trajectories/completed/2026-01/traj_1g7yx6qtg4ai.md +31 -0
  4. package/.trajectories/completed/2026-01/traj_4qwd4zmhfwp4.json +49 -0
  5. package/.trajectories/completed/2026-01/traj_4qwd4zmhfwp4.md +31 -0
  6. package/.trajectories/completed/2026-01/traj_6unwwmgyj5sq.json +109 -0
  7. package/.trajectories/completed/2026-01/traj_a0tqx8biw9c4.json +49 -0
  8. package/.trajectories/completed/2026-01/traj_a0tqx8biw9c4.md +31 -0
  9. package/.trajectories/completed/2026-01/traj_ax8uungxz2qh.json +66 -0
  10. package/.trajectories/completed/2026-01/traj_ax8uungxz2qh.md +36 -0
  11. package/.trajectories/completed/2026-01/traj_c9izbh2snpzf.json +49 -0
  12. package/.trajectories/completed/2026-01/traj_c9izbh2snpzf.md +31 -0
  13. package/.trajectories/completed/2026-01/traj_cpn70dw066nt.json +65 -0
  14. package/.trajectories/completed/2026-01/traj_cpn70dw066nt.md +37 -0
  15. package/.trajectories/completed/2026-01/traj_erglv2f8t9eh.json +36 -0
  16. package/.trajectories/completed/2026-01/traj_erglv2f8t9eh.md +21 -0
  17. package/.trajectories/completed/2026-01/traj_he75f24d1xfm.json +101 -0
  18. package/.trajectories/completed/2026-01/traj_he75f24d1xfm.md +52 -0
  19. package/.trajectories/completed/2026-01/traj_lgtodco7dp1n.json +61 -0
  20. package/.trajectories/completed/2026-01/traj_lgtodco7dp1n.md +36 -0
  21. package/.trajectories/completed/2026-01/traj_oszg9flv74pk.json +73 -0
  22. package/.trajectories/completed/2026-01/traj_oszg9flv74pk.md +41 -0
  23. package/.trajectories/completed/2026-01/traj_pulomd3y8cvj.json +77 -0
  24. package/.trajectories/completed/2026-01/traj_pulomd3y8cvj.md +42 -0
  25. package/.trajectories/completed/2026-01/traj_rsavt0jipi3c.json +109 -0
  26. package/.trajectories/completed/2026-01/traj_rsavt0jipi3c.md +56 -0
  27. package/.trajectories/completed/2026-01/traj_x721m1j9rzup.json +113 -0
  28. package/.trajectories/completed/2026-01/traj_x721m1j9rzup.md +57 -0
  29. package/.trajectories/completed/2026-01/traj_xjqvmep5ed3h.json +61 -0
  30. package/.trajectories/completed/2026-01/traj_xjqvmep5ed3h.md +36 -0
  31. package/.trajectories/completed/2026-01/traj_y7n6hfbf7dmg.json +49 -0
  32. package/.trajectories/completed/2026-01/traj_y7n6hfbf7dmg.md +31 -0
  33. package/.trajectories/completed/2026-01/traj_yvfkwnkdiso2.json +49 -0
  34. package/.trajectories/completed/2026-01/traj_yvfkwnkdiso2.md +31 -0
  35. package/.trajectories/index.json +140 -1
  36. package/README.md +23 -9
  37. package/TRAIL_GIT_AUTH_FIX.md +113 -0
  38. package/deploy/workspace/codex.config.toml +1 -1
  39. package/deploy/workspace/entrypoint.sh +20 -79
  40. package/deploy/workspace/gh-relay +156 -0
  41. package/deploy/workspace/git-credential-relay +5 -1
  42. package/dist/bridge/multi-project-client.js +13 -10
  43. package/dist/bridge/spawner.d.ts +2 -0
  44. package/dist/bridge/spawner.js +58 -76
  45. package/dist/bridge/types.d.ts +2 -0
  46. package/dist/cli/index.d.ts +8 -6
  47. package/dist/cli/index.js +297 -30
  48. package/dist/cloud/api/admin.js +16 -3
  49. package/dist/cloud/api/codex-auth-helper.js +28 -8
  50. package/dist/cloud/api/consensus.d.ts +13 -0
  51. package/dist/cloud/api/consensus.js +259 -0
  52. package/dist/cloud/api/daemons.js +205 -1
  53. package/dist/cloud/api/git.js +37 -7
  54. package/dist/cloud/api/onboarding.js +4 -1
  55. package/dist/cloud/api/provider-env.d.ts +5 -0
  56. package/dist/cloud/api/provider-env.js +27 -0
  57. package/dist/cloud/api/providers.js +2 -0
  58. package/dist/cloud/api/test-helpers.js +130 -0
  59. package/dist/cloud/api/workspaces.js +38 -3
  60. package/dist/cloud/db/bulk-ingest.d.ts +88 -0
  61. package/dist/cloud/db/bulk-ingest.js +268 -0
  62. package/dist/cloud/db/drizzle.d.ts +33 -0
  63. package/dist/cloud/db/drizzle.js +174 -2
  64. package/dist/cloud/db/index.d.ts +24 -5
  65. package/dist/cloud/db/index.js +19 -4
  66. package/dist/cloud/db/schema.d.ts +397 -3
  67. package/dist/cloud/db/schema.js +75 -1
  68. package/dist/cloud/provisioner/index.d.ts +8 -0
  69. package/dist/cloud/provisioner/index.js +256 -50
  70. package/dist/cloud/server.js +47 -3
  71. package/dist/cloud/services/index.d.ts +1 -0
  72. package/dist/cloud/services/index.js +2 -0
  73. package/dist/cloud/services/nango.d.ts +3 -4
  74. package/dist/cloud/services/nango.js +11 -33
  75. package/dist/cloud/services/workspace-keepalive.d.ts +76 -0
  76. package/dist/cloud/services/workspace-keepalive.js +234 -0
  77. package/dist/config/relay-config.d.ts +23 -0
  78. package/dist/config/relay-config.js +23 -0
  79. package/dist/daemon/agent-manager.d.ts +20 -1
  80. package/dist/daemon/agent-manager.js +51 -0
  81. package/dist/daemon/agent-registry.js +4 -4
  82. package/dist/daemon/agent-signing.d.ts +158 -0
  83. package/dist/daemon/agent-signing.js +523 -0
  84. package/dist/daemon/api.js +18 -1
  85. package/dist/daemon/cli-auth.d.ts +4 -1
  86. package/dist/daemon/cli-auth.js +55 -11
  87. package/dist/daemon/cloud-sync.d.ts +47 -1
  88. package/dist/daemon/cloud-sync.js +152 -3
  89. package/dist/daemon/connection.d.ts +28 -0
  90. package/dist/daemon/connection.js +113 -22
  91. package/dist/daemon/consensus-integration.d.ts +167 -0
  92. package/dist/daemon/consensus-integration.js +371 -0
  93. package/dist/daemon/consensus.d.ts +271 -0
  94. package/dist/daemon/consensus.js +632 -0
  95. package/dist/daemon/delivery-tracker.d.ts +34 -0
  96. package/dist/daemon/delivery-tracker.js +104 -0
  97. package/dist/daemon/enhanced-features.d.ts +118 -0
  98. package/dist/daemon/enhanced-features.js +178 -0
  99. package/dist/daemon/index.d.ts +4 -0
  100. package/dist/daemon/index.js +5 -0
  101. package/dist/daemon/rate-limiter.d.ts +68 -0
  102. package/dist/daemon/rate-limiter.js +130 -0
  103. package/dist/daemon/router.d.ts +18 -11
  104. package/dist/daemon/router.js +57 -113
  105. package/dist/daemon/server.d.ts +13 -1
  106. package/dist/daemon/server.js +71 -9
  107. package/dist/daemon/sync-queue.d.ts +116 -0
  108. package/dist/daemon/sync-queue.js +361 -0
  109. package/dist/dashboard/out/404.html +1 -1
  110. package/dist/dashboard/out/_next/static/chunks/116-de2a4ac06e5000dc.js +1 -0
  111. package/dist/dashboard/out/_next/static/chunks/847-f1f467060f32afff.js +1 -0
  112. package/dist/dashboard/out/_next/static/chunks/919-87d604a5d76c1fbd.js +1 -0
  113. package/dist/dashboard/out/_next/static/chunks/app/app/{page-c617745b81344f4f.js → page-7f64824ae7d06707.js} +1 -1
  114. package/dist/dashboard/out/_next/static/chunks/app/cloud/link/page-3f559d393902aad2.js +1 -0
  115. package/dist/dashboard/out/_next/static/chunks/app/login/page-16d1715ddaa874ee.js +1 -0
  116. package/dist/dashboard/out/_next/static/chunks/app/{page-dc786c183425c2ac.js → page-814efc4d77b4191d.js} +1 -1
  117. package/dist/dashboard/out/_next/static/chunks/{main-2ee6beb2ae96d210.js → main-5a40a5ae29646e1b.js} +1 -1
  118. package/dist/dashboard/out/_next/static/css/44d2b52637b511bc.css +1 -0
  119. package/dist/dashboard/out/app/onboarding.html +1 -1
  120. package/dist/dashboard/out/app/onboarding.txt +1 -1
  121. package/dist/dashboard/out/app.html +1 -1
  122. package/dist/dashboard/out/app.txt +2 -2
  123. package/dist/dashboard/out/cloud/link.html +1 -0
  124. package/dist/dashboard/out/cloud/link.txt +7 -0
  125. package/dist/dashboard/out/connect-repos.html +1 -1
  126. package/dist/dashboard/out/connect-repos.txt +1 -1
  127. package/dist/dashboard/out/history.html +1 -1
  128. package/dist/dashboard/out/history.txt +2 -2
  129. package/dist/dashboard/out/index.html +1 -1
  130. package/dist/dashboard/out/index.txt +2 -2
  131. package/dist/dashboard/out/login.html +2 -3
  132. package/dist/dashboard/out/login.txt +2 -2
  133. package/dist/dashboard/out/metrics.html +1 -1
  134. package/dist/dashboard/out/metrics.txt +2 -2
  135. package/dist/dashboard/out/pricing.html +2 -2
  136. package/dist/dashboard/out/pricing.txt +1 -1
  137. package/dist/dashboard/out/providers/setup/claude.html +1 -1
  138. package/dist/dashboard/out/providers/setup/claude.txt +1 -1
  139. package/dist/dashboard/out/providers/setup/codex.html +1 -1
  140. package/dist/dashboard/out/providers/setup/codex.txt +1 -1
  141. package/dist/dashboard/out/providers.html +1 -1
  142. package/dist/dashboard/out/providers.txt +1 -1
  143. package/dist/dashboard/out/signup.html +2 -2
  144. package/dist/dashboard/out/signup.txt +1 -1
  145. package/dist/dashboard-server/server.js +244 -28
  146. package/dist/health-worker-manager.d.ts +62 -0
  147. package/dist/health-worker-manager.js +144 -0
  148. package/dist/health-worker.d.ts +9 -0
  149. package/dist/health-worker.js +79 -0
  150. package/dist/index.d.ts +2 -1
  151. package/dist/index.js +5 -1
  152. package/dist/memory/context-compaction.d.ts +156 -0
  153. package/dist/memory/context-compaction.js +453 -0
  154. package/dist/memory/index.d.ts +1 -0
  155. package/dist/memory/index.js +1 -0
  156. package/dist/protocol/channels.js +4 -4
  157. package/dist/protocol/framing.d.ts +72 -10
  158. package/dist/protocol/framing.js +194 -25
  159. package/dist/storage/adapter.d.ts +8 -1
  160. package/dist/storage/adapter.js +11 -0
  161. package/dist/storage/batched-sqlite-adapter.d.ts +71 -0
  162. package/dist/storage/batched-sqlite-adapter.js +183 -0
  163. package/dist/storage/dead-letter-queue.d.ts +196 -0
  164. package/dist/storage/dead-letter-queue.js +427 -0
  165. package/dist/storage/dlq-adapter.d.ts +195 -0
  166. package/dist/storage/dlq-adapter.js +664 -0
  167. package/dist/trajectory/config.d.ts +32 -14
  168. package/dist/trajectory/config.js +38 -16
  169. package/dist/trajectory/integration.js +217 -64
  170. package/dist/utils/git-remote.d.ts +47 -0
  171. package/dist/utils/git-remote.js +125 -0
  172. package/dist/utils/id-generator.d.ts +35 -0
  173. package/dist/utils/id-generator.js +60 -0
  174. package/dist/utils/index.d.ts +1 -0
  175. package/dist/utils/index.js +1 -0
  176. package/dist/utils/precompiled-patterns.d.ts +110 -0
  177. package/dist/utils/precompiled-patterns.js +322 -0
  178. package/dist/wrapper/auth-detection.js +1 -1
  179. package/dist/wrapper/base-wrapper.d.ts +40 -0
  180. package/dist/wrapper/base-wrapper.js +60 -6
  181. package/dist/wrapper/client.d.ts +14 -4
  182. package/dist/wrapper/client.js +89 -31
  183. package/dist/wrapper/idle-detector.d.ts +102 -0
  184. package/dist/wrapper/idle-detector.js +279 -0
  185. package/dist/wrapper/parser.d.ts +4 -0
  186. package/dist/wrapper/parser.js +19 -1
  187. package/dist/wrapper/pty-wrapper.d.ts +14 -2
  188. package/dist/wrapper/pty-wrapper.js +132 -32
  189. package/dist/wrapper/shared.d.ts +1 -1
  190. package/dist/wrapper/shared.js +1 -1
  191. package/dist/wrapper/tmux-wrapper.d.ts +20 -2
  192. package/dist/wrapper/tmux-wrapper.js +163 -40
  193. package/package.json +3 -1
  194. package/scripts/run-migrations.js +43 -0
  195. package/scripts/verify-schema.js +134 -0
  196. package/tests/benchmarks/protocol.bench.ts +310 -0
  197. package/dist/dashboard/out/_next/static/chunks/116-2502180def231162.js +0 -1
  198. package/dist/dashboard/out/_next/static/chunks/899-fc02ed79e3de4302.js +0 -1
  199. package/dist/dashboard/out/_next/static/chunks/app/login/page-c22d080201cbd9fb.js +0 -1
  200. package/dist/dashboard/out/_next/static/css/48a8fbe3e659080e.css +0 -1
  201. /package/dist/dashboard/out/_next/static/{sDcbGRTYLcpPvyTs_rsNb → R-uQOUcOLINtsp6ACeZa9}/_buildManifest.js +0 -0
  202. /package/dist/dashboard/out/_next/static/{sDcbGRTYLcpPvyTs_rsNb → R-uQOUcOLINtsp6ACeZa9}/_ssgManifest.js +0 -0
@@ -19,6 +19,7 @@ import { loadTeamsConfig } from '../bridge/teams-config.js';
19
19
  import { getMemoryMonitor } from '../resiliency/memory-monitor.js';
20
20
  import { detectWorkspacePath } from '../utils/project-namespace.js';
21
21
  import { startCLIAuth, getAuthSession, cancelAuthSession, submitAuthCode, completeAuthSession, getSupportedProviders, } from '../daemon/cli-auth.js';
22
+ import { HealthWorkerManager, getHealthPort } from '../health-worker-manager.js';
22
23
  /**
23
24
  * Initialize cloud persistence for session tracking.
24
25
  *
@@ -597,7 +598,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
597
598
  return connectionPromise;
598
599
  };
599
600
  // Start default relay client connection (non-blocking)
600
- getRelayClient('Dashboard').catch(() => { });
601
+ // Use '_DashboardUI' to avoid conflicts with agents named 'Dashboard'
602
+ getRelayClient('_DashboardUI').catch(() => { });
601
603
  // User bridge for human-to-human and human-to-agent messaging
602
604
  const userBridge = new UserBridge({
603
605
  socketPath,
@@ -693,6 +695,12 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
693
695
  return false;
694
696
  }
695
697
  };
698
+ const isUserOnline = (username) => {
699
+ if (username === '*')
700
+ return true;
701
+ return onlineUsers.has(username) || userBridge.isUserRegistered(username);
702
+ };
703
+ const isRecipientOnline = (name) => (isAgentOnline(name) || isUserOnline(name));
696
704
  // Helper to get team members from teams.json, agents.json, and spawner's active workers
697
705
  const getTeamMembers = (teamName) => {
698
706
  const members = new Set();
@@ -752,13 +760,15 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
752
760
  }
753
761
  else {
754
762
  // Fail fast if target agent is offline (except broadcasts)
755
- if (to !== '*' && !isAgentOnline(to)) {
756
- return res.status(404).json({ error: `Agent "${to}" is not online` });
763
+ if (to !== '*' && !isRecipientOnline(to)) {
764
+ return res.status(404).json({ error: `Recipient "${to}" is not online` });
757
765
  }
758
766
  targets = [to];
759
767
  }
760
- // Get or create relay client for this sender (defaults to 'Dashboard' for non-cloud mode)
761
- const relayClient = await getRelayClient(senderName || 'Dashboard');
768
+ // Always use '_DashboardUI' client to avoid name conflicts with user agents
769
+ // (underscore prefix indicates system client, prevents collision if user names an agent "Dashboard")
770
+ // The sender name is preserved in message history/logs but not used for the relay connection
771
+ const relayClient = await getRelayClient('_DashboardUI');
762
772
  if (!relayClient || relayClient.state !== 'READY') {
763
773
  return res.status(503).json({ error: 'Relay daemon not connected' });
764
774
  }
@@ -1103,6 +1113,25 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1103
1113
  team: a.team,
1104
1114
  });
1105
1115
  });
1116
+ // Inject online human users (connected via dashboard WebSocket) into agentsMap
1117
+ // These users are tracked in onlineUsers for presence but need to appear in the agents list
1118
+ // with cli: 'dashboard' so they show up in the sidebar for DM conversations
1119
+ for (const [username, state] of onlineUsers) {
1120
+ // Don't overwrite existing entries (e.g., if user is also in team.json)
1121
+ if (!agentsMap.has(username)) {
1122
+ agentsMap.set(username, {
1123
+ name: username,
1124
+ role: 'User',
1125
+ cli: 'dashboard',
1126
+ messageCount: 0,
1127
+ status: 'online',
1128
+ lastSeen: state.info.lastSeen,
1129
+ lastActive: state.info.lastSeen,
1130
+ needsAttention: false,
1131
+ avatarUrl: state.info.avatarUrl,
1132
+ });
1133
+ }
1134
+ }
1106
1135
  // Update inbox counts if fallback mode; if storage, count messages addressed to agent
1107
1136
  if (storage) {
1108
1137
  for (const msg of allMessages) {
@@ -2067,16 +2096,49 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2067
2096
  websocketClients: wss.clients.size,
2068
2097
  });
2069
2098
  });
2099
+ /**
2100
+ * GET /keep-alive - Keep-alive endpoint for Fly.io idle prevention
2101
+ * Called by cloud server when workspace has active agents running.
2102
+ * This inbound request counts as activity for Fly.io's request-based
2103
+ * concurrency tracking, preventing the machine from being idled.
2104
+ */
2105
+ app.get('/keep-alive', (req, res) => {
2106
+ // Count online agents (seen within last 30 seconds)
2107
+ let activeAgents = 0;
2108
+ const agentsPath = path.join(teamDir, 'agents.json');
2109
+ if (fs.existsSync(agentsPath)) {
2110
+ try {
2111
+ const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
2112
+ const thirtySecondsAgo = Date.now() - 30 * 1000;
2113
+ activeAgents = (data.agents || []).filter((a) => {
2114
+ if (!a.lastSeen)
2115
+ return false;
2116
+ return new Date(a.lastSeen).getTime() > thirtySecondsAgo;
2117
+ }).length;
2118
+ }
2119
+ catch {
2120
+ // Ignore parse errors
2121
+ }
2122
+ }
2123
+ res.json({
2124
+ ok: true,
2125
+ activeAgents,
2126
+ timestamp: Date.now(),
2127
+ });
2128
+ });
2070
2129
  // ===== CLI Auth API (for workspace-based provider authentication) =====
2071
2130
  /**
2072
2131
  * POST /auth/cli/:provider/start - Start CLI auth flow
2073
- * Body: { useDeviceFlow?: boolean }
2132
+ * Body: { useDeviceFlow?: boolean, userId?: string }
2133
+ *
2134
+ * When userId is provided, credentials are stored per-user at /data/users/{userId}/.{provider}/
2135
+ * This allows multiple users to share a workspace with their own CLI credentials.
2074
2136
  */
2075
2137
  app.post('/auth/cli/:provider/start', async (req, res) => {
2076
2138
  const { provider } = req.params;
2077
- const { useDeviceFlow } = req.body || {};
2139
+ const { useDeviceFlow, userId } = req.body || {};
2078
2140
  try {
2079
- const session = await startCLIAuth(provider, { useDeviceFlow });
2141
+ const session = await startCLIAuth(provider, { useDeviceFlow, userId });
2080
2142
  res.json({
2081
2143
  sessionId: session.id,
2082
2144
  status: session.status,
@@ -2239,18 +2301,35 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2239
2301
  */
2240
2302
  app.get('/auth/cli/openai/check', async (req, res) => {
2241
2303
  try {
2242
- // Codex stores credentials at ~/.codex/auth.json
2243
- const homedir = process.env.HOME || '/home/workspace';
2244
- const credPath = path.join(homedir, '.codex', 'auth.json');
2304
+ // Get userId from query params for per-user credential checking
2305
+ // Multiple users can share a workspace, each with their own CLI credentials
2306
+ const userId = req.query.userId;
2307
+ let credPath;
2308
+ if (userId) {
2309
+ // Per-user credential path: /data/users/{userId}/.codex/auth.json
2310
+ const dataDir = process.env.AGENT_RELAY_DATA_DIR || '/data';
2311
+ credPath = path.join(dataDir, 'users', userId, '.codex', 'auth.json');
2312
+ }
2313
+ else {
2314
+ // Fallback to workspace-wide path for backwards compatibility
2315
+ const homedir = process.env.HOME || '/home/workspace';
2316
+ credPath = path.join(homedir, '.codex', 'auth.json');
2317
+ }
2245
2318
  if (!fs.existsSync(credPath)) {
2246
2319
  return res.json({ authenticated: false });
2247
2320
  }
2248
2321
  const creds = JSON.parse(fs.readFileSync(credPath, 'utf-8'));
2249
2322
  // Check if we have a valid access token or API key
2250
- const hasToken = !!(creds.access_token || creds.token || creds.api_key || creds.OPENAI_API_KEY);
2323
+ // Codex stores tokens in a nested 'tokens' object: { tokens: { access_token, refresh_token } }
2324
+ const hasToken = !!(creds.access_token ||
2325
+ creds.token ||
2326
+ creds.api_key ||
2327
+ creds.OPENAI_API_KEY ||
2328
+ creds.tokens?.access_token ||
2329
+ creds.tokens?.refresh_token);
2251
2330
  res.json({ authenticated: hasToken });
2252
2331
  }
2253
- catch (error) {
2332
+ catch (_error) {
2254
2333
  // File doesn't exist or is invalid
2255
2334
  res.json({ authenticated: false });
2256
2335
  }
@@ -2858,10 +2937,20 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2858
2937
  res.status(500).json({ error: 'Failed to list agents' });
2859
2938
  }
2860
2939
  });
2940
+ // ===== Agent Status API =====
2941
+ /**
2942
+ * GET /api/agents/:name/online - Check if an agent is online
2943
+ * Used by wrappers to wait for spawned agents before sending tasks.
2944
+ */
2945
+ app.get('/api/agents/:name/online', (req, res) => {
2946
+ const { name } = req.params;
2947
+ const online = isAgentOnline(name);
2948
+ res.json({ name, online });
2949
+ });
2861
2950
  // ===== Agent Spawn API =====
2862
2951
  /**
2863
2952
  * POST /api/spawn - Spawn a new agent
2864
- * Body: { name: string, cli?: string, task?: string, team?: string, shadowMode?, shadowAgent?, shadowOf?, shadowTriggers?, shadowSpeakOn? }
2953
+ * Body: { name: string, cli?: string, task?: string, team?: string, spawnerName?, cwd?, interactive?, shadowMode?, shadowAgent?, shadowOf?, shadowTriggers?, shadowSpeakOn? }
2865
2954
  */
2866
2955
  app.post('/api/spawn', async (req, res) => {
2867
2956
  if (!spawner) {
@@ -2870,7 +2959,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2870
2959
  error: 'Spawner not enabled. Start dashboard with enableSpawner: true',
2871
2960
  });
2872
2961
  }
2873
- const { name, cli = 'claude', task = '', team, interactive, shadowMode, shadowAgent, shadowOf, shadowTriggers, shadowSpeakOn, } = req.body;
2962
+ const { name, cli = 'claude', task = '', team, spawnerName, cwd, interactive, shadowMode, shadowAgent, shadowOf, shadowTriggers, shadowSpeakOn, userId, } = req.body;
2874
2963
  if (!name || typeof name !== 'string') {
2875
2964
  return res.status(400).json({
2876
2965
  success: false,
@@ -2883,12 +2972,15 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2883
2972
  cli,
2884
2973
  task,
2885
2974
  team: team || undefined, // Optional team name
2975
+ spawnerName: spawnerName || undefined, // For policy enforcement
2976
+ cwd: cwd || undefined, // Working directory
2886
2977
  interactive, // Disables auto-accept for auth setup flows
2887
2978
  shadowMode,
2888
2979
  shadowAgent,
2889
2980
  shadowOf,
2890
2981
  shadowTriggers,
2891
2982
  shadowSpeakOn,
2983
+ userId: typeof userId === 'string' ? userId : undefined,
2892
2984
  };
2893
2985
  const result = await spawner.spawn(request);
2894
2986
  if (result.success) {
@@ -2997,19 +3089,70 @@ Start by greeting the project leads and asking for status updates.`;
2997
3089
  });
2998
3090
  /**
2999
3091
  * GET /api/spawned - List active spawned agents
3092
+ *
3093
+ * Returns agents from two sources:
3094
+ * 1. Spawner's active workers (in-memory tracking)
3095
+ * 2. Daemon's agents.json registry (persisted, survives restarts)
3096
+ *
3097
+ * This fallback ensures docker deployments show agents even after
3098
+ * container restarts when spawner's in-memory state is lost but
3099
+ * agents have reconnected to the daemon.
3000
3100
  */
3001
3101
  app.get('/api/spawned', (req, res) => {
3002
- if (!spawner) {
3003
- return res.status(503).json({
3004
- success: false,
3005
- error: 'Spawner not enabled',
3006
- agents: [],
3007
- });
3102
+ // Collect agents from all available sources
3103
+ const agentsByName = new Map();
3104
+ // Source 1: Spawner's active workers (authoritative for spawned agents)
3105
+ if (spawner) {
3106
+ for (const worker of spawner.getActiveWorkers()) {
3107
+ agentsByName.set(worker.name, {
3108
+ name: worker.name,
3109
+ cli: worker.cli,
3110
+ pid: worker.pid,
3111
+ spawnedAt: worker.spawnedAt,
3112
+ task: worker.task,
3113
+ team: worker.team,
3114
+ source: 'spawner',
3115
+ });
3116
+ }
3008
3117
  }
3009
- const agents = spawner.getActiveWorkers();
3118
+ // Source 2: Daemon's agents.json registry (fallback for docker restarts)
3119
+ // Only include agents not already tracked by spawner
3120
+ const agentsPath = path.join(teamDir, 'agents.json');
3121
+ if (fs.existsSync(agentsPath)) {
3122
+ try {
3123
+ const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
3124
+ const registeredAgents = data.agents || [];
3125
+ const thirtySecondsAgo = Date.now() - 30 * 1000;
3126
+ for (const agent of registeredAgents) {
3127
+ // Skip if already tracked by spawner
3128
+ if (agentsByName.has(agent.name))
3129
+ continue;
3130
+ // Only include recently active agents (within 30s heartbeat window)
3131
+ const lastSeen = agent.lastSeen ? new Date(agent.lastSeen).getTime() : 0;
3132
+ if (lastSeen < thirtySecondsAgo)
3133
+ continue;
3134
+ agentsByName.set(agent.name, {
3135
+ name: agent.name,
3136
+ cli: agent.cli || 'unknown',
3137
+ spawnedAt: agent.connectedAt ? new Date(agent.connectedAt).getTime() : undefined,
3138
+ team: agent.team,
3139
+ source: 'daemon',
3140
+ });
3141
+ }
3142
+ }
3143
+ catch (err) {
3144
+ console.error('[api/spawned] Failed to read agents.json:', err);
3145
+ }
3146
+ }
3147
+ const agents = Array.from(agentsByName.values());
3010
3148
  res.json({
3011
3149
  success: true,
3012
3150
  agents,
3151
+ // Include source info for debugging
3152
+ sources: {
3153
+ spawnerEnabled: !!spawner,
3154
+ daemonAgentsFile: fs.existsSync(agentsPath),
3155
+ },
3013
3156
  });
3014
3157
  });
3015
3158
  /**
@@ -3043,6 +3186,53 @@ Start by greeting the project leads and asking for status updates.`;
3043
3186
  });
3044
3187
  }
3045
3188
  });
3189
+ /**
3190
+ * POST /api/agents/by-name/:name/interrupt - Send ESC sequence to interrupt an agent
3191
+ *
3192
+ * Sends ESC ESC (0x1b 0x1b) to the agent's PTY to interrupt the current operation.
3193
+ * This is useful for breaking agents out of stuck loops without terminating them.
3194
+ */
3195
+ app.post('/api/agents/by-name/:name/interrupt', (req, res) => {
3196
+ if (!spawner) {
3197
+ return res.status(503).json({
3198
+ success: false,
3199
+ error: 'Spawner not enabled',
3200
+ });
3201
+ }
3202
+ const { name } = req.params;
3203
+ // Check if agent exists
3204
+ if (!spawner.hasWorker(name)) {
3205
+ return res.status(404).json({
3206
+ success: false,
3207
+ error: `Agent ${name} not found or not spawned`,
3208
+ });
3209
+ }
3210
+ try {
3211
+ // Send ESC ESC sequence to interrupt the agent
3212
+ // ESC = 0x1b in hexadecimal
3213
+ const success = spawner.sendWorkerInput(name, '\x1b\x1b');
3214
+ if (success) {
3215
+ console.log(`[api] Sent interrupt (ESC ESC) to agent ${name}`);
3216
+ res.json({
3217
+ success: true,
3218
+ message: `Interrupt signal sent to ${name}`,
3219
+ });
3220
+ }
3221
+ else {
3222
+ res.status(500).json({
3223
+ success: false,
3224
+ error: `Failed to send interrupt to ${name}`,
3225
+ });
3226
+ }
3227
+ }
3228
+ catch (err) {
3229
+ console.error('[api] Interrupt error:', err);
3230
+ res.status(500).json({
3231
+ success: false,
3232
+ error: err.message,
3233
+ });
3234
+ }
3235
+ });
3046
3236
  /**
3047
3237
  * GET /api/trajectory - Get current trajectory status
3048
3238
  */
@@ -3311,7 +3501,7 @@ Start by greeting the project leads and asking for status updates.`;
3311
3501
  }
3312
3502
  // Try to send message to agent
3313
3503
  try {
3314
- const client = await getRelayClient('Dashboard');
3504
+ const client = await getRelayClient('_DashboardUI');
3315
3505
  if (client) {
3316
3506
  await client.sendMessage(agentName, responseMessage, 'message');
3317
3507
  }
@@ -3341,7 +3531,7 @@ Start by greeting the project leads and asking for status updates.`;
3341
3531
  responseMessage += `\nReason: ${reason}`;
3342
3532
  }
3343
3533
  try {
3344
- const client = await getRelayClient('Dashboard');
3534
+ const client = await getRelayClient('_DashboardUI');
3345
3535
  if (client) {
3346
3536
  await client.sendMessage(agentName, responseMessage, 'message');
3347
3537
  }
@@ -3529,7 +3719,7 @@ Start by greeting the project leads and asking for status updates.`;
3529
3719
  tasks.set(task.id, task);
3530
3720
  // Send task to agent via relay
3531
3721
  try {
3532
- const client = await getRelayClient('Dashboard');
3722
+ const client = await getRelayClient('_DashboardUI');
3533
3723
  if (client) {
3534
3724
  const taskMessage = `TASK ASSIGNED [${priority.toUpperCase()}]: ${title}\n\n${description || 'No additional details.'}`;
3535
3725
  await client.sendMessage(agentName, taskMessage, 'message');
@@ -3577,7 +3767,7 @@ Start by greeting the project leads and asking for status updates.`;
3577
3767
  // Notify agent of cancellation if task is still active
3578
3768
  if (task.status === 'pending' || task.status === 'assigned' || task.status === 'in_progress') {
3579
3769
  try {
3580
- const client = await getRelayClient('Dashboard');
3770
+ const client = await getRelayClient('_DashboardUI');
3581
3771
  if (client) {
3582
3772
  await client.sendMessage(task.agentName, `TASK CANCELLED: ${task.title}`, 'message');
3583
3773
  }
@@ -3651,7 +3841,7 @@ Start by greeting the project leads and asking for status updates.`;
3651
3841
  return res.status(400).json({ success: false, error: 'Message content is required' });
3652
3842
  }
3653
3843
  try {
3654
- const client = await getRelayClient('Dashboard');
3844
+ const client = await getRelayClient('_DashboardUI');
3655
3845
  if (!client) {
3656
3846
  return res.status(503).json({
3657
3847
  success: false,
@@ -3758,13 +3948,39 @@ Start by greeting the project leads and asking for status updates.`;
3758
3948
  console.log(`Requested dashboard port ${port} is busy; switching to ${availablePort}.`);
3759
3949
  }
3760
3950
  return new Promise((resolve, reject) => {
3761
- server.listen(availablePort, () => {
3951
+ server.listen(availablePort, async () => {
3762
3952
  console.log(`Dashboard running at http://localhost:${availablePort}`);
3763
3953
  console.log(`Monitoring: ${dataDir}`);
3764
3954
  // Set the dashboard port on spawner so spawned agents can use the API for nested spawns
3765
3955
  if (spawner) {
3766
3956
  spawner.setDashboardPort(availablePort);
3767
3957
  }
3958
+ // Start health worker on separate thread for reliable health checks
3959
+ // This ensures health checks respond even when main event loop is blocked
3960
+ const healthPort = getHealthPort(availablePort);
3961
+ const healthWorker = new HealthWorkerManager({ port: healthPort }, {
3962
+ getUptime: () => process.uptime(),
3963
+ getMemoryMB: () => Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
3964
+ getRelayConnected: () => {
3965
+ const defaultClient = relayClients.get('Dashboard');
3966
+ return defaultClient?.state === 'READY';
3967
+ },
3968
+ getAgentCount: () => relayClients.size,
3969
+ getStatus: () => {
3970
+ const socketExists = fs.existsSync(socketPath);
3971
+ if (!socketExists)
3972
+ return 'degraded';
3973
+ const defaultClient = relayClients.get('Dashboard');
3974
+ return defaultClient?.state === 'READY' ? 'healthy' : 'busy';
3975
+ },
3976
+ });
3977
+ try {
3978
+ await healthWorker.start();
3979
+ console.log(`Health check worker running at http://localhost:${healthPort}/health`);
3980
+ }
3981
+ catch (err) {
3982
+ console.warn('[dashboard] Failed to start health worker, using main thread health check:', err);
3983
+ }
3768
3984
  resolve(availablePort);
3769
3985
  });
3770
3986
  server.on('error', (err) => {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Health Worker Manager
3
+ *
4
+ * Manages the health check worker thread, sending periodic stats updates
5
+ * and handling worker lifecycle.
6
+ */
7
+ export interface HealthWorkerConfig {
8
+ /** Port for health check server (default: main port + 1) */
9
+ port: number;
10
+ /** Interval for sending stats updates (default: 5000ms) */
11
+ statsInterval?: number;
12
+ }
13
+ export interface HealthStatsProvider {
14
+ getUptime: () => number;
15
+ getMemoryMB: () => number;
16
+ getRelayConnected: () => boolean;
17
+ getAgentCount: () => number;
18
+ getStatus: () => 'healthy' | 'busy' | 'degraded';
19
+ }
20
+ export declare class HealthWorkerManager {
21
+ private worker;
22
+ private statsInterval;
23
+ private config;
24
+ private statsProvider;
25
+ private ready;
26
+ constructor(config: HealthWorkerConfig, statsProvider: HealthStatsProvider);
27
+ /**
28
+ * Start the health worker thread
29
+ */
30
+ start(): Promise<void>;
31
+ /**
32
+ * Stop the health worker thread
33
+ */
34
+ stop(): Promise<void>;
35
+ /**
36
+ * Check if worker is ready
37
+ */
38
+ isReady(): boolean;
39
+ /**
40
+ * Get the port the health worker is listening on
41
+ */
42
+ getPort(): number;
43
+ /**
44
+ * Start periodic stats updates to worker
45
+ */
46
+ private startStatsUpdates;
47
+ /**
48
+ * Stop stats updates
49
+ */
50
+ private stopStatsUpdates;
51
+ /**
52
+ * Send current stats to worker
53
+ */
54
+ private sendStats;
55
+ }
56
+ /** Default health port offset from main port */
57
+ export declare const HEALTH_PORT_OFFSET = 1;
58
+ /**
59
+ * Calculate health port from main port
60
+ */
61
+ export declare function getHealthPort(mainPort: number): number;
62
+ //# sourceMappingURL=health-worker-manager.d.ts.map
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Health Worker Manager
3
+ *
4
+ * Manages the health check worker thread, sending periodic stats updates
5
+ * and handling worker lifecycle.
6
+ */
7
+ import { Worker } from 'node:worker_threads';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+ export class HealthWorkerManager {
13
+ worker = null;
14
+ statsInterval = null;
15
+ config;
16
+ statsProvider;
17
+ ready = false;
18
+ constructor(config, statsProvider) {
19
+ this.config = {
20
+ statsInterval: 5000,
21
+ ...config,
22
+ };
23
+ this.statsProvider = statsProvider;
24
+ }
25
+ /**
26
+ * Start the health worker thread
27
+ */
28
+ async start() {
29
+ if (this.worker) {
30
+ console.warn('[health-manager] Worker already running');
31
+ return;
32
+ }
33
+ return new Promise((resolve, reject) => {
34
+ // Worker script path - handle both dev (src) and prod (dist)
35
+ const workerPath = path.join(__dirname, 'health-worker.js');
36
+ this.worker = new Worker(workerPath, {
37
+ workerData: { port: this.config.port },
38
+ });
39
+ this.worker.on('message', (msg) => {
40
+ if (msg.type === 'ready') {
41
+ this.ready = true;
42
+ console.log(`[health-manager] Worker ready on port ${msg.port}`);
43
+ this.startStatsUpdates();
44
+ resolve();
45
+ }
46
+ else if (msg.type === 'error') {
47
+ console.error('[health-manager] Worker error:', msg.error);
48
+ }
49
+ });
50
+ this.worker.on('error', (err) => {
51
+ console.error('[health-manager] Worker thread error:', err);
52
+ if (!this.ready) {
53
+ reject(err);
54
+ }
55
+ });
56
+ this.worker.on('exit', (code) => {
57
+ console.log(`[health-manager] Worker exited with code ${code}`);
58
+ this.ready = false;
59
+ this.worker = null;
60
+ this.stopStatsUpdates();
61
+ });
62
+ // Timeout for worker startup
63
+ setTimeout(() => {
64
+ if (!this.ready) {
65
+ reject(new Error('Health worker startup timeout'));
66
+ }
67
+ }, 10000);
68
+ });
69
+ }
70
+ /**
71
+ * Stop the health worker thread
72
+ */
73
+ async stop() {
74
+ this.stopStatsUpdates();
75
+ if (this.worker) {
76
+ await this.worker.terminate();
77
+ this.worker = null;
78
+ this.ready = false;
79
+ }
80
+ }
81
+ /**
82
+ * Check if worker is ready
83
+ */
84
+ isReady() {
85
+ return this.ready;
86
+ }
87
+ /**
88
+ * Get the port the health worker is listening on
89
+ */
90
+ getPort() {
91
+ return this.config.port;
92
+ }
93
+ /**
94
+ * Start periodic stats updates to worker
95
+ */
96
+ startStatsUpdates() {
97
+ if (this.statsInterval)
98
+ return;
99
+ // Send initial stats
100
+ this.sendStats();
101
+ // Send periodic updates
102
+ this.statsInterval = setInterval(() => {
103
+ this.sendStats();
104
+ }, this.config.statsInterval);
105
+ }
106
+ /**
107
+ * Stop stats updates
108
+ */
109
+ stopStatsUpdates() {
110
+ if (this.statsInterval) {
111
+ clearInterval(this.statsInterval);
112
+ this.statsInterval = null;
113
+ }
114
+ }
115
+ /**
116
+ * Send current stats to worker
117
+ */
118
+ sendStats() {
119
+ if (!this.worker || !this.ready)
120
+ return;
121
+ try {
122
+ const stats = {
123
+ uptime: this.statsProvider.getUptime(),
124
+ memoryMB: this.statsProvider.getMemoryMB(),
125
+ relayConnected: this.statsProvider.getRelayConnected(),
126
+ agentCount: this.statsProvider.getAgentCount(),
127
+ status: this.statsProvider.getStatus(),
128
+ };
129
+ this.worker.postMessage(stats);
130
+ }
131
+ catch (err) {
132
+ console.error('[health-manager] Failed to send stats:', err);
133
+ }
134
+ }
135
+ }
136
+ /** Default health port offset from main port */
137
+ export const HEALTH_PORT_OFFSET = 1;
138
+ /**
139
+ * Calculate health port from main port
140
+ */
141
+ export function getHealthPort(mainPort) {
142
+ return mainPort + HEALTH_PORT_OFFSET;
143
+ }
144
+ //# sourceMappingURL=health-worker-manager.js.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Health Check Worker Thread
3
+ *
4
+ * Runs a minimal HTTP server on a separate thread to handle health checks.
5
+ * This ensures health checks respond even when the main event loop is blocked
6
+ * by heavy compute tasks (builds, large file operations, etc.).
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=health-worker.d.ts.map