@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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/cli.js";
package/dist/agent.js ADDED
@@ -0,0 +1,6 @@
1
+ import {
2
+ startAgent
3
+ } from "./chunk-24ORBJSI.js";
4
+ export {
5
+ startAgent
6
+ };
@@ -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
+ }