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.
- package/README.md +176 -6
- package/dist/bridge/config.d.ts +41 -0
- package/dist/bridge/config.d.ts.map +1 -0
- package/dist/bridge/config.js +143 -0
- package/dist/bridge/config.js.map +1 -0
- package/dist/bridge/index.d.ts +10 -0
- package/dist/bridge/index.d.ts.map +1 -0
- package/dist/bridge/index.js +10 -0
- package/dist/bridge/index.js.map +1 -0
- package/dist/bridge/multi-project-client.d.ts +99 -0
- package/dist/bridge/multi-project-client.d.ts.map +1 -0
- package/dist/bridge/multi-project-client.js +386 -0
- package/dist/bridge/multi-project-client.js.map +1 -0
- package/dist/bridge/spawner.d.ts +46 -0
- package/dist/bridge/spawner.d.ts.map +1 -0
- package/dist/bridge/spawner.js +223 -0
- package/dist/bridge/spawner.js.map +1 -0
- package/dist/bridge/types.d.ts +55 -0
- package/dist/bridge/types.d.ts.map +1 -0
- package/dist/bridge/types.js +6 -0
- package/dist/bridge/types.js.map +1 -0
- package/dist/bridge/utils.d.ts +30 -0
- package/dist/bridge/utils.d.ts.map +1 -0
- package/dist/bridge/utils.js +54 -0
- package/dist/bridge/utils.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +906 -6
- package/dist/cli/index.js.map +1 -1
- package/dist/daemon/agent-registry.d.ts +60 -0
- package/dist/daemon/agent-registry.d.ts.map +1 -0
- package/dist/daemon/agent-registry.js +163 -0
- package/dist/daemon/agent-registry.js.map +1 -0
- package/dist/daemon/connection.d.ts +33 -1
- package/dist/daemon/connection.d.ts.map +1 -1
- package/dist/daemon/connection.js +86 -11
- package/dist/daemon/connection.js.map +1 -1
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +2 -0
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/registry.d.ts +9 -0
- package/dist/daemon/registry.d.ts.map +1 -0
- package/dist/daemon/registry.js +9 -0
- package/dist/daemon/registry.js.map +1 -0
- package/dist/daemon/router.d.ts +61 -2
- package/dist/daemon/router.d.ts.map +1 -1
- package/dist/daemon/router.js +219 -4
- package/dist/daemon/router.js.map +1 -1
- package/dist/daemon/server.d.ts +9 -0
- package/dist/daemon/server.d.ts.map +1 -1
- package/dist/daemon/server.js +135 -16
- package/dist/daemon/server.js.map +1 -1
- package/dist/dashboard/metrics.d.ts +105 -0
- package/dist/dashboard/metrics.d.ts.map +1 -0
- package/dist/dashboard/metrics.js +192 -0
- package/dist/dashboard/metrics.js.map +1 -0
- package/dist/dashboard/needs-attention.d.ts +24 -0
- package/dist/dashboard/needs-attention.d.ts.map +1 -0
- package/dist/dashboard/needs-attention.js +78 -0
- package/dist/dashboard/needs-attention.js.map +1 -0
- package/dist/dashboard/public/bridge.html +1272 -0
- package/dist/dashboard/public/index.html +2094 -347
- package/dist/dashboard/public/js/app.js +184 -0
- package/dist/dashboard/public/js/app.js.map +7 -0
- package/dist/dashboard/public/metrics.html +999 -0
- package/dist/dashboard/server.d.ts +14 -1
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +689 -16
- package/dist/dashboard/server.js.map +1 -1
- package/dist/dashboard/start.js +1 -1
- package/dist/dashboard/start.js.map +1 -1
- package/dist/dashboard-v2/index.d.ts +10 -0
- package/dist/dashboard-v2/index.d.ts.map +1 -0
- package/dist/dashboard-v2/index.js +54 -0
- package/dist/dashboard-v2/index.js.map +1 -0
- package/dist/dashboard-v2/lib/api.d.ts +95 -0
- package/dist/dashboard-v2/lib/api.d.ts.map +1 -0
- package/dist/dashboard-v2/lib/api.js +270 -0
- package/dist/dashboard-v2/lib/api.js.map +1 -0
- package/dist/dashboard-v2/lib/colors.d.ts +61 -0
- package/dist/dashboard-v2/lib/colors.d.ts.map +1 -0
- package/dist/dashboard-v2/lib/colors.js +198 -0
- package/dist/dashboard-v2/lib/colors.js.map +1 -0
- package/dist/dashboard-v2/lib/hierarchy.d.ts +74 -0
- package/dist/dashboard-v2/lib/hierarchy.d.ts.map +1 -0
- package/dist/dashboard-v2/lib/hierarchy.js +196 -0
- package/dist/dashboard-v2/lib/hierarchy.js.map +1 -0
- package/dist/dashboard-v2/types/index.d.ts +154 -0
- package/dist/dashboard-v2/types/index.d.ts.map +1 -0
- package/dist/dashboard-v2/types/index.js +6 -0
- package/dist/dashboard-v2/types/index.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/protocol/types.d.ts +15 -1
- package/dist/protocol/types.d.ts.map +1 -1
- package/dist/storage/adapter.d.ts +74 -1
- package/dist/storage/adapter.d.ts.map +1 -1
- package/dist/storage/adapter.js +39 -0
- package/dist/storage/adapter.js.map +1 -1
- package/dist/storage/sqlite-adapter.d.ts +92 -1
- package/dist/storage/sqlite-adapter.d.ts.map +1 -1
- package/dist/storage/sqlite-adapter.js +615 -47
- package/dist/storage/sqlite-adapter.js.map +1 -1
- package/dist/utils/agent-config.d.ts +45 -0
- package/dist/utils/agent-config.d.ts.map +1 -0
- package/dist/utils/agent-config.js +118 -0
- package/dist/utils/agent-config.js.map +1 -0
- package/dist/utils/project-namespace.d.ts.map +1 -1
- package/dist/utils/project-namespace.js +22 -1
- package/dist/utils/project-namespace.js.map +1 -1
- package/dist/wrapper/client.d.ts +30 -3
- package/dist/wrapper/client.d.ts.map +1 -1
- package/dist/wrapper/client.js +85 -9
- package/dist/wrapper/client.js.map +1 -1
- package/dist/wrapper/parser.d.ts +127 -4
- package/dist/wrapper/parser.d.ts.map +1 -1
- package/dist/wrapper/parser.js +622 -86
- package/dist/wrapper/parser.js.map +1 -1
- package/dist/wrapper/tmux-wrapper.d.ts +136 -10
- package/dist/wrapper/tmux-wrapper.d.ts.map +1 -1
- package/dist/wrapper/tmux-wrapper.js +599 -79
- package/dist/wrapper/tmux-wrapper.js.map +1 -1
- package/docs/AGENTS.md +132 -27
- package/docs/ARCHITECTURE_DECISIONS.md +175 -0
- package/docs/CHANGELOG.md +1 -1
- package/docs/COMPETITIVE_ANALYSIS.md +897 -0
- package/docs/DESIGN_BRIDGE_STAFFING.md +878 -0
- package/docs/DESIGN_V2.md +1079 -0
- package/docs/INTEGRATION-GUIDE.md +926 -0
- package/docs/MONETIZATION.md +1679 -0
- package/docs/PROPOSAL-trajectories.md +1582 -0
- package/docs/PROTOCOL.md +3 -3
- package/docs/SCALING_ANALYSIS.md +280 -0
- package/docs/TMUX_IMPLEMENTATION_NOTES.md +9 -9
- package/docs/TMUX_IMPROVEMENTS.md +968 -0
- package/docs/agent-relay-snippet.md +61 -0
- package/docs/competitive-analysis-mcp-agent-mail.md +389 -0
- package/docs/dashboard-v2-plan.md +179 -0
- 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:
|
|
78
|
+
args: finalArgs,
|
|
57
79
|
socketPath: paths.socketPath,
|
|
58
|
-
debug:
|
|
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
|
-
|
|
103
|
-
|
|
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
|