agent-relay 1.0.7 → 1.0.9

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 (140) hide show
  1. package/README.md +176 -6
  2. package/dist/bridge/config.d.ts +41 -0
  3. package/dist/bridge/config.d.ts.map +1 -0
  4. package/dist/bridge/config.js +143 -0
  5. package/dist/bridge/config.js.map +1 -0
  6. package/dist/bridge/index.d.ts +10 -0
  7. package/dist/bridge/index.d.ts.map +1 -0
  8. package/dist/bridge/index.js +10 -0
  9. package/dist/bridge/index.js.map +1 -0
  10. package/dist/bridge/multi-project-client.d.ts +99 -0
  11. package/dist/bridge/multi-project-client.d.ts.map +1 -0
  12. package/dist/bridge/multi-project-client.js +386 -0
  13. package/dist/bridge/multi-project-client.js.map +1 -0
  14. package/dist/bridge/spawner.d.ts +46 -0
  15. package/dist/bridge/spawner.d.ts.map +1 -0
  16. package/dist/bridge/spawner.js +223 -0
  17. package/dist/bridge/spawner.js.map +1 -0
  18. package/dist/bridge/types.d.ts +55 -0
  19. package/dist/bridge/types.d.ts.map +1 -0
  20. package/dist/bridge/types.js +6 -0
  21. package/dist/bridge/types.js.map +1 -0
  22. package/dist/bridge/utils.d.ts +30 -0
  23. package/dist/bridge/utils.d.ts.map +1 -0
  24. package/dist/bridge/utils.js +54 -0
  25. package/dist/bridge/utils.js.map +1 -0
  26. package/dist/cli/index.d.ts +2 -0
  27. package/dist/cli/index.d.ts.map +1 -1
  28. package/dist/cli/index.js +906 -6
  29. package/dist/cli/index.js.map +1 -1
  30. package/dist/daemon/agent-registry.d.ts +60 -0
  31. package/dist/daemon/agent-registry.d.ts.map +1 -0
  32. package/dist/daemon/agent-registry.js +163 -0
  33. package/dist/daemon/agent-registry.js.map +1 -0
  34. package/dist/daemon/connection.d.ts +33 -1
  35. package/dist/daemon/connection.d.ts.map +1 -1
  36. package/dist/daemon/connection.js +86 -11
  37. package/dist/daemon/connection.js.map +1 -1
  38. package/dist/daemon/index.d.ts +2 -0
  39. package/dist/daemon/index.d.ts.map +1 -1
  40. package/dist/daemon/index.js +2 -0
  41. package/dist/daemon/index.js.map +1 -1
  42. package/dist/daemon/registry.d.ts +9 -0
  43. package/dist/daemon/registry.d.ts.map +1 -0
  44. package/dist/daemon/registry.js +9 -0
  45. package/dist/daemon/registry.js.map +1 -0
  46. package/dist/daemon/router.d.ts +61 -2
  47. package/dist/daemon/router.d.ts.map +1 -1
  48. package/dist/daemon/router.js +219 -4
  49. package/dist/daemon/router.js.map +1 -1
  50. package/dist/daemon/server.d.ts +9 -0
  51. package/dist/daemon/server.d.ts.map +1 -1
  52. package/dist/daemon/server.js +135 -16
  53. package/dist/daemon/server.js.map +1 -1
  54. package/dist/dashboard/metrics.d.ts +105 -0
  55. package/dist/dashboard/metrics.d.ts.map +1 -0
  56. package/dist/dashboard/metrics.js +192 -0
  57. package/dist/dashboard/metrics.js.map +1 -0
  58. package/dist/dashboard/needs-attention.d.ts +24 -0
  59. package/dist/dashboard/needs-attention.d.ts.map +1 -0
  60. package/dist/dashboard/needs-attention.js +78 -0
  61. package/dist/dashboard/needs-attention.js.map +1 -0
  62. package/dist/dashboard/public/bridge.html +1272 -0
  63. package/dist/dashboard/public/index.html +2094 -347
  64. package/dist/dashboard/public/js/app.js +184 -0
  65. package/dist/dashboard/public/js/app.js.map +7 -0
  66. package/dist/dashboard/public/metrics.html +999 -0
  67. package/dist/dashboard/server.d.ts +14 -1
  68. package/dist/dashboard/server.d.ts.map +1 -1
  69. package/dist/dashboard/server.js +689 -16
  70. package/dist/dashboard/server.js.map +1 -1
  71. package/dist/dashboard/start.js +1 -1
  72. package/dist/dashboard/start.js.map +1 -1
  73. package/dist/dashboard-v2/index.d.ts +10 -0
  74. package/dist/dashboard-v2/index.d.ts.map +1 -0
  75. package/dist/dashboard-v2/index.js +54 -0
  76. package/dist/dashboard-v2/index.js.map +1 -0
  77. package/dist/dashboard-v2/lib/api.d.ts +95 -0
  78. package/dist/dashboard-v2/lib/api.d.ts.map +1 -0
  79. package/dist/dashboard-v2/lib/api.js +270 -0
  80. package/dist/dashboard-v2/lib/api.js.map +1 -0
  81. package/dist/dashboard-v2/lib/colors.d.ts +61 -0
  82. package/dist/dashboard-v2/lib/colors.d.ts.map +1 -0
  83. package/dist/dashboard-v2/lib/colors.js +198 -0
  84. package/dist/dashboard-v2/lib/colors.js.map +1 -0
  85. package/dist/dashboard-v2/lib/hierarchy.d.ts +74 -0
  86. package/dist/dashboard-v2/lib/hierarchy.d.ts.map +1 -0
  87. package/dist/dashboard-v2/lib/hierarchy.js +196 -0
  88. package/dist/dashboard-v2/lib/hierarchy.js.map +1 -0
  89. package/dist/dashboard-v2/types/index.d.ts +154 -0
  90. package/dist/dashboard-v2/types/index.d.ts.map +1 -0
  91. package/dist/dashboard-v2/types/index.js +6 -0
  92. package/dist/dashboard-v2/types/index.js.map +1 -0
  93. package/dist/index.d.ts +1 -0
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/protocol/types.d.ts +15 -1
  96. package/dist/protocol/types.d.ts.map +1 -1
  97. package/dist/storage/adapter.d.ts +74 -1
  98. package/dist/storage/adapter.d.ts.map +1 -1
  99. package/dist/storage/adapter.js +39 -0
  100. package/dist/storage/adapter.js.map +1 -1
  101. package/dist/storage/sqlite-adapter.d.ts +92 -1
  102. package/dist/storage/sqlite-adapter.d.ts.map +1 -1
  103. package/dist/storage/sqlite-adapter.js +615 -47
  104. package/dist/storage/sqlite-adapter.js.map +1 -1
  105. package/dist/utils/agent-config.d.ts +45 -0
  106. package/dist/utils/agent-config.d.ts.map +1 -0
  107. package/dist/utils/agent-config.js +118 -0
  108. package/dist/utils/agent-config.js.map +1 -0
  109. package/dist/utils/project-namespace.d.ts.map +1 -1
  110. package/dist/utils/project-namespace.js +22 -1
  111. package/dist/utils/project-namespace.js.map +1 -1
  112. package/dist/wrapper/client.d.ts +30 -3
  113. package/dist/wrapper/client.d.ts.map +1 -1
  114. package/dist/wrapper/client.js +85 -9
  115. package/dist/wrapper/client.js.map +1 -1
  116. package/dist/wrapper/parser.d.ts +127 -4
  117. package/dist/wrapper/parser.d.ts.map +1 -1
  118. package/dist/wrapper/parser.js +622 -86
  119. package/dist/wrapper/parser.js.map +1 -1
  120. package/dist/wrapper/tmux-wrapper.d.ts +136 -10
  121. package/dist/wrapper/tmux-wrapper.d.ts.map +1 -1
  122. package/dist/wrapper/tmux-wrapper.js +599 -79
  123. package/dist/wrapper/tmux-wrapper.js.map +1 -1
  124. package/docs/AGENTS.md +132 -27
  125. package/docs/ARCHITECTURE_DECISIONS.md +175 -0
  126. package/docs/CHANGELOG.md +1 -1
  127. package/docs/COMPETITIVE_ANALYSIS.md +897 -0
  128. package/docs/DESIGN_BRIDGE_STAFFING.md +878 -0
  129. package/docs/DESIGN_V2.md +1079 -0
  130. package/docs/INTEGRATION-GUIDE.md +926 -0
  131. package/docs/MONETIZATION.md +1679 -0
  132. package/docs/PROPOSAL-trajectories.md +1582 -0
  133. package/docs/PROTOCOL.md +3 -3
  134. package/docs/SCALING_ANALYSIS.md +280 -0
  135. package/docs/TMUX_IMPLEMENTATION_NOTES.md +9 -9
  136. package/docs/TMUX_IMPROVEMENTS.md +968 -0
  137. package/docs/agent-relay-snippet.md +61 -0
  138. package/docs/competitive-analysis-mcp-agent-mail.md +389 -0
  139. package/docs/dashboard-v2-plan.md +179 -0
  140. package/package.json +10 -3
