@agtd/agent 0.1.0
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/bin/agtd-agent.js +2 -0
- package/dist/agent.js +6 -0
- package/dist/chunk-24ORBJSI.js +989 -0
- package/dist/cli.js +167 -0
- package/package.json +31 -0
package/dist/agent.js
ADDED
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
import { hostname, platform, homedir } from "os";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
function resolveConfigPath(cliPath) {
|
|
6
|
+
if (cliPath) return cliPath;
|
|
7
|
+
const envPath = process.env["AGENT_CONFIG"];
|
|
8
|
+
if (envPath && existsSync(envPath)) return envPath;
|
|
9
|
+
const homePath = resolve(homedir(), ".agtd", "agent.config.json");
|
|
10
|
+
if (existsSync(homePath)) return homePath;
|
|
11
|
+
const cwdPath = resolve(process.cwd(), "agent.config.json");
|
|
12
|
+
if (existsSync(cwdPath)) return cwdPath;
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
function parseConfigFile(configPath) {
|
|
16
|
+
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
17
|
+
return {
|
|
18
|
+
deviceId: raw.deviceId ?? hostname(),
|
|
19
|
+
deviceName: raw.deviceName ?? `${hostname()}-${platform()}`,
|
|
20
|
+
backendUrl: raw.backendUrl ?? "http://localhost:3001",
|
|
21
|
+
apiKey: raw.apiKey ?? "",
|
|
22
|
+
projects: Array.isArray(raw.projects) ? raw.projects : [],
|
|
23
|
+
projectDirs: Array.isArray(raw.projectDirs) ? raw.projectDirs : []
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function mergeCliOverrides(config, overrides) {
|
|
27
|
+
return {
|
|
28
|
+
...config,
|
|
29
|
+
...overrides.backend && { backendUrl: overrides.backend },
|
|
30
|
+
...overrides.apiKey && { apiKey: overrides.apiKey },
|
|
31
|
+
...overrides.deviceName && { deviceName: overrides.deviceName }
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function loadConfig(cliPath) {
|
|
35
|
+
const configPath = resolveConfigPath(cliPath);
|
|
36
|
+
if (!configPath) {
|
|
37
|
+
throw new Error("No config file found");
|
|
38
|
+
}
|
|
39
|
+
return parseConfigFile(configPath);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/register.ts
|
|
43
|
+
import { hostname as hostname2, platform as platform2 } from "os";
|
|
44
|
+
async function registerDevice(config) {
|
|
45
|
+
const payload = {
|
|
46
|
+
id: config.deviceId,
|
|
47
|
+
name: config.deviceName,
|
|
48
|
+
host: hostname2(),
|
|
49
|
+
os: platform2()
|
|
50
|
+
};
|
|
51
|
+
const res = await fetch(`${config.backendUrl}/api/register-device`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: {
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
"x-api-key": config.apiKey
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify(payload)
|
|
58
|
+
});
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
throw new Error(`Register device failed: ${res.status} ${res.statusText}`);
|
|
61
|
+
}
|
|
62
|
+
console.log(`Device registered: ${config.deviceId}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/syncProjects.ts
|
|
66
|
+
import { execSync } from "child_process";
|
|
67
|
+
import { readdirSync, statSync, existsSync as existsSync2 } from "fs";
|
|
68
|
+
import { join, basename } from "path";
|
|
69
|
+
function getGitRemoteUrl(projectPath) {
|
|
70
|
+
try {
|
|
71
|
+
return execSync("git remote get-url origin", {
|
|
72
|
+
cwd: projectPath,
|
|
73
|
+
encoding: "utf-8",
|
|
74
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
75
|
+
}).trim();
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function discoverProjectsInDir(dir) {
|
|
81
|
+
if (!existsSync2(dir)) return [];
|
|
82
|
+
try {
|
|
83
|
+
const entries = readdirSync(dir);
|
|
84
|
+
return entries.filter((entry) => {
|
|
85
|
+
if (entry.startsWith(".")) return false;
|
|
86
|
+
if (entry === "node_modules") return false;
|
|
87
|
+
const full = join(dir, entry);
|
|
88
|
+
try {
|
|
89
|
+
return statSync(full).isDirectory();
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}).map((entry) => ({
|
|
94
|
+
name: `${entry} (${dir})`,
|
|
95
|
+
path: join(dir, entry)
|
|
96
|
+
}));
|
|
97
|
+
} catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function syncProjects(config) {
|
|
102
|
+
const seen = /* @__PURE__ */ new Set();
|
|
103
|
+
const allProjects = [];
|
|
104
|
+
for (const p of config.projects) {
|
|
105
|
+
if (!seen.has(p.path)) {
|
|
106
|
+
seen.add(p.path);
|
|
107
|
+
allProjects.push({
|
|
108
|
+
name: `${basename(p.path)} (${p.path})`,
|
|
109
|
+
path: p.path,
|
|
110
|
+
repoUrl: getGitRemoteUrl(p.path)
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const dir of config.projectDirs) {
|
|
115
|
+
const discovered = discoverProjectsInDir(dir);
|
|
116
|
+
for (const p of discovered) {
|
|
117
|
+
if (!seen.has(p.path)) {
|
|
118
|
+
seen.add(p.path);
|
|
119
|
+
allProjects.push({
|
|
120
|
+
name: p.name,
|
|
121
|
+
path: p.path,
|
|
122
|
+
repoUrl: getGitRemoteUrl(p.path)
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const payload = {
|
|
128
|
+
deviceId: config.deviceId,
|
|
129
|
+
projects: allProjects
|
|
130
|
+
};
|
|
131
|
+
const res = await fetch(`${config.backendUrl}/api/projects/sync`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: {
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
"x-api-key": config.apiKey,
|
|
136
|
+
"x-device-id": config.deviceId
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify(payload)
|
|
139
|
+
});
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
throw new Error(`Sync projects failed: ${res.status} ${res.statusText}`);
|
|
142
|
+
}
|
|
143
|
+
console.log(`Synced ${allProjects.length} project(s)`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/wsClient.ts
|
|
147
|
+
import WebSocket2 from "ws";
|
|
148
|
+
|
|
149
|
+
// src/spawn.ts
|
|
150
|
+
import { v4 as uuidv4 } from "uuid";
|
|
151
|
+
|
|
152
|
+
// src/tmux.ts
|
|
153
|
+
import { execSync as execSync2 } from "child_process";
|
|
154
|
+
function sanitizeShellArg(s) {
|
|
155
|
+
return s.replace(/'/g, "'\\''");
|
|
156
|
+
}
|
|
157
|
+
function isTmuxAvailable() {
|
|
158
|
+
try {
|
|
159
|
+
execSync2("tmux list-sessions", {
|
|
160
|
+
encoding: "utf-8",
|
|
161
|
+
timeout: 3e3,
|
|
162
|
+
stdio: "pipe"
|
|
163
|
+
});
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function buildCreateSessionCmd(sessionName, cwd) {
|
|
170
|
+
return `tmux new-session -d -s '${sanitizeShellArg(sessionName)}' -c '${sanitizeShellArg(cwd)}'`;
|
|
171
|
+
}
|
|
172
|
+
function buildSendKeysCmd(target, keys) {
|
|
173
|
+
const hex = Array.from(Buffer.from(keys)).map((b) => b.toString(16).padStart(2, "0")).join(" ");
|
|
174
|
+
return `tmux send-keys -t '${sanitizeShellArg(target)}' -H ${hex}`;
|
|
175
|
+
}
|
|
176
|
+
function buildCapturePaneCmd(target) {
|
|
177
|
+
return `tmux capture-pane -t '${sanitizeShellArg(target)}' -p -e -S -`;
|
|
178
|
+
}
|
|
179
|
+
function tmuxExec(cmd) {
|
|
180
|
+
try {
|
|
181
|
+
return execSync2(cmd, { encoding: "utf-8", timeout: 5e3 });
|
|
182
|
+
} catch (e) {
|
|
183
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
184
|
+
throw new Error(`tmux command failed: ${msg}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function tmuxSessionExists(sessionName) {
|
|
188
|
+
try {
|
|
189
|
+
execSync2(`tmux has-session -t '${sanitizeShellArg(sessionName)}'`, {
|
|
190
|
+
timeout: 3e3
|
|
191
|
+
});
|
|
192
|
+
return true;
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function capturePaneOutput(target) {
|
|
198
|
+
return tmuxExec(buildCapturePaneCmd(target));
|
|
199
|
+
}
|
|
200
|
+
function sendKeys(target, input) {
|
|
201
|
+
tmuxExec(buildSendKeysCmd(target, input));
|
|
202
|
+
}
|
|
203
|
+
function createSession(sessionName, cwd) {
|
|
204
|
+
if (!tmuxSessionExists(sessionName)) {
|
|
205
|
+
tmuxExec(buildCreateSessionCmd(sessionName, cwd));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/adapters/claude-code.ts
|
|
210
|
+
function escapeForShell(str) {
|
|
211
|
+
return str.replace(/'/g, "'\\''");
|
|
212
|
+
}
|
|
213
|
+
var claudeCodeAdapter = {
|
|
214
|
+
type: "claude-code",
|
|
215
|
+
buildSpawnCommand(_task, cwd) {
|
|
216
|
+
const escapedCwd = escapeForShell(cwd);
|
|
217
|
+
return `cd '${escapedCwd}' && claude`;
|
|
218
|
+
},
|
|
219
|
+
hookInstallInstructions() {
|
|
220
|
+
return [
|
|
221
|
+
"To install the Claude Code hook for the agent dashboard:",
|
|
222
|
+
"",
|
|
223
|
+
"1. Edit (or create) ~/.claude/hooks.json",
|
|
224
|
+
"2. Add the following hook configuration:",
|
|
225
|
+
"",
|
|
226
|
+
" {",
|
|
227
|
+
' "hooks": {',
|
|
228
|
+
' "PreToolUse": [',
|
|
229
|
+
" {",
|
|
230
|
+
' "type": "command",',
|
|
231
|
+
' "command": "/path/to/packages/agent/src/hooks/claude-hook.sh PreToolUse"',
|
|
232
|
+
" }",
|
|
233
|
+
" ],",
|
|
234
|
+
' "PostToolUse": [',
|
|
235
|
+
" {",
|
|
236
|
+
' "type": "command",',
|
|
237
|
+
' "command": "/path/to/packages/agent/src/hooks/claude-hook.sh PostToolUse"',
|
|
238
|
+
" }",
|
|
239
|
+
" ],",
|
|
240
|
+
' "Stop": [',
|
|
241
|
+
" {",
|
|
242
|
+
' "type": "command",',
|
|
243
|
+
' "command": "/path/to/packages/agent/src/hooks/claude-hook.sh Stop"',
|
|
244
|
+
" }",
|
|
245
|
+
" ]",
|
|
246
|
+
" }",
|
|
247
|
+
" }",
|
|
248
|
+
"",
|
|
249
|
+
"3. Set the following environment variables:",
|
|
250
|
+
" - AGENT_DASHBOARD_BACKEND (e.g., http://localhost:3001)",
|
|
251
|
+
" - AGENT_DASHBOARD_DEVICE_ID",
|
|
252
|
+
" - AGENT_DASHBOARD_API_KEY"
|
|
253
|
+
].join("\n");
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// src/adapters/codex.ts
|
|
258
|
+
function escapeForShell2(str) {
|
|
259
|
+
return str.replace(/'/g, "'\\''");
|
|
260
|
+
}
|
|
261
|
+
var codexAdapter = {
|
|
262
|
+
type: "codex",
|
|
263
|
+
buildSpawnCommand(task, cwd) {
|
|
264
|
+
const escapedTask = escapeForShell2(task);
|
|
265
|
+
const escapedCwd = escapeForShell2(cwd);
|
|
266
|
+
return `cd '${escapedCwd}' && codex '${escapedTask}'`;
|
|
267
|
+
},
|
|
268
|
+
hookInstallInstructions() {
|
|
269
|
+
return [
|
|
270
|
+
"To install the Codex hook for the agent dashboard:",
|
|
271
|
+
"",
|
|
272
|
+
"1. Use the codex-hook.sh wrapper script instead of calling codex directly:",
|
|
273
|
+
"",
|
|
274
|
+
" ./packages/agent/src/hooks/codex-hook.sh 'your task here'",
|
|
275
|
+
"",
|
|
276
|
+
" The wrapper script will:",
|
|
277
|
+
" - Send a working heartbeat before running codex",
|
|
278
|
+
" - Start a background keepalive loop (every 10s)",
|
|
279
|
+
" - Run codex with the provided task",
|
|
280
|
+
" - Send an idle heartbeat on exit",
|
|
281
|
+
"",
|
|
282
|
+
"2. Set the following environment variables:",
|
|
283
|
+
" - AGENT_DASHBOARD_BACKEND (e.g., http://localhost:3001)",
|
|
284
|
+
" - AGENT_DASHBOARD_DEVICE_ID",
|
|
285
|
+
" - AGENT_DASHBOARD_API_KEY",
|
|
286
|
+
" - AGENT_DASHBOARD_SESSION_ID (unique session identifier)"
|
|
287
|
+
].join("\n");
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// src/adapters/generic.ts
|
|
292
|
+
function escapeForShell3(str) {
|
|
293
|
+
return str.replace(/'/g, "'\\''");
|
|
294
|
+
}
|
|
295
|
+
var genericAdapter = {
|
|
296
|
+
type: "generic",
|
|
297
|
+
buildSpawnCommand(task, cwd) {
|
|
298
|
+
const escapedTask = escapeForShell3(task);
|
|
299
|
+
const escapedCwd = escapeForShell3(cwd);
|
|
300
|
+
return `cd '${escapedCwd}' && echo 'Task: ${escapedTask}'`;
|
|
301
|
+
},
|
|
302
|
+
hookInstallInstructions() {
|
|
303
|
+
return [
|
|
304
|
+
"To send heartbeats from a generic agent to the dashboard:",
|
|
305
|
+
"",
|
|
306
|
+
"1. Use the generic-hook.sh script to send manual heartbeat updates:",
|
|
307
|
+
"",
|
|
308
|
+
" ./packages/agent/src/hooks/generic-hook.sh <session_id> <status> [task]",
|
|
309
|
+
"",
|
|
310
|
+
" Examples:",
|
|
311
|
+
' ./packages/agent/src/hooks/generic-hook.sh my-session working "Implementing feature X"',
|
|
312
|
+
" ./packages/agent/src/hooks/generic-hook.sh my-session idle",
|
|
313
|
+
" ./packages/agent/src/hooks/generic-hook.sh my-session awaiting_permission",
|
|
314
|
+
"",
|
|
315
|
+
"2. Or send heartbeats directly via curl:",
|
|
316
|
+
"",
|
|
317
|
+
" curl -X POST $AGENT_DASHBOARD_BACKEND/api/agent/heartbeat \\",
|
|
318
|
+
' -H "Content-Type: application/json" \\',
|
|
319
|
+
' -H "x-api-key: $AGENT_DASHBOARD_API_KEY" \\',
|
|
320
|
+
` -d '{"deviceId": "...", "sessionId": "...", "status": "working"}'`,
|
|
321
|
+
"",
|
|
322
|
+
"3. Set the following environment variables:",
|
|
323
|
+
" - AGENT_DASHBOARD_BACKEND (e.g., http://localhost:3001)",
|
|
324
|
+
" - AGENT_DASHBOARD_DEVICE_ID",
|
|
325
|
+
" - AGENT_DASHBOARD_API_KEY"
|
|
326
|
+
].join("\n");
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// src/adapters/index.ts
|
|
331
|
+
var adapters = {
|
|
332
|
+
"claude-code": claudeCodeAdapter,
|
|
333
|
+
"codex": codexAdapter,
|
|
334
|
+
"generic": genericAdapter
|
|
335
|
+
};
|
|
336
|
+
function getAdapter(agentType) {
|
|
337
|
+
return adapters[agentType] || genericAdapter;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/heartbeat.ts
|
|
341
|
+
async function sendHeartbeat(config, payload) {
|
|
342
|
+
const res = await fetch(`${config.backendUrl}/api/session-heartbeat`, {
|
|
343
|
+
method: "POST",
|
|
344
|
+
headers: {
|
|
345
|
+
"Content-Type": "application/json",
|
|
346
|
+
"x-api-key": config.apiKey,
|
|
347
|
+
"x-device-id": config.deviceId
|
|
348
|
+
},
|
|
349
|
+
body: JSON.stringify(payload)
|
|
350
|
+
});
|
|
351
|
+
if (!res.ok) {
|
|
352
|
+
throw new Error(`Heartbeat failed: ${res.status} ${res.statusText}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// src/spawn.ts
|
|
357
|
+
var TMUX_SESSION_PREFIX = "aidash";
|
|
358
|
+
function delay(ms) {
|
|
359
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
360
|
+
}
|
|
361
|
+
async function spawnAgentSession(config, params) {
|
|
362
|
+
const sessionId = `sess_${uuidv4().slice(0, 8)}`;
|
|
363
|
+
const tmuxSessionName = `${TMUX_SESSION_PREFIX}-${sessionId}`;
|
|
364
|
+
const adapter = getAdapter(params.agentType);
|
|
365
|
+
const spawnCmd = adapter.buildSpawnCommand(params.task, params.projectPath);
|
|
366
|
+
createSession(tmuxSessionName, params.projectPath);
|
|
367
|
+
tmuxExec(
|
|
368
|
+
`tmux send-keys -t '${sanitizeShellArg(tmuxSessionName)}' '${sanitizeShellArg(spawnCmd)}' Enter`
|
|
369
|
+
);
|
|
370
|
+
if (params.task) {
|
|
371
|
+
await delay(3e3);
|
|
372
|
+
tmuxExec(
|
|
373
|
+
`tmux send-keys -t '${sanitizeShellArg(tmuxSessionName)}' '${sanitizeShellArg(params.task)}' Enter`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
await sendHeartbeat(config, {
|
|
377
|
+
deviceId: config.deviceId,
|
|
378
|
+
sessionId,
|
|
379
|
+
agentType: params.agentType,
|
|
380
|
+
project: params.projectName,
|
|
381
|
+
cwd: params.projectPath,
|
|
382
|
+
branch: "",
|
|
383
|
+
status: "working",
|
|
384
|
+
task: params.task,
|
|
385
|
+
tmuxSession: tmuxSessionName,
|
|
386
|
+
tmuxWindow: params.projectName
|
|
387
|
+
});
|
|
388
|
+
console.log(`Spawned session ${sessionId} in tmux:${tmuxSessionName}`);
|
|
389
|
+
return sessionId;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/terminalBridge.ts
|
|
393
|
+
import WebSocket from "ws";
|
|
394
|
+
|
|
395
|
+
// ../shared/dist/constants.js
|
|
396
|
+
var KEEPALIVE_INTERVAL_MS = 1e4;
|
|
397
|
+
var TERMINAL_POLL_INTERVAL_MS = 500;
|
|
398
|
+
|
|
399
|
+
// src/terminalBridge.ts
|
|
400
|
+
var activeBridges = /* @__PURE__ */ new Map();
|
|
401
|
+
function startTerminalBridge(sessionId, tmuxTarget, ws) {
|
|
402
|
+
if (!isTmuxAvailable()) {
|
|
403
|
+
console.log(`Terminal bridge skipped for ${sessionId}: tmux not available`);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
stopTerminalBridge(sessionId);
|
|
407
|
+
let lastOutput = "";
|
|
408
|
+
const interval = setInterval(() => {
|
|
409
|
+
try {
|
|
410
|
+
const raw = capturePaneOutput(tmuxTarget);
|
|
411
|
+
if (raw !== lastOutput) {
|
|
412
|
+
lastOutput = raw;
|
|
413
|
+
const output = raw.replace(/\n/g, "\r\n");
|
|
414
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
415
|
+
ws.send(
|
|
416
|
+
JSON.stringify({
|
|
417
|
+
type: "terminal-output",
|
|
418
|
+
payload: { sessionId, data: output }
|
|
419
|
+
})
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
} catch {
|
|
424
|
+
stopTerminalBridge(sessionId);
|
|
425
|
+
}
|
|
426
|
+
}, TERMINAL_POLL_INTERVAL_MS);
|
|
427
|
+
activeBridges.set(sessionId, interval);
|
|
428
|
+
console.log(`Terminal bridge started for ${sessionId}`);
|
|
429
|
+
}
|
|
430
|
+
function stopTerminalBridge(sessionId) {
|
|
431
|
+
const interval = activeBridges.get(sessionId);
|
|
432
|
+
if (interval) {
|
|
433
|
+
clearInterval(interval);
|
|
434
|
+
activeBridges.delete(sessionId);
|
|
435
|
+
console.log(`Terminal bridge stopped for ${sessionId}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/wsClient.ts
|
|
440
|
+
var reconnectAttempts = 0;
|
|
441
|
+
function connectToBackend(config) {
|
|
442
|
+
const wsUrl = config.backendUrl.replace(/^http/, "ws") + `/ws/agent/${config.deviceId}?apiKey=${encodeURIComponent(config.apiKey)}`;
|
|
443
|
+
console.log(
|
|
444
|
+
`Connecting to backend WS: ${config.backendUrl}/ws/agent/${config.deviceId}`
|
|
445
|
+
);
|
|
446
|
+
const ws = new WebSocket2(wsUrl);
|
|
447
|
+
ws.on("open", () => {
|
|
448
|
+
reconnectAttempts = 0;
|
|
449
|
+
console.log("Connected to backend WebSocket");
|
|
450
|
+
});
|
|
451
|
+
ws.on("message", async (data) => {
|
|
452
|
+
try {
|
|
453
|
+
const msg = JSON.parse(data.toString());
|
|
454
|
+
switch (msg.type) {
|
|
455
|
+
case "spawn-session": {
|
|
456
|
+
const { projectPath, projectName, agentType, task } = msg.payload;
|
|
457
|
+
const sessionId = await spawnAgentSession(config, {
|
|
458
|
+
projectPath,
|
|
459
|
+
projectName,
|
|
460
|
+
agentType,
|
|
461
|
+
task
|
|
462
|
+
});
|
|
463
|
+
ws.send(
|
|
464
|
+
JSON.stringify({
|
|
465
|
+
type: "session-started",
|
|
466
|
+
payload: { sessionId }
|
|
467
|
+
})
|
|
468
|
+
);
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
case "kill-session": {
|
|
472
|
+
const { sessionId, tmuxSession } = msg.payload;
|
|
473
|
+
stopTerminalBridge(sessionId);
|
|
474
|
+
if (tmuxSession && tmuxSessionExists(tmuxSession)) {
|
|
475
|
+
tmuxExec(`tmux kill-session -t '${sanitizeShellArg(tmuxSession)}'`);
|
|
476
|
+
console.log(`Killed tmux session: ${tmuxSession}`);
|
|
477
|
+
}
|
|
478
|
+
ws.send(
|
|
479
|
+
JSON.stringify({
|
|
480
|
+
type: "session-ended",
|
|
481
|
+
payload: { sessionId }
|
|
482
|
+
})
|
|
483
|
+
);
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
case "attach-terminal": {
|
|
487
|
+
const { sessionId, tmuxSession } = msg.payload;
|
|
488
|
+
startTerminalBridge(sessionId, tmuxSession, ws);
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
case "detach-terminal": {
|
|
492
|
+
stopTerminalBridge(msg.payload.sessionId);
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case "terminal-input": {
|
|
496
|
+
if (isTmuxAvailable()) {
|
|
497
|
+
sendKeys(msg.payload.tmuxSession, msg.payload.data);
|
|
498
|
+
}
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
} catch (e) {
|
|
503
|
+
console.error("Error handling WS message:", e);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
ws.on("close", () => {
|
|
507
|
+
const delaySec = Math.min(2 ** Math.min(reconnectAttempts, 5), 32);
|
|
508
|
+
reconnectAttempts++;
|
|
509
|
+
console.log(`Disconnected. Reconnecting in ${delaySec}s...`);
|
|
510
|
+
setTimeout(() => connectToBackend(config), delaySec * 1e3);
|
|
511
|
+
});
|
|
512
|
+
ws.on("error", (e) => console.error("WS error:", e.message));
|
|
513
|
+
return ws;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// src/sessionScanner.ts
|
|
517
|
+
import { execSync as execSync4 } from "child_process";
|
|
518
|
+
import {
|
|
519
|
+
statSync as statSync2,
|
|
520
|
+
readdirSync as readdirSync2,
|
|
521
|
+
existsSync as existsSync4,
|
|
522
|
+
openSync,
|
|
523
|
+
readSync,
|
|
524
|
+
closeSync
|
|
525
|
+
} from "fs";
|
|
526
|
+
import { join as join2, basename as basename2 } from "path";
|
|
527
|
+
import { homedir as homedir2 } from "os";
|
|
528
|
+
|
|
529
|
+
// src/enrichers/git.ts
|
|
530
|
+
import { execSync as execSync3 } from "child_process";
|
|
531
|
+
import { existsSync as existsSync3 } from "fs";
|
|
532
|
+
function getGitInfo(cwd) {
|
|
533
|
+
if (!existsSync3(`${cwd}/.git`)) return null;
|
|
534
|
+
try {
|
|
535
|
+
const branch = execSync3("git rev-parse --abbrev-ref HEAD", { cwd, encoding: "utf-8", timeout: 3e3 }).trim();
|
|
536
|
+
const repoName = execSync3("git rev-parse --show-toplevel", { cwd, encoding: "utf-8", timeout: 3e3 }).trim().split("/").pop() || "";
|
|
537
|
+
let remoteUrl = "";
|
|
538
|
+
try {
|
|
539
|
+
remoteUrl = execSync3("git remote get-url origin", { cwd, encoding: "utf-8", timeout: 3e3 }).trim();
|
|
540
|
+
} catch {
|
|
541
|
+
}
|
|
542
|
+
return { repoName, branch, remoteUrl };
|
|
543
|
+
} catch {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/sessionScanner.ts
|
|
549
|
+
function buildTmuxPidMap() {
|
|
550
|
+
const map = /* @__PURE__ */ new Map();
|
|
551
|
+
try {
|
|
552
|
+
const output = execSync4(
|
|
553
|
+
"tmux list-panes -a -F '#{pane_pid} #{session_name}' 2>/dev/null",
|
|
554
|
+
{ encoding: "utf-8", timeout: 3e3, stdio: "pipe" }
|
|
555
|
+
).trim();
|
|
556
|
+
for (const line of output.split("\n")) {
|
|
557
|
+
if (!line) continue;
|
|
558
|
+
const spaceIdx = line.indexOf(" ");
|
|
559
|
+
if (spaceIdx === -1) continue;
|
|
560
|
+
const pid = parseInt(line.substring(0, spaceIdx), 10);
|
|
561
|
+
const sessionName = line.substring(spaceIdx + 1);
|
|
562
|
+
if (!isNaN(pid) && sessionName) {
|
|
563
|
+
map.set(pid, sessionName);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} catch {
|
|
567
|
+
}
|
|
568
|
+
return map;
|
|
569
|
+
}
|
|
570
|
+
function findTmuxSessionForPid(pid, tmuxPidMap) {
|
|
571
|
+
if (tmuxPidMap.size === 0) return "";
|
|
572
|
+
let currentPid = pid;
|
|
573
|
+
const maxDepth = 10;
|
|
574
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
575
|
+
const session = tmuxPidMap.get(currentPid);
|
|
576
|
+
if (session) return session;
|
|
577
|
+
try {
|
|
578
|
+
const ppid = parseInt(
|
|
579
|
+
execSync4(`ps -p ${currentPid} -o ppid=`, {
|
|
580
|
+
encoding: "utf-8",
|
|
581
|
+
timeout: 2e3,
|
|
582
|
+
stdio: "pipe"
|
|
583
|
+
}).trim(),
|
|
584
|
+
10
|
|
585
|
+
);
|
|
586
|
+
if (isNaN(ppid) || ppid <= 1 || ppid === currentPid) break;
|
|
587
|
+
currentPid = ppid;
|
|
588
|
+
} catch {
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return "";
|
|
593
|
+
}
|
|
594
|
+
function pathToProjectDir(p) {
|
|
595
|
+
return p.replace(/\//g, "-");
|
|
596
|
+
}
|
|
597
|
+
function getProcessCwd(pid) {
|
|
598
|
+
try {
|
|
599
|
+
const output = execSync4(
|
|
600
|
+
`lsof -a -p ${pid} -d cwd -Fn 2>/dev/null | grep '^n'`,
|
|
601
|
+
{ encoding: "utf-8", timeout: 3e3, stdio: "pipe" }
|
|
602
|
+
).trim();
|
|
603
|
+
const match = output.match(/^n(.+)$/m);
|
|
604
|
+
return match ? match[1] : null;
|
|
605
|
+
} catch {
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
function findAgentProcesses() {
|
|
610
|
+
const processes = [];
|
|
611
|
+
try {
|
|
612
|
+
const psOutput = execSync4("ps aux", {
|
|
613
|
+
encoding: "utf-8",
|
|
614
|
+
timeout: 5e3,
|
|
615
|
+
stdio: "pipe"
|
|
616
|
+
});
|
|
617
|
+
for (const line of psOutput.split("\n")) {
|
|
618
|
+
const parts = line.trim().split(/\s+/);
|
|
619
|
+
const pid = parseInt(parts[1], 10);
|
|
620
|
+
if (isNaN(pid) || pid === process.pid) continue;
|
|
621
|
+
if ((line.includes("/claude") || line.match(/\sclaude(\s|$)/)) && !line.includes("grep") && !line.includes("codex")) {
|
|
622
|
+
let sessionId = null;
|
|
623
|
+
const resumeMatch = line.match(/--resume\s+([a-f0-9-]{36})/);
|
|
624
|
+
const sessionMatch = line.match(/--session-id\s+([a-f0-9-]{36})/);
|
|
625
|
+
if (resumeMatch) sessionId = resumeMatch[1];
|
|
626
|
+
else if (sessionMatch) sessionId = sessionMatch[1];
|
|
627
|
+
let model = "default";
|
|
628
|
+
const modelMatch = line.match(/--model\s+(\S+)/);
|
|
629
|
+
if (modelMatch) model = modelMatch[1];
|
|
630
|
+
const cwd = getProcessCwd(pid);
|
|
631
|
+
processes.push({
|
|
632
|
+
pid,
|
|
633
|
+
agentType: "claude-code",
|
|
634
|
+
sessionId,
|
|
635
|
+
model,
|
|
636
|
+
cwd
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
if (line.match(/\scodex\s*$/) || // bare "codex" at end of line (terminal)
|
|
640
|
+
line.match(/\scodex\s+(?!app-server)/)) {
|
|
641
|
+
const cwd = getProcessCwd(pid);
|
|
642
|
+
const sessionId = `codex-${pid}`;
|
|
643
|
+
processes.push({
|
|
644
|
+
pid,
|
|
645
|
+
agentType: "codex",
|
|
646
|
+
sessionId,
|
|
647
|
+
model: "codex",
|
|
648
|
+
cwd
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
} catch {
|
|
653
|
+
}
|
|
654
|
+
return processes;
|
|
655
|
+
}
|
|
656
|
+
function findLatestSessionFile(projPath) {
|
|
657
|
+
try {
|
|
658
|
+
const files = readdirSync2(projPath).filter((f) => f.endsWith(".jsonl"));
|
|
659
|
+
let latest = null;
|
|
660
|
+
for (const file of files) {
|
|
661
|
+
const filePath = join2(projPath, file);
|
|
662
|
+
try {
|
|
663
|
+
const mtime = statSync2(filePath).mtimeMs;
|
|
664
|
+
if (!latest || mtime > latest.mtimeMs) {
|
|
665
|
+
latest = { sessionId: basename2(file, ".jsonl"), mtimeMs: mtime };
|
|
666
|
+
}
|
|
667
|
+
} catch {
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return latest;
|
|
672
|
+
} catch {
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
function isAwaitingPermissionFromJsonl(projPath, sessionId) {
|
|
677
|
+
try {
|
|
678
|
+
const filePath = join2(projPath, `${sessionId}.jsonl`);
|
|
679
|
+
const size = statSync2(filePath).size;
|
|
680
|
+
if (size === 0) return false;
|
|
681
|
+
const TAIL_BYTES = 4096;
|
|
682
|
+
const start = Math.max(0, size - TAIL_BYTES);
|
|
683
|
+
const buf = Buffer.alloc(Math.min(TAIL_BYTES, size));
|
|
684
|
+
const fd = openSync(filePath, "r");
|
|
685
|
+
try {
|
|
686
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
687
|
+
} finally {
|
|
688
|
+
closeSync(fd);
|
|
689
|
+
}
|
|
690
|
+
const tail = buf.toString("utf-8");
|
|
691
|
+
const lines = tail.trimEnd().split("\n");
|
|
692
|
+
const lastLine = lines[lines.length - 1];
|
|
693
|
+
if (!lastLine) return false;
|
|
694
|
+
const record = JSON.parse(lastLine);
|
|
695
|
+
return record.type === "assistant" && record.message?.stop_reason === "tool_use";
|
|
696
|
+
} catch {
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
var AWAITING_INPUT_PATTERNS = [
|
|
701
|
+
// Claude Code: tool use approval
|
|
702
|
+
/Do you want to proceed\?/,
|
|
703
|
+
/Allow once/,
|
|
704
|
+
/Allow always/,
|
|
705
|
+
// Codex: command approval
|
|
706
|
+
/Would you like to run the following command\?/,
|
|
707
|
+
/Yes, proceed/,
|
|
708
|
+
/Press enter to confirm or esc to cancel/,
|
|
709
|
+
/don't ask again for/,
|
|
710
|
+
// Generic: common approval prompts
|
|
711
|
+
/\(y\/n\)\s*$/,
|
|
712
|
+
/\[Y\/n\]\s*$/,
|
|
713
|
+
/\[yes\/no\]\s*$/
|
|
714
|
+
];
|
|
715
|
+
function isAwaitingInputFromTmux(tmuxSession) {
|
|
716
|
+
if (!tmuxSession) return false;
|
|
717
|
+
try {
|
|
718
|
+
const output = execSync4(
|
|
719
|
+
`tmux capture-pane -t '${tmuxSession.replace(/'/g, "'\\''")}' -p -S -20`,
|
|
720
|
+
{
|
|
721
|
+
encoding: "utf-8",
|
|
722
|
+
timeout: 3e3,
|
|
723
|
+
stdio: "pipe"
|
|
724
|
+
}
|
|
725
|
+
);
|
|
726
|
+
return AWAITING_INPUT_PATTERNS.some((pattern) => pattern.test(output));
|
|
727
|
+
} catch {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function discoverClaudeSessions(processes, tmuxPidMap) {
|
|
732
|
+
const sessions = [];
|
|
733
|
+
const claudeProjectsDir = join2(homedir2(), ".claude", "projects");
|
|
734
|
+
if (!existsSync4(claudeProjectsDir)) return sessions;
|
|
735
|
+
const now = Date.now();
|
|
736
|
+
const WORKING_THRESHOLD_MS = 5 * 1e3;
|
|
737
|
+
const seen = /* @__PURE__ */ new Map();
|
|
738
|
+
const seenCwd = /* @__PURE__ */ new Map();
|
|
739
|
+
for (const proc of processes) {
|
|
740
|
+
if (proc.agentType !== "claude-code") continue;
|
|
741
|
+
const cwd = proc.cwd;
|
|
742
|
+
if (!cwd) continue;
|
|
743
|
+
const projDirName = pathToProjectDir(cwd);
|
|
744
|
+
const projPath = join2(claudeProjectsDir, projDirName);
|
|
745
|
+
if (!existsSync4(projPath)) continue;
|
|
746
|
+
let sessionId = proc.sessionId;
|
|
747
|
+
let mtimeMs = null;
|
|
748
|
+
if (sessionId) {
|
|
749
|
+
const filePath = join2(projPath, `${sessionId}.jsonl`);
|
|
750
|
+
try {
|
|
751
|
+
mtimeMs = statSync2(filePath).mtimeMs;
|
|
752
|
+
} catch {
|
|
753
|
+
sessionId = null;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (!sessionId) {
|
|
757
|
+
const latest = findLatestSessionFile(projPath);
|
|
758
|
+
if (!latest) continue;
|
|
759
|
+
sessionId = latest.sessionId;
|
|
760
|
+
mtimeMs = latest.mtimeMs;
|
|
761
|
+
}
|
|
762
|
+
if (!mtimeMs) continue;
|
|
763
|
+
const tmuxSession = findTmuxSessionForPid(proc.pid, tmuxPidMap);
|
|
764
|
+
const isSpawned = /^aidash-(sess_\w+)$/.test(tmuxSession);
|
|
765
|
+
const spawnMatch = tmuxSession.match(/^aidash-(sess_\w+)$/);
|
|
766
|
+
if (spawnMatch) {
|
|
767
|
+
sessionId = spawnMatch[1];
|
|
768
|
+
}
|
|
769
|
+
if (seen.has(sessionId)) {
|
|
770
|
+
if (tmuxSession) {
|
|
771
|
+
const idx = seen.get(sessionId);
|
|
772
|
+
sessions[idx].tmuxSession = tmuxSession;
|
|
773
|
+
}
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
if (seenCwd.has(cwd)) {
|
|
777
|
+
const existingIdx = seenCwd.get(cwd);
|
|
778
|
+
const existing = sessions[existingIdx];
|
|
779
|
+
if (isSpawned && !existing.tmuxSession.startsWith("aidash-")) {
|
|
780
|
+
sessions[existingIdx] = void 0;
|
|
781
|
+
seen.delete(existing.sessionId);
|
|
782
|
+
} else {
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
seen.set(sessionId, sessions.length);
|
|
787
|
+
seenCwd.set(cwd, sessions.length);
|
|
788
|
+
const ageMs = now - mtimeMs;
|
|
789
|
+
const project = basename2(cwd);
|
|
790
|
+
let branch = "";
|
|
791
|
+
const gitInfo = getGitInfo(cwd);
|
|
792
|
+
if (gitInfo) branch = gitInfo.branch;
|
|
793
|
+
const awaiting = isAwaitingPermissionFromJsonl(projPath, sessionId) || isAwaitingInputFromTmux(tmuxSession);
|
|
794
|
+
sessions.push({
|
|
795
|
+
sessionId,
|
|
796
|
+
project,
|
|
797
|
+
cwd,
|
|
798
|
+
branch,
|
|
799
|
+
model: proc.model,
|
|
800
|
+
status: awaiting ? "awaiting_permission" : ageMs < WORKING_THRESHOLD_MS ? "working" : "idle",
|
|
801
|
+
agentType: "claude-code",
|
|
802
|
+
pid: proc.pid,
|
|
803
|
+
tmuxSession
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
return sessions.filter(Boolean);
|
|
807
|
+
}
|
|
808
|
+
function getCodexSessionsFromDb() {
|
|
809
|
+
const map = /* @__PURE__ */ new Map();
|
|
810
|
+
const dbPath = join2(homedir2(), ".codex", "state_5.sqlite");
|
|
811
|
+
if (!existsSync4(dbPath)) return map;
|
|
812
|
+
try {
|
|
813
|
+
const output = execSync4(
|
|
814
|
+
`sqlite3 "${dbPath}" "SELECT id, cwd, title, model_provider, updated_at FROM threads ORDER BY updated_at DESC LIMIT 50;"`,
|
|
815
|
+
{ encoding: "utf-8", timeout: 3e3, stdio: "pipe" }
|
|
816
|
+
).trim();
|
|
817
|
+
if (!output) return map;
|
|
818
|
+
for (const line of output.split("\n")) {
|
|
819
|
+
const [id, cwd, title, model, updatedAtStr] = line.split("|");
|
|
820
|
+
if (!cwd || map.has(cwd)) continue;
|
|
821
|
+
map.set(cwd, {
|
|
822
|
+
id,
|
|
823
|
+
cwd,
|
|
824
|
+
title,
|
|
825
|
+
model: model || "codex",
|
|
826
|
+
updatedAt: parseInt(updatedAtStr, 10) || 0
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
} catch {
|
|
830
|
+
}
|
|
831
|
+
return map;
|
|
832
|
+
}
|
|
833
|
+
function discoverCodexSessions(processes, tmuxPidMap) {
|
|
834
|
+
const sessions = [];
|
|
835
|
+
const seen = /* @__PURE__ */ new Set();
|
|
836
|
+
const codexProcesses = processes.filter(
|
|
837
|
+
(p) => p.agentType === "codex" && p.cwd && p.cwd !== "/"
|
|
838
|
+
);
|
|
839
|
+
if (codexProcesses.length === 0) return sessions;
|
|
840
|
+
const codexSessions = getCodexSessionsFromDb();
|
|
841
|
+
const nowSecs = Math.floor(Date.now() / 1e3);
|
|
842
|
+
const WORKING_THRESHOLD_SECS = 5;
|
|
843
|
+
for (const proc of codexProcesses) {
|
|
844
|
+
const cwd = proc.cwd;
|
|
845
|
+
const dbSession = codexSessions.get(cwd);
|
|
846
|
+
const sessionId = dbSession ? dbSession.id : `codex-${proc.pid}`;
|
|
847
|
+
if (seen.has(sessionId)) continue;
|
|
848
|
+
seen.add(sessionId);
|
|
849
|
+
const project = basename2(cwd);
|
|
850
|
+
let branch = "";
|
|
851
|
+
const gitInfo = getGitInfo(cwd);
|
|
852
|
+
if (gitInfo) branch = gitInfo.branch;
|
|
853
|
+
const ageSecs = dbSession ? nowSecs - dbSession.updatedAt : Infinity;
|
|
854
|
+
const model = dbSession ? dbSession.model : "codex";
|
|
855
|
+
const tmuxSession = findTmuxSessionForPid(proc.pid, tmuxPidMap);
|
|
856
|
+
const spawnMatch = tmuxSession.match(/^aidash-(sess_\w+)$/);
|
|
857
|
+
if (spawnMatch) {
|
|
858
|
+
const spawnId = spawnMatch[1];
|
|
859
|
+
if (!seen.has(spawnId)) {
|
|
860
|
+
seen.add(spawnId);
|
|
861
|
+
sessions.push({
|
|
862
|
+
sessionId: spawnId,
|
|
863
|
+
project,
|
|
864
|
+
cwd,
|
|
865
|
+
branch,
|
|
866
|
+
model,
|
|
867
|
+
status: isAwaitingInputFromTmux(tmuxSession) ? "awaiting_permission" : ageSecs < WORKING_THRESHOLD_SECS ? "working" : "idle",
|
|
868
|
+
agentType: "codex",
|
|
869
|
+
pid: proc.pid,
|
|
870
|
+
tmuxSession
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
const awaiting = isAwaitingInputFromTmux(tmuxSession);
|
|
876
|
+
sessions.push({
|
|
877
|
+
sessionId,
|
|
878
|
+
project,
|
|
879
|
+
cwd,
|
|
880
|
+
branch,
|
|
881
|
+
model,
|
|
882
|
+
status: awaiting ? "awaiting_permission" : ageSecs < WORKING_THRESHOLD_SECS ? "working" : "idle",
|
|
883
|
+
agentType: "codex",
|
|
884
|
+
pid: proc.pid,
|
|
885
|
+
tmuxSession
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
return sessions;
|
|
889
|
+
}
|
|
890
|
+
function discoverAllSessions() {
|
|
891
|
+
const processes = findAgentProcesses();
|
|
892
|
+
const tmuxPidMap = buildTmuxPidMap();
|
|
893
|
+
return [
|
|
894
|
+
...discoverClaudeSessions(processes, tmuxPidMap),
|
|
895
|
+
...discoverCodexSessions(processes, tmuxPidMap)
|
|
896
|
+
];
|
|
897
|
+
}
|
|
898
|
+
async function scanAndReportSessions(config) {
|
|
899
|
+
const sessions = discoverAllSessions();
|
|
900
|
+
for (const session of sessions) {
|
|
901
|
+
try {
|
|
902
|
+
await fetch(`${config.backendUrl}/api/session-heartbeat`, {
|
|
903
|
+
method: "POST",
|
|
904
|
+
headers: {
|
|
905
|
+
"Content-Type": "application/json",
|
|
906
|
+
"x-api-key": config.apiKey,
|
|
907
|
+
"x-device-id": config.deviceId
|
|
908
|
+
},
|
|
909
|
+
body: JSON.stringify({
|
|
910
|
+
deviceId: config.deviceId,
|
|
911
|
+
sessionId: session.sessionId,
|
|
912
|
+
agentType: session.agentType,
|
|
913
|
+
project: session.project,
|
|
914
|
+
cwd: session.cwd,
|
|
915
|
+
branch: session.branch,
|
|
916
|
+
status: session.status,
|
|
917
|
+
model: session.model,
|
|
918
|
+
task: "",
|
|
919
|
+
tmuxSession: session.tmuxSession,
|
|
920
|
+
tmuxWindow: session.project
|
|
921
|
+
})
|
|
922
|
+
});
|
|
923
|
+
} catch {
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
if (sessions.length > 0) {
|
|
927
|
+
console.log(
|
|
928
|
+
`Session scanner: reported ${sessions.length} active session(s)`
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
var SCAN_INTERVAL_MS = 5e3;
|
|
933
|
+
function startSessionScanner(config) {
|
|
934
|
+
scanAndReportSessions(config).catch(() => {
|
|
935
|
+
});
|
|
936
|
+
return setInterval(() => {
|
|
937
|
+
scanAndReportSessions(config).catch(() => {
|
|
938
|
+
});
|
|
939
|
+
}, SCAN_INTERVAL_MS);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// src/agent.ts
|
|
943
|
+
async function waitForBackend(config, maxRetries = 30) {
|
|
944
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
945
|
+
try {
|
|
946
|
+
await registerDevice(config);
|
|
947
|
+
return;
|
|
948
|
+
} catch {
|
|
949
|
+
const delaySec = Math.min(2 ** Math.min(i, 4), 16);
|
|
950
|
+
console.log(
|
|
951
|
+
`Backend not ready, retrying in ${delaySec}s... (${i + 1}/${maxRetries})`
|
|
952
|
+
);
|
|
953
|
+
await new Promise((r) => setTimeout(r, delaySec * 1e3));
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
throw new Error("Backend not reachable after retries");
|
|
957
|
+
}
|
|
958
|
+
async function startAgent(config) {
|
|
959
|
+
console.log(`Starting agent for device: ${config.deviceId}`);
|
|
960
|
+
console.log(`Backend: ${config.backendUrl}`);
|
|
961
|
+
await waitForBackend(config);
|
|
962
|
+
await syncProjects(config);
|
|
963
|
+
connectToBackend(config);
|
|
964
|
+
startSessionScanner(config);
|
|
965
|
+
setInterval(async () => {
|
|
966
|
+
try {
|
|
967
|
+
await registerDevice(config);
|
|
968
|
+
} catch (e) {
|
|
969
|
+
console.error("Keepalive failed:", e);
|
|
970
|
+
}
|
|
971
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
972
|
+
console.log("Agent running. Press Ctrl+C to stop.");
|
|
973
|
+
}
|
|
974
|
+
var isDirectRun = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/"));
|
|
975
|
+
if (isDirectRun) {
|
|
976
|
+
const configPath = process.argv[2];
|
|
977
|
+
const config = loadConfig(configPath);
|
|
978
|
+
startAgent(config).catch((e) => {
|
|
979
|
+
console.error("Agent failed:", e);
|
|
980
|
+
process.exit(1);
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
export {
|
|
985
|
+
resolveConfigPath,
|
|
986
|
+
parseConfigFile,
|
|
987
|
+
mergeCliOverrides,
|
|
988
|
+
startAgent
|
|
989
|
+
};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mergeCliOverrides,
|
|
3
|
+
parseConfigFile,
|
|
4
|
+
resolveConfigPath,
|
|
5
|
+
startAgent
|
|
6
|
+
} from "./chunk-24ORBJSI.js";
|
|
7
|
+
|
|
8
|
+
// src/init.ts
|
|
9
|
+
import { createInterface } from "readline/promises";
|
|
10
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
11
|
+
import { resolve } from "path";
|
|
12
|
+
import { hostname, platform, homedir } from "os";
|
|
13
|
+
function buildConfigFromAnswers(answers) {
|
|
14
|
+
return {
|
|
15
|
+
deviceId: hostname(),
|
|
16
|
+
deviceName: answers.deviceName || `${hostname()}-${platform()}`,
|
|
17
|
+
backendUrl: answers.backendUrl,
|
|
18
|
+
apiKey: answers.apiKey,
|
|
19
|
+
projects: [],
|
|
20
|
+
projectDirs: answers.projectDirs ? answers.projectDirs.split(",").map((d) => d.trim()).filter(Boolean) : []
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
async function validateBackend(url) {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(`${url}/health`);
|
|
26
|
+
return res.ok;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function runInitWizard() {
|
|
32
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
33
|
+
console.log("\nNo config found. Let's set up your agent.\n");
|
|
34
|
+
try {
|
|
35
|
+
const backendUrl = await rl.question("Backend URL: ");
|
|
36
|
+
if (!backendUrl) {
|
|
37
|
+
throw new Error("Backend URL is required");
|
|
38
|
+
}
|
|
39
|
+
process.stdout.write("Checking backend... ");
|
|
40
|
+
const reachable = await validateBackend(backendUrl);
|
|
41
|
+
if (!reachable) {
|
|
42
|
+
console.log("unreachable (continuing anyway)");
|
|
43
|
+
} else {
|
|
44
|
+
console.log("ok");
|
|
45
|
+
}
|
|
46
|
+
const apiKey = await rl.question("API Key: ");
|
|
47
|
+
if (!apiKey) {
|
|
48
|
+
throw new Error("API Key is required");
|
|
49
|
+
}
|
|
50
|
+
const defaultName = `${hostname()}-${platform()}`;
|
|
51
|
+
const deviceName = await rl.question(`Device name (${defaultName}): `);
|
|
52
|
+
const projectDirs = await rl.question(
|
|
53
|
+
"Project directories (comma-separated, optional): "
|
|
54
|
+
);
|
|
55
|
+
const config = buildConfigFromAnswers({
|
|
56
|
+
backendUrl,
|
|
57
|
+
apiKey,
|
|
58
|
+
deviceName,
|
|
59
|
+
projectDirs
|
|
60
|
+
});
|
|
61
|
+
const configDir = resolve(homedir(), ".agtd");
|
|
62
|
+
const configPath = resolve(configDir, "agent.config.json");
|
|
63
|
+
mkdirSync(configDir, { recursive: true });
|
|
64
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
65
|
+
console.log(`
|
|
66
|
+
Config saved to ${configPath}
|
|
67
|
+
`);
|
|
68
|
+
return config;
|
|
69
|
+
} finally {
|
|
70
|
+
rl.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/cli.ts
|
|
75
|
+
function parseArgs(argv) {
|
|
76
|
+
const args = { init: false, help: false };
|
|
77
|
+
for (let i = 2; i < argv.length; i++) {
|
|
78
|
+
const arg = argv[i];
|
|
79
|
+
const next = argv[i + 1];
|
|
80
|
+
switch (arg) {
|
|
81
|
+
case "--init":
|
|
82
|
+
args.init = true;
|
|
83
|
+
break;
|
|
84
|
+
case "--backend":
|
|
85
|
+
args.backend = next;
|
|
86
|
+
i++;
|
|
87
|
+
break;
|
|
88
|
+
case "--api-key":
|
|
89
|
+
args.apiKey = next;
|
|
90
|
+
i++;
|
|
91
|
+
break;
|
|
92
|
+
case "--device-name":
|
|
93
|
+
args.deviceName = next;
|
|
94
|
+
i++;
|
|
95
|
+
break;
|
|
96
|
+
case "--config":
|
|
97
|
+
args.configPath = next;
|
|
98
|
+
i++;
|
|
99
|
+
break;
|
|
100
|
+
case "--help":
|
|
101
|
+
case "-h":
|
|
102
|
+
args.help = true;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return args;
|
|
107
|
+
}
|
|
108
|
+
function printHelp() {
|
|
109
|
+
console.log(`
|
|
110
|
+
Usage: agtd-agent [options]
|
|
111
|
+
|
|
112
|
+
Options:
|
|
113
|
+
--init Run interactive setup wizard
|
|
114
|
+
--backend <url> Backend URL (overrides config)
|
|
115
|
+
--api-key <key> API key (overrides config)
|
|
116
|
+
--device-name <n> Device name (overrides config)
|
|
117
|
+
--config <path> Path to config file
|
|
118
|
+
-h, --help Show this help message
|
|
119
|
+
|
|
120
|
+
Config is loaded from (in order):
|
|
121
|
+
1. --config flag
|
|
122
|
+
2. AGENT_CONFIG env var
|
|
123
|
+
3. ~/.agtd/agent.config.json
|
|
124
|
+
4. ./agent.config.json
|
|
125
|
+
5. Interactive setup (if none found)
|
|
126
|
+
`);
|
|
127
|
+
}
|
|
128
|
+
async function main() {
|
|
129
|
+
const args = parseArgs(process.argv);
|
|
130
|
+
if (args.help) {
|
|
131
|
+
printHelp();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (args.init) {
|
|
135
|
+
const config = await runInitWizard();
|
|
136
|
+
await startAgent(config);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const configPath = resolveConfigPath(args.configPath);
|
|
140
|
+
if (configPath) {
|
|
141
|
+
const overrides = {
|
|
142
|
+
backend: args.backend,
|
|
143
|
+
apiKey: args.apiKey,
|
|
144
|
+
deviceName: args.deviceName
|
|
145
|
+
};
|
|
146
|
+
const config = mergeCliOverrides(parseConfigFile(configPath), overrides);
|
|
147
|
+
await startAgent(config);
|
|
148
|
+
} else if (args.backend && args.apiKey) {
|
|
149
|
+
const config = buildConfigFromAnswers({
|
|
150
|
+
backendUrl: args.backend,
|
|
151
|
+
apiKey: args.apiKey,
|
|
152
|
+
deviceName: args.deviceName ?? "",
|
|
153
|
+
projectDirs: ""
|
|
154
|
+
});
|
|
155
|
+
await startAgent(config);
|
|
156
|
+
} else {
|
|
157
|
+
const config = await runInitWizard();
|
|
158
|
+
await startAgent(config);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
main().catch((e) => {
|
|
162
|
+
console.error("Agent failed:", e);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
});
|
|
165
|
+
export {
|
|
166
|
+
parseArgs
|
|
167
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agtd/agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"agtd-agent": "./bin/agtd-agent.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "dist/agent.js",
|
|
9
|
+
"files": ["bin", "dist", "README.md"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "tsx watch src/agent.ts",
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"build:publish": "tsup",
|
|
14
|
+
"start": "node dist/agent.js",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"test:watch": "vitest"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"uuid": "^10.0.0",
|
|
20
|
+
"ws": "^8.18.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@agtd/shared": "workspace:*",
|
|
24
|
+
"@types/uuid": "^10.0.0",
|
|
25
|
+
"@types/ws": "^8.5.0",
|
|
26
|
+
"tsup": "^8.5.1",
|
|
27
|
+
"tsx": "^4.0.0",
|
|
28
|
+
"typescript": "^5.7.0",
|
|
29
|
+
"vitest": "^2.0.0"
|
|
30
|
+
}
|
|
31
|
+
}
|