package/dist/cli/index.js CHANGED
@@ -7,6 +7,8 @@
7
7
  * relay -n Name cmd - Wrap with specific agent name
8
8
  * relay up - Start daemon + dashboard
9
9
  * relay read <id> - Read full message by ID
10
+ * relay agents - List connected agents
11
+ * relay who - Show currently active agents
10
12
  */
11
13
  import { Command } from 'commander';
12
14
  import { config as dotenvConfig } from 'dotenv';
@@ -15,6 +17,8 @@ import { RelayClient } from '../wrapper/client.js';
15
17
  import { generateAgentName } from '../utils/name-generator.js';
16
18
  import fs from 'node:fs';
17
19
  import path from 'node:path';
20
+ import { promisify } from 'node:util';
21
+ import { exec } from 'node:child_process';
18
22
  import { fileURLToPath } from 'node:url';
19
23
  dotenvConfig();
20
24
  const DEFAULT_DASHBOARD_PORT = process.env.AGENT_RELAY_DASHBOARD_PORT || '3888';
@@ -24,6 +28,7 @@ const __dirname = path.dirname(__filename);
24
28
  const packageJsonPath = path.resolve(__dirname, '../../package.json');
25
29
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
26
30
  const VERSION = packageJson.version;
31
+ const execAsync = promisify(exec);
27
32
  const program = new Command();
28
33
  function pidFilePathForSocket(socketPath) {
29
34
  return `${socketPath}.pid`;
@@ -36,6 +41,7 @@ program
36
41
  program
37
42
  .option('-n, --name <name>', 'Agent name (auto-generated if not set)')
38
43
  .option('-q, --quiet', 'Disable debug output', false)
44
+ .option('--prefix <pattern>', 'Relay prefix pattern (default: ->relay:)')
39
45
  .argument('[command...]', 'Command to wrap (e.g., claude)')
40
46
  .action(async (commandParts, options) => {
41
47
  // If no command provided, show help
@@ -44,25 +50,158 @@ program
44
50
  return;
45
51
  }
46
52
  const { getProjectPaths } = await import('../utils/project-namespace.js');
53
+ const { findAgentConfig, isClaudeCli, buildClaudeArgs } = await import('../utils/agent-config.js');
47
54
  const paths = getProjectPaths();
48
55
  const [mainCommand, ...commandArgs] = commandParts;
49
56
  const agentName = options.name ?? generateAgentName();
50
57
  console.error(`Agent: ${agentName}`);
51
58
  console.error(`Project: ${paths.projectId}`);
59
+ // Auto-detect agent config and inject --model/--agent for Claude CLI
60
+ let finalArgs = commandArgs;
61
+ if (isClaudeCli(mainCommand)) {
62
+ const config = findAgentConfig(agentName, paths.projectRoot);
63
+ if (config) {
64
+ console.error(`Agent config: ${config.configPath}`);
65
+ if (config.model) {
66
+ console.error(`Model: ${config.model}`);
67
+ }
68
+ finalArgs = buildClaudeArgs(agentName, commandArgs, paths.projectRoot);
69
+ }
70
+ }
52
71
  const { TmuxWrapper } = await import('../wrapper/tmux-wrapper.js');
72
+ const { AgentSpawner } = await import('../bridge/spawner.js');
73
+ // Create spawner so any agent can spawn workers
74
+ const spawner = new AgentSpawner(paths.projectRoot);
53
75
  const wrapper = new TmuxWrapper({
54
76
  name: agentName,
55
77
  command: mainCommand,
56
- args: commandArgs,
78
+ args: finalArgs,
57
79
  socketPath: paths.socketPath,
58
- debug: !options.quiet,
80
+ debug: false, // Use -q to keep quiet (debug off by default)
81
+ relayPrefix: options.prefix,
82
+ useInbox: true,
83
+ inboxDir: paths.dataDir, // Use the project-specific data directory for the inbox
84
+ // Wire up spawn/release callbacks so any agent can spawn workers
85
+ onSpawn: async (workerName, workerCli, task) => {
86
+ console.error(`[${agentName}] Spawning ${workerName} (${workerCli})...`);
87
+ const result = await spawner.spawn({
88
+ name: workerName,
89
+ cli: workerCli,
90
+ task,
91
+ requestedBy: agentName,
92
+ });
93
+ if (result.success) {
94
+ console.error(`[${agentName}] ✓ Spawned ${workerName} in ${result.window}`);
95
+ }
96
+ else {
97
+ console.error(`[${agentName}] ✗ Failed to spawn ${workerName}: ${result.error}`);
98
+ }
99
+ },
100
+ onRelease: async (workerName) => {
101
+ console.error(`[${agentName}] Releasing ${workerName}...`);
102
+ const released = await spawner.release(workerName);
103
+ if (released) {
104
+ console.error(`[${agentName}] ✓ Released ${workerName}`);
105
+ }
106
+ else {
107
+ console.error(`[${agentName}] ✗ Worker ${workerName} not found`);
108
+ }
109
+ },
59
110
  });
60
- process.on('SIGINT', () => {
111
+ process.on('SIGINT', async () => {
112
+ await spawner.releaseAll();
61
113
  wrapper.stop();
62
114
  process.exit(0);
63
115
  });
64
116
  await wrapper.start();
65
117
  });
118
+ // Load teams.json from project root or .agent-relay/
119
+ function loadTeamConfig(projectRoot) {
120
+ const locations = [
121
+ path.join(projectRoot, 'teams.json'),
122
+ path.join(projectRoot, '.agent-relay', 'teams.json'),
123
+ ];
124
+ for (const configPath of locations) {
125
+ if (fs.existsSync(configPath)) {
126
+ try {
127
+ const content = fs.readFileSync(configPath, 'utf-8');
128
+ return JSON.parse(content);
129
+ }
130
+ catch (err) {
131
+ console.error(`Failed to parse ${configPath}:`, err);
132
+ }
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+ // Spawn agents from team config using tmux
138
+ async function spawnTeamAgents(agents, socketPath, dataDir, projectRoot, relayPrefix) {
139
+ const { TmuxWrapper } = await import('../wrapper/tmux-wrapper.js');
140
+ const { findAgentConfig, isClaudeCli, buildClaudeArgs } = await import('../utils/agent-config.js');
141
+ const { AgentSpawner } = await import('../bridge/spawner.js');
142
+ // Create spawner so all team agents can spawn workers
143
+ const spawner = new AgentSpawner(projectRoot);
144
+ for (const agent of agents) {
145
+ console.log(`Spawning agent: ${agent.name} (${agent.cli})`);
146
+ // Parse CLI - handle "claude:opus" format
147
+ const [mainCommand, ...cliArgs] = agent.cli.split(/\s+/);
148
+ // Auto-detect agent config and inject --model/--agent for Claude CLI
149
+ let finalArgs = cliArgs;
150
+ if (isClaudeCli(mainCommand)) {
151
+ const config = findAgentConfig(agent.name, projectRoot);
152
+ if (config) {
153
+ console.log(` Agent config: ${config.configPath}`);
154
+ if (config.model) {
155
+ console.log(` Model: ${config.model}`);
156
+ }
157
+ finalArgs = buildClaudeArgs(agent.name, cliArgs, projectRoot);
158
+ }
159
+ }
160
+ const wrapper = new TmuxWrapper({
161
+ name: agent.name,
162
+ command: mainCommand,
163
+ args: finalArgs,
164
+ socketPath,
165
+ debug: false,
166
+ relayPrefix,
167
+ useInbox: true,
168
+ inboxDir: dataDir,
169
+ // Wire up spawn/release callbacks so any agent can spawn workers
170
+ onSpawn: async (workerName, workerCli, task) => {
171
+ console.log(`[${agent.name}] Spawning ${workerName} (${workerCli})...`);
172
+ const result = await spawner.spawn({
173
+ name: workerName,
174
+ cli: workerCli,
175
+ task,
176
+ requestedBy: agent.name,
177
+ });
178
+ if (result.success) {
179
+ console.log(`[${agent.name}] ✓ Spawned ${workerName} in ${result.window}`);
180
+ }
181
+ else {
182
+ console.error(`[${agent.name}] ✗ Failed to spawn ${workerName}: ${result.error}`);
183
+ }
184
+ },
185
+ onRelease: async (workerName) => {
186
+ console.log(`[${agent.name}] Releasing ${workerName}...`);
187
+ const released = await spawner.release(workerName);
188
+ if (released) {
189
+ console.log(`[${agent.name}] ✓ Released ${workerName}`);
190
+ }
191
+ else {
192
+ console.error(`[${agent.name}] ✗ Worker ${workerName} not found`);
193
+ }
194
+ },
195
+ });
196
+ try {
197
+ await wrapper.start();
198
+ console.log(` Started: ${agent.name}`);
199
+ }
200
+ catch (err) {
201
+ console.error(` Failed to start ${agent.name}:`, err);
202
+ }
203
+ }
204
+ }
66
205
  // up - Start daemon + dashboard
67
206
  program
68
207
  .command('up')
@@ -70,7 +209,7 @@ program
70
209
  .option('--no-dashboard', 'Disable web dashboard')
71
210
  .option('--port <port>', 'Dashboard port', DEFAULT_DASHBOARD_PORT)
72
211
  .action(async (options) => {
73
- const { ensureProjectDir } = await import('../utils/project-namespace.js');
212
+ const { getProjectPaths, ensureProjectDir } = await import('../utils/project-namespace.js');
74
213
  const paths = ensureProjectDir();
75
214
  const socketPath = paths.socketPath;
76
215
  const dbPath = paths.dbPath;
@@ -99,8 +238,15 @@ program
99
238
  if (options.dashboard !== false) {
100
239
  const port = parseInt(options.port, 10);
101
240
  const { startDashboard } = await import('../dashboard/server.js');
102
- startDashboard(port, paths.teamDir, dbPath).catch(console.error);
103
- console.log(`Dashboard: http://localhost:${port}`);
241
+ const actualPort = await startDashboard({
242
+ port,
243
+ dataDir: paths.dataDir,
244
+ teamDir: paths.teamDir,
245
+ dbPath,
246
+ enableSpawner: true,
247
+ projectRoot: paths.projectRoot,
248
+ });
249
+ console.log(`Dashboard: http://localhost:${actualPort}`);
104
250
  }
105
251
  console.log('Press Ctrl+C to stop.');
106
252
  await new Promise(() => { });
@@ -139,8 +285,10 @@ program
139
285
  .action(async () => {
140
286
  const { getProjectPaths } = await import('../utils/project-namespace.js');
141
287
  const paths = getProjectPaths();
288
+ const relaySessions = await discoverRelaySessions();
142
289
  if (!fs.existsSync(paths.socketPath)) {
143
290
  console.log('Status: STOPPED');
291
+ logRelaySessions(relaySessions);
144
292
  return;
145
293
  }
146
294
  const client = new RelayClient({
@@ -152,12 +300,81 @@ program
152
300
  await client.connect();
153
301
  console.log('Status: RUNNING');
154
302
  console.log(`Socket: ${paths.socketPath}`);
303
+ logRelaySessions(relaySessions);
155
304
  client.disconnect();
156
305
  }
157
306
  catch {
158
307
  console.log('Status: STOPPED');
308
+ logRelaySessions(relaySessions);
159
309
  }
160
310
  });
311
+ // agents - List connected agents (from registry file)
312
+ program
313
+ .command('agents')
314
+ .description('List connected agents')
315
+ .option('--all', 'Include internal/CLI agents')
316
+ .option('--json', 'Output as JSON')
317
+ .action(async (options) => {
318
+ const { getProjectPaths } = await import('../utils/project-namespace.js');
319
+ const paths = getProjectPaths();
320
+ const agentsPath = path.join(paths.teamDir, 'agents.json');
321
+ const allAgents = loadAgents(agentsPath);
322
+ const agents = options.all
323
+ ? allAgents
324
+ : allAgents.filter(isVisibleAgent);
325
+ if (options.json) {
326
+ console.log(JSON.stringify(agents.map(a => ({ ...a, status: getAgentStatus(a) })), null, 2));
327
+ return;
328
+ }
329
+ if (!agents.length) {
330
+ const hint = options.all ? '' : ' (use --all to include internal/cli agents)';
331
+ console.log(`No agents found. Ensure the daemon is running and agents are connected${hint}.`);
332
+ return;
333
+ }
334
+ console.log('NAME STATUS CLI LAST SEEN');
335
+ console.log('---------------------------------------------');
336
+ agents.forEach((agent) => {
337
+ const name = (agent.name ?? 'unknown').padEnd(15);
338
+ const status = getAgentStatus(agent).padEnd(8);
339
+ const cli = (agent.cli ?? '-').padEnd(8);
340
+ const lastSeen = formatRelativeTime(agent.lastSeen);
341
+ console.log(`${name} ${status} ${cli} ${lastSeen}`);
342
+ });
343
+ });
344
+ // who - Show currently active agents (online within last 30s)
345
+ program
346
+ .command('who')
347
+ .description('Show currently active agents (last seen within 30 seconds)')
348
+ .option('--all', 'Include internal/CLI agents')
349
+ .option('--json', 'Output as JSON')
350
+ .action(async (options) => {
351
+ const { getProjectPaths } = await import('../utils/project-namespace.js');
352
+ const paths = getProjectPaths();
353
+ const agentsPath = path.join(paths.teamDir, 'agents.json');
354
+ const allAgents = loadAgents(agentsPath);
355
+ const visibleAgents = options.all
356
+ ? allAgents
357
+ : allAgents.filter(a => !isInternalAgent(a.name));
358
+ const onlineAgents = visibleAgents.filter(isAgentOnline);
359
+ if (options.json) {
360
+ console.log(JSON.stringify(onlineAgents.map(a => ({ ...a, status: getAgentStatus(a) })), null, 2));
361
+ return;
362
+ }
363
+ if (!onlineAgents.length) {
364
+ const hint = options.all ? '' : ' (use --all to include internal/cli agents)';
365
+ console.log(`No active agents found${hint}.`);
366
+ return;
367
+ }
368
+ console.log('NAME STATUS CLI LAST SEEN');
369
+ console.log('---------------------------------------------');
370
+ onlineAgents.forEach((agent) => {
371
+ const name = (agent.name ?? 'unknown').padEnd(15);
372
+ const status = getAgentStatus(agent).padEnd(8);
373
+ const cli = (agent.cli ?? '-').padEnd(8);
374
+ const lastSeen = formatRelativeTime(agent.lastSeen);
375
+ console.log(`${name} ${status} ${cli} ${lastSeen}`);
376
+ });
377
+ });
161
378
  // read - Read full message by ID (for truncated messages)
162
379
  program
163
380
  .command('read')
@@ -184,6 +401,62 @@ program
184
401
  console.log(msg.body);
185
402
  await adapter.close?.();
186
403
  });
404
+ // ============================================
405
+ // Hidden commands (for agents, not in --help)
406
+ // ============================================
407
+ // history - Show recent messages (hidden from help, for agent use)
408
+ program
409
+ .command('history', { hidden: true })
410
+ .description('Show recent messages')
411
+ .option('-n, --limit <count>', 'Number of messages to show', '50')
412
+ .option('-f, --from <agent>', 'Filter by sender')
413
+ .option('-t, --to <agent>', 'Filter by recipient')
414
+ .option('--since <time>', 'Since time (e.g., "1h", "2024-01-01")')
415
+ .option('--json', 'Output as JSON')
416
+ .action(async (options) => {
417
+ const { getProjectPaths } = await import('../utils/project-namespace.js');
418
+ const { createStorageAdapter } = await import('../storage/adapter.js');
419
+ const paths = getProjectPaths();
420
+ const adapter = await createStorageAdapter(paths.dbPath);
421
+ const limit = Number.parseInt(options.limit ?? '50', 10) || 50;
422
+ const sinceTs = parseSince(options.since);
423
+ try {
424
+ const messages = await adapter.getMessages({
425
+ limit,
426
+ from: options.from,
427
+ to: options.to,
428
+ sinceTs,
429
+ order: 'desc',
430
+ });
431
+ if (options.json) {
432
+ const payload = messages.map((m) => ({
433
+ id: m.id,
434
+ ts: m.ts,
435
+ timestamp: new Date(m.ts).toISOString(),
436
+ from: m.from,
437
+ to: m.to,
438
+ topic: m.topic,
439
+ thread: m.thread,
440
+ kind: m.kind,
441
+ body: m.body,
442
+ }));
443
+ console.log(JSON.stringify(payload, null, 2));
444
+ return;
445
+ }
446
+ if (!messages.length) {
447
+ console.log('No messages found.');
448
+ return;
449
+ }
450
+ messages.forEach((msg) => {
451
+ const ts = new Date(msg.ts).toISOString();
452
+ const body = msg.body.length > 120 ? `${msg.body.slice(0, 117)}...` : msg.body;
453
+ console.log(`${ts} ${msg.from} -> ${msg.to}:${body}`);
454
+ });
455
+ }
456
+ finally {
457
+ await adapter.close?.();
458
+ }
459
+ });
187
460
  // version - Show version info
188
461
  program
189
462
  .command('version')
@@ -191,5 +464,632 @@ program
191
464
  .action(() => {
192
465
  console.log(`agent-relay v${VERSION}`);
193
466
  });
467
+ // bridge - Multi-project orchestration
468
+ program
469
+ .command('bridge')
470
+ .description('Bridge multiple projects as orchestrator')
471
+ .argument('[projects...]', 'Project paths to bridge')
472
+ .option('--cli <tool>', 'CLI tool override for all projects')
473
+ .action(async (projectPaths, options) => {
474
+ const { resolveProjects, validateDaemons } = await import('../bridge/config.js');
475
+ const { MultiProjectClient } = await import('../bridge/multi-project-client.js');
476
+ const { getProjectPaths } = await import('../utils/project-namespace.js');
477
+ const fs = await import('node:fs');
478
+ const pathModule = await import('node:path');
479
+ // Resolve projects from args or config
480
+ const projects = resolveProjects(projectPaths, options.cli);
481
+ if (projects.length === 0) {
482
+ console.error('No projects specified.');
483
+ console.error('Usage: agent-relay bridge ~/project1 ~/project2');
484
+ console.error(' or: Create ~/.agent-relay/bridge.json with project config');
485
+ process.exit(1);
486
+ }
487
+ console.log('Bridge Mode - Multi-Project Orchestration');
488
+ console.log('─'.repeat(40));
489
+ // Check which daemons are running
490
+ const { valid, missing } = validateDaemons(projects);
491
+ if (missing.length > 0) {
492
+ console.error('\nMissing daemons for:');
493
+ for (const p of missing) {
494
+ console.error(` - ${p.path}`);
495
+ console.error(` Run: cd "${p.path}" && agent-relay up`);
496
+ }
497
+ console.error('');
498
+ }
499
+ if (valid.length === 0) {
500
+ console.error('No projects have running daemons. Start them first.');
501
+ process.exit(1);
502
+ }
503
+ console.log('\nConnecting to projects:');
504
+ for (const p of valid) {
505
+ console.log(` - ${p.id} (${p.path})`);
506
+ console.log(` Lead: ${p.leadName}, CLI: ${p.cli}`);
507
+ }
508
+ console.log('');
509
+ // Get data directories for ALL bridged projects (so each project's dashboard can show bridge state)
510
+ const bridgeStatePaths = valid.map(p => {
511
+ const projectPaths = getProjectPaths(p.path);
512
+ // Ensure directory exists
513
+ if (!fs.existsSync(projectPaths.dataDir)) {
514
+ fs.mkdirSync(projectPaths.dataDir, { recursive: true });
515
+ }
516
+ return pathModule.join(projectPaths.dataDir, 'bridge-state.json');
517
+ });
518
+ const bridgeState = {
519
+ projects: valid.map(p => ({
520
+ id: p.id,
521
+ name: pathModule.basename(p.path),
522
+ path: p.path,
523
+ connected: false,
524
+ lead: { name: p.leadName, connected: false },
525
+ agents: [],
526
+ })),
527
+ messages: [],
528
+ connected: false,
529
+ startedAt: new Date().toISOString(),
530
+ };
531
+ // Write bridge state to ALL project data directories
532
+ const writeBridgeState = () => {
533
+ const stateJson = JSON.stringify(bridgeState, null, 2);
534
+ for (const statePath of bridgeStatePaths) {
535
+ try {
536
+ fs.writeFileSync(statePath, stateJson);
537
+ }
538
+ catch (err) {
539
+ console.error(`[bridge] Failed to write state to ${statePath}:`, err);
540
+ }
541
+ }
542
+ };
543
+ // Initial state write
544
+ writeBridgeState();
545
+ console.log(`Bridge state written to ${bridgeStatePaths.length} project(s)`);
546
+ // Connect to all project daemons
547
+ const client = new MultiProjectClient(valid);
548
+ // Track connection state changes (daemon connection, not agent registration)
549
+ // Also track "reconnecting" state for UI feedback
550
+ const wasConnected = new Map();
551
+ client.onProjectStateChange = (projectId, connected) => {
552
+ const project = bridgeState.projects.find(p => p.id === projectId);
553
+ if (project) {
554
+ const hadConnection = wasConnected.get(projectId) || false;
555
+ project.connected = connected;
556
+ // Set reconnecting if we lost connection (had it before, now disconnected)
557
+ project.reconnecting = !connected && hadConnection;
558
+ wasConnected.set(projectId, connected);
559
+ // Note: lead.connected should only be true when an actual lead agent registers
560
+ // The bridge connecting to daemon doesn't mean a lead agent is active
561
+ }
562
+ bridgeState.connected = bridgeState.projects.some(p => p.connected);
563
+ writeBridgeState();
564
+ };
565
+ try {
566
+ await client.connect();
567
+ }
568
+ catch (err) {
569
+ console.error('Failed to connect to all projects');
570
+ writeBridgeState(); // Write final state before exit
571
+ process.exit(1);
572
+ }
573
+ bridgeState.connected = true;
574
+ writeBridgeState();
575
+ console.log('Connected to all projects.');
576
+ console.log('');
577
+ console.log('Cross-project messaging:');
578
+ console.log(' @relay:projectId:agent Message');
579
+ console.log(' @relay:*:lead Broadcast to all leads');
580
+ console.log('');
581
+ // Handle messages from projects
582
+ client.onMessage = (projectId, from, payload, messageId) => {
583
+ console.log(`[${projectId}] ${from}: ${payload.body.substring(0, 80)}...`);
584
+ // Track message in bridge state
585
+ bridgeState.messages.push({
586
+ id: messageId,
587
+ from,
588
+ to: '*', // Incoming messages are from agents
589
+ body: payload.body,
590
+ sourceProject: projectId,
591
+ timestamp: new Date().toISOString(),
592
+ });
593
+ // Keep last 100 messages
594
+ if (bridgeState.messages.length > 100) {
595
+ bridgeState.messages = bridgeState.messages.slice(-100);
596
+ }
597
+ writeBridgeState();
598
+ };
599
+ // Clean up on exit
600
+ const cleanup = () => {
601
+ bridgeState.connected = false;
602
+ bridgeState.projects.forEach(p => {
603
+ p.connected = false;
604
+ if (p.lead)
605
+ p.lead.connected = false;
606
+ });
607
+ writeBridgeState();
608
+ };
609
+ // Keep running
610
+ process.on('SIGINT', () => {
611
+ console.log('\nDisconnecting...');
612
+ cleanup();
613
+ client.disconnect();
614
+ process.exit(0);
615
+ });
616
+ // Start a simple REPL for sending messages
617
+ const readline = await import('node:readline');
618
+ const rl = readline.createInterface({
619
+ input: process.stdin,
620
+ output: process.stdout,
621
+ });
622
+ console.log('Enter messages as: projectId:agent message');
623
+ console.log('Or: *:lead message (broadcast to all leads)');
624
+ console.log('Type "quit" to exit.\n');
625
+ const promptForInput = () => {
626
+ rl.question('> ', (input) => {
627
+ if (input.toLowerCase() === 'quit') {
628
+ client.disconnect();
629
+ rl.close();
630
+ process.exit(0);
631
+ }
632
+ // Parse input: projectId:agent message
633
+ const match = input.match(/^(\S+):(\S+)\s+(.+)$/);
634
+ if (match) {
635
+ const [, projectId, agent, message] = match;
636
+ if (projectId === '*' && agent === 'lead') {
637
+ client.broadcastToLeads(message);
638
+ console.log('→ Broadcast to all leads');
639
+ }
640
+ else if (projectId === '*') {
641
+ client.broadcastAll(message);
642
+ console.log('→ Broadcast to all');
643
+ }
644
+ else {
645
+ const sent = client.sendToProject(projectId, agent, message);
646
+ if (sent) {
647
+ console.log(`→ ${projectId}:${agent}`);
648
+ }
649
+ }
650
+ }
651
+ else {
652
+ console.log('Format: projectId:agent message');
653
+ }
654
+ promptForInput();
655
+ });
656
+ };
657
+ promptForInput();
658
+ });
659
+ // gc - Clean up orphaned tmux sessions (hidden - for agent use)
660
+ program
661
+ .command('gc', { hidden: true })
662
+ .description('Clean up orphaned tmux sessions (sessions with no connected agent)')
663
+ .option('--dry-run', 'Show what would be cleaned without actually doing it')
664
+ .option('--force', 'Kill all relay sessions regardless of connection status')
665
+ .action(async (options) => {
666
+ const { getProjectPaths } = await import('../utils/project-namespace.js');
667
+ const paths = getProjectPaths();
668
+ const agentsPath = path.join(paths.teamDir, 'agents.json');
669
+ // Get all relay tmux sessions
670
+ const sessions = await discoverRelaySessions();
671
+ if (!sessions.length) {
672
+ console.log('No relay tmux sessions found.');
673
+ return;
674
+ }
675
+ // Get connected agents
676
+ const connectedAgents = new Set();
677
+ if (!options.force) {
678
+ const agents = loadAgents(agentsPath);
679
+ // Consider an agent "connected" if last seen within 30 seconds
680
+ const staleThresholdMs = 30_000;
681
+ const now = Date.now();
682
+ agents.forEach(a => {
683
+ if (a.name && a.lastSeen) {
684
+ const lastSeenTs = Date.parse(a.lastSeen);
685
+ if (!Number.isNaN(lastSeenTs) && now - lastSeenTs < staleThresholdMs) {
686
+ connectedAgents.add(a.name);
687
+ }
688
+ }
689
+ });
690
+ }
691
+ // Find orphaned sessions
692
+ const orphaned = sessions.filter(s => options.force || (s.agentName && !connectedAgents.has(s.agentName)));
693
+ if (!orphaned.length) {
694
+ console.log(`All ${sessions.length} session(s) have active agents.`);
695
+ return;
696
+ }
697
+ console.log(`Found ${orphaned.length} orphaned session(s):`);
698
+ for (const session of orphaned) {
699
+ console.log(` - ${session.sessionName} (agent: ${session.agentName ?? 'unknown'})`);
700
+ }
701
+ if (options.dryRun) {
702
+ console.log('\nDry run - no sessions killed.');
703
+ return;
704
+ }
705
+ // Kill orphaned sessions
706
+ let killed = 0;
707
+ for (const session of orphaned) {
708
+ try {
709
+ await execAsync(`tmux kill-session -t ${session.sessionName}`);
710
+ killed++;
711
+ console.log(`Killed: ${session.sessionName}`);
712
+ }
713
+ catch (err) {
714
+ console.error(`Failed to kill ${session.sessionName}: ${err.message}`);
715
+ }
716
+ }
717
+ console.log(`\nCleaned up ${killed}/${orphaned.length} session(s).`);
718
+ });
719
+ async function discoverRelaySessions() {
720
+ try {
721
+ const { stdout } = await execAsync('tmux list-sessions -F "#{session_name}"');
722
+ const sessionNames = stdout
723
+ .split('\n')
724
+ .map(s => s.trim())
725
+ .filter(Boolean);
726
+ const relaySessions = sessionNames
727
+ .map(name => {
728
+ const match = name.match(/^relay-(.+)$/);
729
+ if (!match)
730
+ return undefined;
731
+ return { sessionName: name, agentName: match[1] };
732
+ })
733
+ .filter((s) => Boolean(s));
734
+ return await Promise.all(relaySessions.map(async (session) => {
735
+ let cwd;
736
+ try {
737
+ const { stdout: cwdOut } = await execAsync(`tmux display-message -t ${session.sessionName} -p '#{pane_current_path}'`);
738
+ cwd = cwdOut.trim() || undefined;
739
+ }
740
+ catch {
741
+ cwd = undefined;
742
+ }
743
+ return { ...session, cwd };
744
+ }));
745
+ }
746
+ catch {
747
+ return [];
748
+ }
749
+ }
750
+ function logRelaySessions(sessions) {
751
+ if (!sessions.length) {
752
+ console.log('Relay tmux sessions: none detected');
753
+ return;
754
+ }
755
+ console.log('Relay tmux sessions:');
756
+ sessions.forEach((session) => {
757
+ const parts = [
758
+ `agent: ${session.agentName ?? 'unknown'}`,
759
+ session.cwd ? `cwd: ${session.cwd}` : undefined,
760
+ ].filter(Boolean);
761
+ console.log(`- ${session.sessionName}${parts.length ? ` (${parts.join(', ')})` : ''}`);
762
+ });
763
+ }
764
+ function loadAgents(agentsPath) {
765
+ if (!fs.existsSync(agentsPath)) {
766
+ return [];
767
+ }
768
+ try {
769
+ const raw = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
770
+ const agentsArray = Array.isArray(raw?.agents)
771
+ ? raw.agents
772
+ : raw?.agents
773
+ ? Object.values(raw.agents)
774
+ : [];
775
+ return agentsArray
776
+ .filter((a) => a?.name)
777
+ .map((a) => ({
778
+ id: a.id,
779
+ name: a.name,
780
+ cli: a.cli,
781
+ workingDirectory: a.workingDirectory,
782
+ firstSeen: a.firstSeen,
783
+ lastSeen: a.lastSeen,
784
+ messagesSent: typeof a.messagesSent === 'number' ? a.messagesSent : 0,
785
+ messagesReceived: typeof a.messagesReceived === 'number' ? a.messagesReceived : 0,
786
+ }));
787
+ }
788
+ catch (err) {
789
+ console.error('Failed to read agents.json:', err.message);
790
+ return [];
791
+ }
792
+ }
793
+ const STALE_THRESHOLD_MS = 30_000;
794
+ // Internal agents that should be hidden from `agents` and `who` by default
795
+ const INTERNAL_AGENTS = new Set(['cli', 'Dashboard']);
796
+ function isInternalAgent(name) {
797
+ if (!name)
798
+ return true;
799
+ if (name.startsWith('__'))
800
+ return true;
801
+ return INTERNAL_AGENTS.has(name);
802
+ }
803
+ function getAgentStatus(agent) {
804
+ if (!agent.lastSeen)
805
+ return 'UNKNOWN';
806
+ const ts = Date.parse(agent.lastSeen);
807
+ if (Number.isNaN(ts))
808
+ return 'UNKNOWN';
809
+ return Date.now() - ts < STALE_THRESHOLD_MS ? 'ONLINE' : 'STALE';
810
+ }
811
+ function isAgentOnline(agent) {
812
+ return getAgentStatus(agent) === 'ONLINE';
813
+ }
814
+ // Visible agents: not internal and not stale (used by `agents` command)
815
+ function isVisibleAgent(agent) {
816
+ if (isInternalAgent(agent.name))
817
+ return false;
818
+ if (getAgentStatus(agent) === 'STALE')
819
+ return false;
820
+ return true;
821
+ }
822
+ function formatRelativeTime(iso) {
823
+ if (!iso)
824
+ return 'unknown';
825
+ const ts = Date.parse(iso);
826
+ if (Number.isNaN(ts))
827
+ return 'unknown';
828
+ const diffMs = Date.now() - ts;
829
+ const diffSec = Math.floor(diffMs / 1000);
830
+ if (diffSec < 60)
831
+ return `${diffSec}s ago`;
832
+ const diffMin = Math.floor(diffSec / 60);
833
+ if (diffMin < 60)
834
+ return `${diffMin}m ago`;
835
+ const diffHours = Math.floor(diffMin / 60);
836
+ if (diffHours < 48)
837
+ return `${diffHours}h ago`;
838
+ const diffDays = Math.floor(diffHours / 24);
839
+ return `${diffDays}d ago`;
840
+ }
841
+ function parseSince(input) {
842
+ if (!input)
843
+ return undefined;
844
+ const trimmed = String(input).trim();
845
+ if (!trimmed)
846
+ return undefined;
847
+ const durationMatch = trimmed.match(/^(-?\d+)([smhd])$/i);
848
+ if (durationMatch) {
849
+ const value = Number(durationMatch[1]);
850
+ const unit = durationMatch[2].toLowerCase();
851
+ const multipliers = {
852
+ s: 1000,
853
+ m: 60_000,
854
+ h: 3_600_000,
855
+ d: 86_400_000,
856
+ };
857
+ return Date.now() - value * multipliers[unit];
858
+ }
859
+ const parsed = Date.parse(trimmed);
860
+ if (Number.isNaN(parsed))
861
+ return undefined;
862
+ return parsed;
863
+ }
864
+ // ============================================
865
+ // Spawn/Worker debugging commands
866
+ // ============================================
867
+ const WORKER_SESSION = 'relay-workers';
868
+ // workers - List spawned workers
869
+ program
870
+ .command('workers')
871
+ .description('List spawned worker agents (from tmux)')
872
+ .option('--json', 'Output as JSON')
873
+ .action(async (options) => {
874
+ try {
875
+ // Check if worker session exists
876
+ try {
877
+ await execAsync(`tmux has-session -t ${WORKER_SESSION} 2>/dev/null`);
878
+ }
879
+ catch {
880
+ if (options.json) {
881
+ console.log(JSON.stringify({ workers: [], session: null }));
882
+ }
883
+ else {
884
+ console.log('No spawned workers (session does not exist)');
885
+ }
886
+ return;
887
+ }
888
+ // List windows in the worker session
889
+ const { stdout } = await execAsync(`tmux list-windows -t ${WORKER_SESSION} -F "#{window_index}|#{window_name}|#{pane_current_command}|#{window_activity}"`);
890
+ const workers = stdout
891
+ .split('\n')
892
+ .filter(Boolean)
893
+ .map(line => {
894
+ const [index, name, command, activity] = line.split('|');
895
+ const activityTs = parseInt(activity, 10) * 1000;
896
+ const lastActive = isNaN(activityTs) ? undefined : new Date(activityTs).toISOString();
897
+ return {
898
+ index: parseInt(index, 10),
899
+ name,
900
+ command,
901
+ lastActive,
902
+ window: `${WORKER_SESSION}:${name}`,
903
+ };
904
+ })
905
+ // Filter out the default zsh window
906
+ .filter(w => w.name !== 'zsh' && w.command !== 'zsh');
907
+ if (options.json) {
908
+ console.log(JSON.stringify({ workers, session: WORKER_SESSION }, null, 2));
909
+ return;
910
+ }
911
+ if (!workers.length) {
912
+ console.log('No spawned workers');
913
+ return;
914
+ }
915
+ console.log('SPAWNED WORKERS');
916
+ console.log('─'.repeat(50));
917
+ console.log('NAME COMMAND WINDOW');
918
+ console.log('─'.repeat(50));
919
+ workers.forEach(w => {
920
+ const name = w.name.padEnd(15);
921
+ const cmd = (w.command || '-').padEnd(12);
922
+ console.log(`${name} ${cmd} ${w.window}`);
923
+ });
924
+ console.log('');
925
+ console.log('Commands:');
926
+ console.log(' agent-relay workers:logs <name> - View worker output');
927
+ console.log(' agent-relay workers:attach <name> - Attach to worker tmux');
928
+ console.log(' agent-relay workers:kill <name> - Kill a worker');
929
+ }
930
+ catch (err) {
931
+ console.error('Failed to list workers:', err.message);
932
+ }
933
+ });
934
+ // workers:logs - Show tmux pane output for a worker
935
+ program
936
+ .command('workers:logs')
937
+ .description('Show recent output from a spawned worker')
938
+ .argument('<name>', 'Worker name')
939
+ .option('-n, --lines <n>', 'Number of lines to show', '50')
940
+ .option('-f, --follow', 'Follow output (like tail -f)')
941
+ .action(async (name, options) => {
942
+ const window = `${WORKER_SESSION}:${name}`;
943
+ try {
944
+ // Check if window exists
945
+ await execAsync(`tmux has-session -t ${window} 2>/dev/null`);
946
+ }
947
+ catch {
948
+ console.error(`Worker "${name}" not found`);
949
+ console.log(`Run 'agent-relay workers' to see available workers`);
950
+ process.exit(1);
951
+ }
952
+ if (options.follow) {
953
+ console.log(`Following output from ${window} (Ctrl+C to stop)...`);
954
+ console.log('─'.repeat(50));
955
+ // Use a polling approach to follow
956
+ let lastContent = '';
957
+ const poll = async () => {
958
+ try {
959
+ const { stdout } = await execAsync(`tmux capture-pane -t ${window} -p -S -100`);
960
+ if (stdout !== lastContent) {
961
+ // Print only new lines
962
+ const newContent = stdout.replace(lastContent, '');
963
+ if (newContent.trim()) {
964
+ process.stdout.write(newContent);
965
+ }
966
+ lastContent = stdout;
967
+ }
968
+ }
969
+ catch {
970
+ console.error('\nWorker disconnected');
971
+ process.exit(1);
972
+ }
973
+ };
974
+ const interval = setInterval(poll, 500);
975
+ process.on('SIGINT', () => {
976
+ clearInterval(interval);
977
+ console.log('\nStopped following');
978
+ process.exit(0);
979
+ });
980
+ await poll(); // Initial fetch
981
+ await new Promise(() => { }); // Keep running
982
+ }
983
+ else {
984
+ try {
985
+ const lines = parseInt(options.lines || '50', 10);
986
+ const { stdout } = await execAsync(`tmux capture-pane -t ${window} -p -S -${lines}`);
987
+ console.log(`Output from ${window} (last ${lines} lines):`);
988
+ console.log('─'.repeat(50));
989
+ console.log(stdout || '(empty)');
990
+ }
991
+ catch (err) {
992
+ console.error('Failed to capture output:', err.message);
993
+ }
994
+ }
995
+ });
996
+ // workers:attach - Attach to a worker's tmux window
997
+ program
998
+ .command('workers:attach')
999
+ .description('Attach to a spawned worker tmux window')
1000
+ .argument('<name>', 'Worker name')
1001
+ .action(async (name) => {
1002
+ const window = `${WORKER_SESSION}:${name}`;
1003
+ try {
1004
+ // Check if window exists
1005
+ await execAsync(`tmux has-session -t ${window} 2>/dev/null`);
1006
+ }
1007
+ catch {
1008
+ console.error(`Worker "${name}" not found`);
1009
+ console.log(`Run 'agent-relay workers' to see available workers`);
1010
+ process.exit(1);
1011
+ }
1012
+ console.log(`Attaching to ${window}...`);
1013
+ console.log('(Use Ctrl+B D to detach)');
1014
+ // Spawn tmux attach as a child process with stdio inherited
1015
+ const { spawn } = await import('child_process');
1016
+ const child = spawn('tmux', ['attach-session', '-t', window], {
1017
+ stdio: 'inherit',
1018
+ });
1019
+ child.on('exit', (code) => {
1020
+ process.exit(code || 0);
1021
+ });
1022
+ });
1023
+ // workers:kill - Kill a spawned worker
1024
+ program
1025
+ .command('workers:kill')
1026
+ .description('Kill a spawned worker')
1027
+ .argument('<name>', 'Worker name')
1028
+ .option('--force', 'Skip graceful shutdown, kill immediately')
1029
+ .action(async (name, options) => {
1030
+ const window = `${WORKER_SESSION}:${name}`;
1031
+ try {
1032
+ // Check if window exists
1033
+ await execAsync(`tmux has-session -t ${window} 2>/dev/null`);
1034
+ }
1035
+ catch {
1036
+ console.error(`Worker "${name}" not found`);
1037
+ console.log(`Run 'agent-relay workers' to see available workers`);
1038
+ process.exit(1);
1039
+ }
1040
+ if (!options.force) {
1041
+ // Try graceful shutdown first
1042
+ console.log(`Sending /exit to ${name}...`);
1043
+ try {
1044
+ await execAsync(`tmux send-keys -t ${window} '/exit' Enter`);
1045
+ // Wait for graceful shutdown
1046
+ await new Promise(r => setTimeout(r, 2000));
1047
+ }
1048
+ catch {
1049
+ // Ignore errors, will force kill below
1050
+ }
1051
+ }
1052
+ // Kill the window
1053
+ try {
1054
+ await execAsync(`tmux kill-window -t ${window}`);
1055
+ console.log(`Killed worker: ${name}`);
1056
+ }
1057
+ catch (err) {
1058
+ console.error(`Failed to kill ${name}:`, err.message);
1059
+ process.exit(1);
1060
+ }
1061
+ });
1062
+ // workers:session - Show tmux session info
1063
+ program
1064
+ .command('workers:session')
1065
+ .description('Show worker tmux session details')
1066
+ .action(async () => {
1067
+ try {
1068
+ // Check if session exists
1069
+ try {
1070
+ await execAsync(`tmux has-session -t ${WORKER_SESSION} 2>/dev/null`);
1071
+ }
1072
+ catch {
1073
+ console.log(`Session "${WORKER_SESSION}" does not exist`);
1074
+ console.log('Spawn a worker to create it.');
1075
+ return;
1076
+ }
1077
+ console.log(`Session: ${WORKER_SESSION}`);
1078
+ console.log('─'.repeat(50));
1079
+ // Get session info
1080
+ const { stdout: sessionInfo } = await execAsync(`tmux display-message -t ${WORKER_SESSION} -p "Created: #{session_created_string}\\nWindows: #{session_windows}\\nAttached: #{?session_attached,yes,no}"`);
1081
+ console.log(sessionInfo);
1082
+ // List windows
1083
+ console.log('\nWindows:');
1084
+ const { stdout: windows } = await execAsync(`tmux list-windows -t ${WORKER_SESSION} -F " #{window_index}: #{window_name} (#{pane_current_command})"`);
1085
+ console.log(windows || ' (none)');
1086
+ console.log('\nQuick commands:');
1087
+ console.log(` tmux attach -t ${WORKER_SESSION} # Attach to session`);
1088
+ console.log(` tmux kill-session -t ${WORKER_SESSION} # Kill entire session`);
1089
+ }
1090
+ catch (err) {
1091
+ console.error('Failed:', err.message);
1092
+ }
1093
+ });
194
1094
  program.parse();
195
1095
  //# sourceMappingURL=index.js.map