ax-agents 0.0.1-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +28 -0
  3. package/ax.js +3684 -0
  4. package/package.json +46 -0
package/ax.js ADDED
@@ -0,0 +1,3684 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ax.js - CLI for interacting with AI agents (Codex, Claude) via tmux
4
+ //
5
+ // Exit codes:
6
+ // 0 - success / ready
7
+ // 1 - error
8
+ // 2 - rate limited
9
+ // 3 - awaiting confirmation
10
+ // 4 - thinking
11
+ //
12
+ // Usage: ./ax.js --help
13
+ // ./axcodex.js --help (symlink)
14
+ // ./axclaude.js --help (symlink)
15
+
16
+ import { execSync, spawnSync, spawn } from "node:child_process";
17
+ import { fstatSync, statSync, readFileSync, readdirSync, existsSync, appendFileSync, mkdirSync, writeFileSync, renameSync, realpathSync, watch } from "node:fs";
18
+ import { randomUUID } from "node:crypto";
19
+ import { fileURLToPath } from "node:url";
20
+ import path from "node:path";
21
+ import os from "node:os";
22
+
23
+ /**
24
+ * @typedef {'claude' | 'codex'} ToolName
25
+ */
26
+
27
+ /**
28
+ * @typedef {Object} ParsedSession
29
+ * @property {string} tool
30
+ * @property {string} [daemonName]
31
+ * @property {string} [uuid]
32
+ */
33
+
34
+ /**
35
+ * @typedef {Object} DaemonConfig
36
+ * @property {string} name
37
+ * @property {ToolName} tool
38
+ * @property {string[]} watch
39
+ * @property {number} interval
40
+ * @property {string} prompt
41
+ * @property {string} [path]
42
+ */
43
+
44
+ /**
45
+ * @typedef {Object} MailboxEntry
46
+ * @property {string} timestamp
47
+ * @property {string} type
48
+ * @property {MailboxPayload} payload
49
+ */
50
+
51
+ /**
52
+ * @typedef {Object} MailboxPayload
53
+ * @property {string} agent
54
+ * @property {string} session
55
+ * @property {string} branch
56
+ * @property {string} commit
57
+ * @property {string[]} files
58
+ * @property {string} [summary]
59
+ * @property {string} [message]
60
+ */
61
+
62
+ /**
63
+ * @typedef {Object} AgentInterface
64
+ * @property {string} name
65
+ * @property {string} envVar
66
+ * @property {string} startCommand
67
+ * @property {string} approveKey
68
+ * @property {string} rejectKey
69
+ * @property {Record<string, string> | null} [reviewOptions]
70
+ * @property {string} [safeAllowedTools]
71
+ * @property {() => string} getDefaultSession
72
+ * @property {(screen: string) => string | null} getState
73
+ * @property {(screen: string) => ActionInfo | null} parseAction
74
+ * @property {(screen: string) => ResponseInfo[]} extractResponses
75
+ * @property {(yolo?: boolean) => string} buildStartCommand
76
+ */
77
+
78
+ /**
79
+ * @typedef {Object} ActionInfo
80
+ * @property {string} tool
81
+ * @property {string} action
82
+ * @property {string} [file]
83
+ * @property {string} [command]
84
+ */
85
+
86
+ /**
87
+ * @typedef {Object} ResponseInfo
88
+ * @property {'assistant' | 'user' | 'thinking'} type
89
+ * @property {string} text
90
+ */
91
+
92
+ /**
93
+ * @typedef {Object} FileEditContext
94
+ * @property {string} intent
95
+ * @property {{name: string, input?: any, id?: string}} toolCall
96
+ * @property {number} editSequence
97
+ * @property {string[]} subsequentErrors
98
+ * @property {string[]} readsBefore
99
+ */
100
+
101
+ /**
102
+ * @typedef {Object} ParentSession
103
+ * @property {string | null} session
104
+ * @property {string} uuid
105
+ */
106
+
107
+ /**
108
+ * @typedef {Object} ClaudeSettings
109
+ * @property {{UserPromptSubmit?: Array<{matcher: string, hooks: Array<{type: string, command: string, timeout?: number}>}>}} [hooks]
110
+ */
111
+
112
+ const DEBUG = process.env.AX_DEBUG === "1";
113
+
114
+ /**
115
+ * @param {string} context
116
+ * @param {unknown} err
117
+ */
118
+ function debugError(context, err) {
119
+ if (DEBUG) console.error(`[debug:${context}]`, err instanceof Error ? err.message : err);
120
+ }
121
+
122
+ // =============================================================================
123
+ // Project root detection (walk up to find .ai/ directory)
124
+ // =============================================================================
125
+
126
+ function findProjectRoot(startDir = process.cwd()) {
127
+ let dir = startDir;
128
+ while (dir !== path.dirname(dir)) {
129
+ if (existsSync(path.join(dir, ".ai"))) {
130
+ return dir;
131
+ }
132
+ dir = path.dirname(dir);
133
+ }
134
+ // Fallback to cwd if no .ai/ found (will be created on first use)
135
+ return startDir;
136
+ }
137
+
138
+ const PROJECT_ROOT = findProjectRoot();
139
+ const AI_DIR = path.join(PROJECT_ROOT, ".ai");
140
+ const AGENTS_DIR = path.join(AI_DIR, "agents");
141
+ const HOOKS_DIR = path.join(AI_DIR, "hooks");
142
+
143
+ // =============================================================================
144
+ // Helpers - tmux
145
+ // =============================================================================
146
+
147
+ /**
148
+ * @param {string[]} args
149
+ * @returns {string}
150
+ */
151
+ function tmux(args) {
152
+ const result = spawnSync("tmux", args, { encoding: "utf-8" });
153
+ if (result.status !== 0) throw new Error(result.stderr || "tmux error");
154
+ return result.stdout;
155
+ }
156
+
157
+ /**
158
+ * @param {string} session
159
+ * @returns {boolean}
160
+ */
161
+ function tmuxHasSession(session) {
162
+ try {
163
+ tmux(["has-session", "-t", session]);
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * @param {string} session
172
+ * @param {number} [scrollback]
173
+ * @returns {string}
174
+ */
175
+ function tmuxCapture(session, scrollback = 0) {
176
+ try {
177
+ const args = ["capture-pane", "-t", session, "-p"];
178
+ if (scrollback) args.push("-S", String(-scrollback));
179
+ return tmux(args);
180
+ } catch (err) {
181
+ debugError("tmuxCapture", err);
182
+ return "";
183
+ }
184
+ }
185
+
186
+ /**
187
+ * @param {string} session
188
+ * @param {string} keys
189
+ */
190
+ function tmuxSend(session, keys) {
191
+ tmux(["send-keys", "-t", session, keys]);
192
+ }
193
+
194
+ /**
195
+ * @param {string} session
196
+ * @param {string} text
197
+ */
198
+ function tmuxSendLiteral(session, text) {
199
+ tmux(["send-keys", "-t", session, "-l", text]);
200
+ }
201
+
202
+ /**
203
+ * @param {string} session
204
+ */
205
+ function tmuxKill(session) {
206
+ try {
207
+ tmux(["kill-session", "-t", session]);
208
+ } catch (err) {
209
+ debugError("tmuxKill", err);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * @param {string} session
215
+ * @param {string} command
216
+ */
217
+ function tmuxNewSession(session, command) {
218
+ // Use spawnSync to avoid command injection via session/command
219
+ const result = spawnSync("tmux", ["new-session", "-d", "-s", session, command], { encoding: "utf-8" });
220
+ if (result.status !== 0) throw new Error(result.stderr || "tmux new-session failed");
221
+ }
222
+
223
+ /**
224
+ * @returns {string | null}
225
+ */
226
+ function tmuxCurrentSession() {
227
+ if (!process.env.TMUX) return null;
228
+ const result = spawnSync("tmux", ["display-message", "-p", "#S"], { encoding: "utf-8" });
229
+ if (result.status !== 0) return null;
230
+ return result.stdout.trim();
231
+ }
232
+
233
+ /**
234
+ * Check if a session was started in yolo mode by inspecting the pane's start command.
235
+ * @param {string} session
236
+ * @returns {boolean}
237
+ */
238
+ function isYoloSession(session) {
239
+ try {
240
+ const result = spawnSync("tmux", ["display-message", "-t", session, "-p", "#{pane_start_command}"], {
241
+ encoding: "utf-8",
242
+ });
243
+ if (result.status !== 0) return false;
244
+ const cmd = result.stdout.trim();
245
+ return cmd.includes("--dangerously-");
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+
251
+ // =============================================================================
252
+ // Helpers - timing
253
+ // =============================================================================
254
+
255
+ /** @param {number} ms */
256
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
257
+
258
+ const POLL_MS = parseInt(process.env.AX_POLL_MS || "200", 10);
259
+ const DEFAULT_TIMEOUT_MS = parseInt(process.env.AX_TIMEOUT_MS || "120000", 10);
260
+ const REVIEW_TIMEOUT_MS = parseInt(process.env.AX_REVIEW_TIMEOUT_MS || "900000", 10); // 15 minutes
261
+ const STARTUP_TIMEOUT_MS = parseInt(process.env.AX_STARTUP_TIMEOUT_MS || "30000", 10);
262
+ const DAEMON_STARTUP_TIMEOUT_MS = parseInt(process.env.AX_DAEMON_STARTUP_TIMEOUT_MS || "60000", 10);
263
+ const DAEMON_RESPONSE_TIMEOUT_MS = parseInt(process.env.AX_DAEMON_RESPONSE_TIMEOUT_MS || "300000", 10); // 5 minutes
264
+ const DAEMON_HEALTH_CHECK_MS = parseInt(process.env.AX_DAEMON_HEALTH_CHECK_MS || "30000", 10);
265
+ const STABLE_MS = parseInt(process.env.AX_STABLE_MS || "1000", 10);
266
+ const APPROVE_DELAY_MS = parseInt(process.env.AX_APPROVE_DELAY_MS || "100", 10);
267
+ const MAILBOX_MAX_AGE_MS = parseInt(process.env.AX_MAILBOX_MAX_AGE_MS || "3600000", 10); // 1 hour
268
+ const CLAUDE_CONFIG_DIR = process.env.AX_CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
269
+ const CODEX_CONFIG_DIR = process.env.AX_CODEX_CONFIG_DIR || path.join(os.homedir(), ".codex");
270
+ const TRUNCATE_USER_LEN = 500;
271
+ const TRUNCATE_THINKING_LEN = 300;
272
+ const DAEMON_GIT_CONTEXT_HOURS = 4;
273
+ const DAEMON_GIT_CONTEXT_MAX_LINES = 200;
274
+ const DAEMON_PARENT_CONTEXT_ENTRIES = 10;
275
+
276
+ /**
277
+ * @param {string} session
278
+ * @param {(screen: string) => boolean} predicate
279
+ * @param {number} [timeoutMs]
280
+ * @returns {Promise<string>}
281
+ */
282
+ async function waitFor(session, predicate, timeoutMs = STARTUP_TIMEOUT_MS) {
283
+ const start = Date.now();
284
+ while (Date.now() - start < timeoutMs) {
285
+ const screen = tmuxCapture(session);
286
+ if (predicate(screen)) return screen;
287
+ await sleep(POLL_MS);
288
+ }
289
+ throw new Error("timeout");
290
+ }
291
+
292
+ // =============================================================================
293
+ // Helpers - process
294
+ // =============================================================================
295
+
296
+ /**
297
+ * @returns {number | null}
298
+ */
299
+ function findCallerPid() {
300
+ let pid = process.ppid;
301
+ while (pid > 1) {
302
+ const result = spawnSync("ps", ["-p", pid.toString(), "-o", "ppid=,comm="], { encoding: "utf-8" });
303
+ if (result.status !== 0) break;
304
+ const parts = result.stdout.trim().split(/\s+/);
305
+ const ppid = parseInt(parts[0], 10);
306
+ const cmd = parts.slice(1).join(" ");
307
+ if (cmd.includes("claude") || cmd.includes("codex")) {
308
+ return pid;
309
+ }
310
+ pid = ppid;
311
+ }
312
+ return null;
313
+ }
314
+
315
+ // =============================================================================
316
+ // Helpers - stdin
317
+ // =============================================================================
318
+
319
+ /**
320
+ * @returns {boolean}
321
+ */
322
+ function hasStdinData() {
323
+ try {
324
+ const stat = fstatSync(0);
325
+ return stat.isFIFO() || stat.isFile();
326
+ } catch {
327
+ return false;
328
+ }
329
+ }
330
+
331
+ /**
332
+ * @returns {Promise<string>}
333
+ */
334
+ async function readStdin() {
335
+ return new Promise((resolve) => {
336
+ let data = "";
337
+ process.stdin.on("data", (chunk) => (data += chunk));
338
+ process.stdin.on("end", () => resolve(data.trim()));
339
+ });
340
+ }
341
+
342
+ // =============================================================================
343
+ // Helpers - session tracking
344
+ // =============================================================================
345
+
346
+ /**
347
+ * @param {string} session
348
+ * @returns {ParsedSession | null}
349
+ */
350
+ function parseSessionName(session) {
351
+ const match = session.match(/^(claude|codex)-(.+)$/i);
352
+ if (!match) return null;
353
+
354
+ const tool = match[1].toLowerCase();
355
+ const rest = match[2];
356
+
357
+ // Daemon: {tool}-daemon-{name}-{uuid}
358
+ const daemonMatch = rest.match(/^daemon-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
359
+ if (daemonMatch) {
360
+ return { tool, daemonName: daemonMatch[1], uuid: daemonMatch[2] };
361
+ }
362
+
363
+ // Partner: {tool}-partner-{uuid}
364
+ const partnerMatch = rest.match(/^partner-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
365
+ if (partnerMatch) {
366
+ return { tool, uuid: partnerMatch[1] };
367
+ }
368
+
369
+ // Anything else
370
+ return { tool };
371
+ }
372
+
373
+ /**
374
+ * @param {string} tool
375
+ * @returns {string}
376
+ */
377
+ function generateSessionName(tool) {
378
+ return `${tool}-partner-${randomUUID()}`;
379
+ }
380
+
381
+ /**
382
+ * @param {string} cwd
383
+ * @returns {string}
384
+ */
385
+ function getClaudeProjectPath(cwd) {
386
+ // Claude encodes project paths by replacing / with -
387
+ // e.g., /Users/sebinsua/dev/gruf -> -Users-sebinsua-dev-gruf
388
+ return cwd.replace(/\//g, "-");
389
+ }
390
+
391
+ /**
392
+ * @param {string} sessionName
393
+ * @returns {string | null}
394
+ */
395
+ function getTmuxSessionCwd(sessionName) {
396
+ try {
397
+ const result = spawnSync("tmux", ["display-message", "-t", sessionName, "-p", "#{pane_current_path}"], {
398
+ encoding: "utf-8",
399
+ });
400
+ if (result.status === 0) return result.stdout.trim();
401
+ } catch (err) {
402
+ debugError("getTmuxSessionCwd", err);
403
+ }
404
+ return null;
405
+ }
406
+
407
+ /**
408
+ * @param {string} sessionId
409
+ * @param {string | null} sessionName
410
+ * @returns {string | null}
411
+ */
412
+ function findClaudeLogPath(sessionId, sessionName) {
413
+ // Get cwd from tmux session, fall back to process.cwd()
414
+ const cwd = (sessionName && getTmuxSessionCwd(sessionName)) || process.cwd();
415
+ const projectPath = getClaudeProjectPath(cwd);
416
+ const claudeProjectDir = path.join(CLAUDE_CONFIG_DIR, "projects", projectPath);
417
+
418
+ // Check sessions-index.json first
419
+ const indexPath = path.join(claudeProjectDir, "sessions-index.json");
420
+ if (existsSync(indexPath)) {
421
+ try {
422
+ const index = JSON.parse(readFileSync(indexPath, "utf-8"));
423
+ const entry = index.entries?.find(/** @param {{sessionId: string, fullPath?: string}} e */ (e) => e.sessionId === sessionId);
424
+ if (entry?.fullPath) return entry.fullPath;
425
+ } catch (err) {
426
+ debugError("findClaudeLogPath", err);
427
+ }
428
+ }
429
+
430
+ // Fallback: direct path
431
+ const directPath = path.join(claudeProjectDir, `${sessionId}.jsonl`);
432
+ if (existsSync(directPath)) return directPath;
433
+
434
+ return null;
435
+ }
436
+
437
+ /**
438
+ * @param {string} sessionName
439
+ * @returns {string | null}
440
+ */
441
+ function findCodexLogPath(sessionName) {
442
+ // For Codex, we need to match by timing since we can't control the session ID
443
+ // Get tmux session creation time
444
+ try {
445
+ const result = spawnSync("tmux", ["display-message", "-t", sessionName, "-p", "#{session_created}"], {
446
+ encoding: "utf-8",
447
+ });
448
+ if (result.status !== 0) return null;
449
+ const createdTs = parseInt(result.stdout.trim(), 10) * 1000; // tmux gives seconds, we need ms
450
+ if (isNaN(createdTs)) return null;
451
+
452
+ // Codex stores sessions in ~/.codex/sessions/YYYY/MM/DD/rollout-TIMESTAMP-UUID.jsonl
453
+ const sessionsDir = path.join(CODEX_CONFIG_DIR, "sessions");
454
+ if (!existsSync(sessionsDir)) return null;
455
+
456
+ const startDate = new Date(createdTs);
457
+ const year = startDate.getFullYear().toString();
458
+ const month = String(startDate.getMonth() + 1).padStart(2, "0");
459
+ const day = String(startDate.getDate()).padStart(2, "0");
460
+
461
+ const dayDir = path.join(sessionsDir, year, month, day);
462
+ if (!existsSync(dayDir)) return null;
463
+
464
+ // Find the closest log file created after the tmux session started
465
+ // Use 60-second window to handle slow startups (model download, first run, heavy load)
466
+ const files = readdirSync(dayDir).filter((f) => f.endsWith(".jsonl"));
467
+ const candidates = [];
468
+
469
+ for (const file of files) {
470
+ // Parse timestamp from filename: rollout-2026-01-22T13-05-15-UUID.jsonl
471
+ const match = file.match(/^rollout-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-/);
472
+ if (!match) continue;
473
+
474
+ const [, y, mo, d, h, mi, s] = match;
475
+ const fileTime = new Date(`${y}-${mo}-${d}T${h}:${mi}:${s}Z`).getTime();
476
+ const diff = fileTime - createdTs;
477
+
478
+ // Log file should be created shortly after session start
479
+ // Allow small negative diff (-2s) for clock skew, up to 60s for slow starts
480
+ if (diff >= -2000 && diff < 60000) {
481
+ candidates.push({ file, diff: Math.abs(diff), path: path.join(dayDir, file) });
482
+ }
483
+ }
484
+
485
+ if (candidates.length === 0) return null;
486
+ // Return the closest match
487
+ candidates.sort((a, b) => a.diff - b.diff);
488
+ return candidates[0].path;
489
+ } catch {
490
+ return null;
491
+ }
492
+ }
493
+
494
+
495
+ /**
496
+ * Extract assistant text responses from a JSONL log file.
497
+ * This provides clean text without screen-scraped artifacts.
498
+ * @param {string} logPath - Path to the JSONL log file
499
+ * @param {number} [index=0] - 0 = last response, -1 = second-to-last, etc.
500
+ * @returns {string | null} The assistant text or null if not found
501
+ */
502
+ function getAssistantText(logPath, index = 0) {
503
+ if (!logPath || !existsSync(logPath)) return null;
504
+
505
+ try {
506
+ const content = readFileSync(logPath, "utf-8");
507
+ const lines = content.trim().split("\n").filter(Boolean);
508
+
509
+ // Collect all assistant entries with text (from end, for efficiency)
510
+ const assistantTexts = [];
511
+ const needed = Math.abs(index) + 1;
512
+
513
+ for (let i = lines.length - 1; i >= 0 && assistantTexts.length < needed; i--) {
514
+ try {
515
+ const entry = JSON.parse(lines[i]);
516
+ if (entry.type === "assistant") {
517
+ /** @type {{type: string, text?: string}[]} */
518
+ const parts = entry.message?.content || [];
519
+ const text = parts
520
+ .filter((p) => p.type === "text")
521
+ .map((p) => p.text || "")
522
+ .join("\n")
523
+ .trim();
524
+ if (text) assistantTexts.push(text);
525
+ }
526
+ } catch (err) {
527
+ debugError("getAssistantText:parse", err);
528
+ }
529
+ }
530
+
531
+ // index=0 means last (assistantTexts[0]), index=-1 means second-to-last (assistantTexts[1])
532
+ const targetIndex = Math.abs(index);
533
+ return assistantTexts[targetIndex] ?? null;
534
+ } catch (err) {
535
+ debugError("getAssistantText", err);
536
+ return null;
537
+ }
538
+ }
539
+
540
+ /**
541
+ * @returns {string[]}
542
+ */
543
+ function tmuxListSessions() {
544
+ try {
545
+ const output = tmux(["list-sessions", "-F", "#{session_name}"]);
546
+ return output.trim().split("\n").filter(Boolean);
547
+ } catch {
548
+ return [];
549
+ }
550
+ }
551
+
552
+ /**
553
+ * @param {string} partial
554
+ * @returns {string | null}
555
+ */
556
+ function resolveSessionName(partial) {
557
+ if (!partial) return null;
558
+
559
+ const sessions = tmuxListSessions();
560
+ const agentSessions = sessions.filter((s) => parseSessionName(s));
561
+
562
+ // Exact match
563
+ if (agentSessions.includes(partial)) return partial;
564
+
565
+ // Daemon name match (e.g., "reviewer" matches "claude-daemon-reviewer-uuid")
566
+ const daemonMatches = agentSessions.filter((s) => {
567
+ const parsed = parseSessionName(s);
568
+ return parsed?.daemonName === partial;
569
+ });
570
+ if (daemonMatches.length === 1) return daemonMatches[0];
571
+ if (daemonMatches.length > 1) {
572
+ console.log("ERROR: ambiguous daemon name. Matches:");
573
+ for (const m of daemonMatches) console.log(` ${m}`);
574
+ process.exit(1);
575
+ }
576
+
577
+ // Prefix match
578
+ const matches = agentSessions.filter((s) => s.startsWith(partial));
579
+ if (matches.length === 1) return matches[0];
580
+ if (matches.length > 1) {
581
+ console.log("ERROR: ambiguous session prefix. Matches:");
582
+ for (const m of matches) console.log(` ${m}`);
583
+ process.exit(1);
584
+ }
585
+
586
+ // Partial UUID match (e.g., "33fe38" matches "claude-partner-33fe38b1-...")
587
+ const uuidMatches = agentSessions.filter((s) => {
588
+ const parsed = parseSessionName(s);
589
+ return parsed?.uuid?.startsWith(partial);
590
+ });
591
+ if (uuidMatches.length === 1) return uuidMatches[0];
592
+ if (uuidMatches.length > 1) {
593
+ console.log("ERROR: ambiguous UUID prefix. Matches:");
594
+ for (const m of uuidMatches) console.log(` ${m}`);
595
+ process.exit(1);
596
+ }
597
+
598
+ return partial; // Return as-is, let caller handle not found
599
+ }
600
+
601
+ // =============================================================================
602
+ // Helpers - daemon agents
603
+ // =============================================================================
604
+
605
+ /**
606
+ * @returns {DaemonConfig[]}
607
+ */
608
+ function loadAgentConfigs() {
609
+ const agentsDir = AGENTS_DIR;
610
+ if (!existsSync(agentsDir)) return [];
611
+
612
+ const files = readdirSync(agentsDir).filter((f) => f.endsWith(".md"));
613
+ /** @type {DaemonConfig[]} */
614
+ const configs = [];
615
+
616
+ for (const file of files) {
617
+ try {
618
+ const content = readFileSync(path.join(agentsDir, file), "utf-8");
619
+ const config = parseAgentConfig(file, content);
620
+ if (config && 'error' in config) {
621
+ console.error(`ERROR: ${file}: ${config.error}`);
622
+ continue;
623
+ }
624
+ if (config) configs.push(config);
625
+ } catch (err) {
626
+ console.error(`ERROR: Failed to read ${file}: ${err instanceof Error ? err.message : err}`);
627
+ }
628
+ }
629
+
630
+ return configs;
631
+ }
632
+
633
+ /**
634
+ * @param {string} filename
635
+ * @param {string} content
636
+ * @returns {DaemonConfig | {error: string} | null}
637
+ */
638
+ function parseAgentConfig(filename, content) {
639
+ const name = filename.replace(/\.md$/, "");
640
+
641
+ // Normalize line endings (handle Windows CRLF)
642
+ const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
643
+
644
+ // Parse frontmatter
645
+ const frontmatterMatch = normalized.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
646
+ if (!frontmatterMatch) {
647
+ if (!normalized.startsWith("---")) {
648
+ return { error: `Missing frontmatter. File must start with '---'` };
649
+ }
650
+ if (!normalized.includes("\n---\n")) {
651
+ return { error: `Frontmatter not closed. Add '---' on its own line after the YAML block` };
652
+ }
653
+ return { error: `Invalid frontmatter format` };
654
+ }
655
+
656
+ const frontmatter = frontmatterMatch[1];
657
+ const prompt = frontmatterMatch[2].trim();
658
+
659
+ if (!prompt) {
660
+ return { error: `Missing prompt content after frontmatter` };
661
+ }
662
+
663
+ // Known fields
664
+ const knownFields = ["tool", "interval", "watch"];
665
+
666
+ // Check for unknown fields (likely typos)
667
+ const fieldLines = frontmatter.split("\n").filter((line) => /^\w+:/.test(line.trim()));
668
+ for (const line of fieldLines) {
669
+ const fieldName = line.trim().match(/^(\w+):/)?.[1];
670
+ if (fieldName && !knownFields.includes(fieldName)) {
671
+ // Suggest closest match
672
+ const suggestions = knownFields.filter((f) => f[0] === fieldName[0] || fieldName.includes(f.slice(0, 3)));
673
+ const hint = suggestions.length > 0 ? ` Did you mean '${suggestions[0]}'?` : "";
674
+ return { error: `Unknown field '${fieldName}'.${hint} Valid fields: ${knownFields.join(", ")}` };
675
+ }
676
+ }
677
+
678
+ // Parse tool
679
+ const toolMatch = frontmatter.match(/^tool:\s*(\S+)/m);
680
+ const tool = toolMatch?.[1] || "codex";
681
+ if (tool !== "claude" && tool !== "codex") {
682
+ return { error: `Invalid tool '${tool}'. Must be 'claude' or 'codex'` };
683
+ }
684
+
685
+ // Parse interval
686
+ const intervalMatch = frontmatter.match(/^interval:\s*(.+)$/m);
687
+ let interval = 60;
688
+ if (intervalMatch) {
689
+ const rawValue = intervalMatch[1].trim();
690
+ const parsed = parseInt(rawValue, 10);
691
+ if (isNaN(parsed)) {
692
+ return { error: `Invalid interval '${rawValue}'. Must be a number (seconds)` };
693
+ }
694
+ interval = Math.max(10, Math.min(3600, parsed)); // Clamp to 10s - 1hr
695
+ }
696
+
697
+ // Parse watch patterns
698
+ const watchLine = frontmatter.match(/^watch:\s*(.+)$/m);
699
+ let watchPatterns = ["**/*"];
700
+ if (watchLine) {
701
+ const rawWatch = watchLine[1].trim();
702
+ // Must be array format
703
+ if (!rawWatch.startsWith("[") || !rawWatch.endsWith("]")) {
704
+ return { error: `Invalid watch format. Must be an array: watch: ["src/**/*.ts"]` };
705
+ }
706
+ const inner = rawWatch.slice(1, -1).trim();
707
+ if (!inner) {
708
+ return { error: `Empty watch array. Add at least one pattern: watch: ["**/*"]` };
709
+ }
710
+ watchPatterns = inner.split(",").map((p) => p.trim().replace(/^["']|["']$/g, ""));
711
+ // Validate patterns aren't empty
712
+ if (watchPatterns.some((p) => !p)) {
713
+ return { error: `Invalid watch pattern. Check for trailing commas or empty values` };
714
+ }
715
+ }
716
+
717
+ return { name, tool, watch: watchPatterns, interval, prompt };
718
+ }
719
+
720
+ /**
721
+ * @param {DaemonConfig} config
722
+ * @returns {string}
723
+ */
724
+ function getDaemonSessionPattern(config) {
725
+ return `${config.tool}-daemon-${config.name}`;
726
+ }
727
+
728
+ // =============================================================================
729
+ // Helpers - mailbox
730
+ // =============================================================================
731
+
732
+ const MAILBOX_PATH = path.join(AI_DIR, "mailbox.jsonl");
733
+
734
+ /**
735
+ * @returns {void}
736
+ */
737
+ function ensureMailboxDir() {
738
+ const dir = path.dirname(MAILBOX_PATH);
739
+ if (!existsSync(dir)) {
740
+ mkdirSync(dir, { recursive: true });
741
+ }
742
+ }
743
+
744
+ /**
745
+ * @param {MailboxPayload} payload
746
+ * @returns {void}
747
+ */
748
+ function writeToMailbox(payload) {
749
+ ensureMailboxDir();
750
+ const entry = {
751
+ timestamp: new Date().toISOString(),
752
+ type: "observation",
753
+ payload,
754
+ };
755
+ appendFileSync(MAILBOX_PATH, JSON.stringify(entry) + "\n");
756
+ }
757
+
758
+ /**
759
+ * @param {Object} [options]
760
+ * @param {number} [options.maxAge]
761
+ * @param {string | null} [options.branch]
762
+ * @param {number} [options.limit]
763
+ * @returns {MailboxEntry[]}
764
+ */
765
+ function readMailbox({ maxAge = MAILBOX_MAX_AGE_MS, branch = null, limit = 10 } = {}) {
766
+ if (!existsSync(MAILBOX_PATH)) return [];
767
+
768
+ const now = Date.now();
769
+ const lines = readFileSync(MAILBOX_PATH, "utf-8").trim().split("\n").filter(Boolean);
770
+ /** @type {MailboxEntry[]} */
771
+ const entries = [];
772
+
773
+ for (const line of lines) {
774
+ try {
775
+ const entry = JSON.parse(line);
776
+ const age = now - new Date(entry.timestamp).getTime();
777
+
778
+ // Filter by age
779
+ if (age > maxAge) continue;
780
+
781
+ // Filter by branch if specified
782
+ if (branch && entry.payload?.branch !== branch) continue;
783
+
784
+ entries.push(entry);
785
+ } catch (err) {
786
+ debugError("readMailbox", err);
787
+ }
788
+ }
789
+
790
+ // Return most recent entries
791
+ return entries.slice(-limit);
792
+ }
793
+
794
+ /**
795
+ * @param {number} [maxAgeHours]
796
+ * @returns {void}
797
+ */
798
+ function gcMailbox(maxAgeHours = 24) {
799
+ if (!existsSync(MAILBOX_PATH)) return;
800
+
801
+ const now = Date.now();
802
+ const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
803
+ const lines = readFileSync(MAILBOX_PATH, "utf-8").trim().split("\n").filter(Boolean);
804
+ const kept = [];
805
+
806
+ for (const line of lines) {
807
+ try {
808
+ const entry = JSON.parse(line);
809
+ const age = now - new Date(entry.timestamp).getTime();
810
+ if (age < maxAgeMs) kept.push(line);
811
+ } catch {
812
+ // Skip invalid lines
813
+ }
814
+ }
815
+
816
+ // Atomic write: write to temp file then rename
817
+ const tmpPath = MAILBOX_PATH + ".tmp";
818
+ writeFileSync(tmpPath, kept.join("\n") + (kept.length ? "\n" : ""));
819
+ renameSync(tmpPath, MAILBOX_PATH);
820
+ }
821
+
822
+ // =============================================================================
823
+ // Helpers - git
824
+ // =============================================================================
825
+
826
+ /** @returns {string} */
827
+ function getCurrentBranch() {
828
+ try {
829
+ return execSync("git branch --show-current 2>/dev/null", { encoding: "utf-8" }).trim();
830
+ } catch {
831
+ return "unknown";
832
+ }
833
+ }
834
+
835
+ /** @returns {string} */
836
+ function getCurrentCommit() {
837
+ try {
838
+ return execSync("git rev-parse --short HEAD 2>/dev/null", { encoding: "utf-8" }).trim();
839
+ } catch {
840
+ return "unknown";
841
+ }
842
+ }
843
+
844
+ /** @returns {string} */
845
+ function getMainBranch() {
846
+ try {
847
+ execSync("git rev-parse --verify main 2>/dev/null");
848
+ return "main";
849
+ } catch {
850
+ try {
851
+ execSync("git rev-parse --verify master 2>/dev/null");
852
+ return "master";
853
+ } catch {
854
+ return "main";
855
+ }
856
+ }
857
+ }
858
+
859
+ /** @returns {string} */
860
+ function getStagedDiff() {
861
+ try {
862
+ return execSync("git diff --cached 2>/dev/null", { encoding: "utf-8" }).trim();
863
+ } catch {
864
+ return "";
865
+ }
866
+ }
867
+
868
+ /** @returns {string} */
869
+ function getUncommittedDiff() {
870
+ try {
871
+ return execSync("git diff 2>/dev/null", { encoding: "utf-8" }).trim();
872
+ } catch {
873
+ return "";
874
+ }
875
+ }
876
+
877
+ /**
878
+ * @param {number} [hoursAgo]
879
+ * @returns {string}
880
+ */
881
+ function getRecentCommitsDiff(hoursAgo = 4) {
882
+ try {
883
+ const mainBranch = getMainBranch();
884
+ const since = `--since="${hoursAgo} hours ago"`;
885
+
886
+ // Get list of commits in range
887
+ const commits = execSync(
888
+ `git log ${mainBranch}..HEAD ${since} --oneline 2>/dev/null`,
889
+ { encoding: "utf-8" }
890
+ ).trim();
891
+
892
+ if (!commits) return "";
893
+
894
+ // Get diff for those commits
895
+ const firstCommit = commits.split("\n").filter(Boolean).pop()?.split(" ")[0];
896
+ if (!firstCommit) return "";
897
+ return execSync(
898
+ `git diff ${firstCommit}^..HEAD 2>/dev/null`,
899
+ { encoding: "utf-8" }
900
+ ).trim();
901
+ } catch {
902
+ return "";
903
+ }
904
+ }
905
+
906
+ /**
907
+ * @param {string} diff
908
+ * @param {number} [maxLines]
909
+ * @returns {string}
910
+ */
911
+ function truncateDiff(diff, maxLines = 200) {
912
+ if (!diff) return "";
913
+ const lines = diff.split("\n");
914
+ if (lines.length <= maxLines) return diff;
915
+ return lines.slice(0, maxLines).join("\n") + `\n\n... (truncated, ${lines.length - maxLines} more lines)`;
916
+ }
917
+
918
+ /**
919
+ * @param {number} [hoursAgo]
920
+ * @param {number} [maxLinesPerSection]
921
+ * @returns {string}
922
+ */
923
+ function buildGitContext(hoursAgo = 4, maxLinesPerSection = 200) {
924
+ const sections = [];
925
+
926
+ const staged = truncateDiff(getStagedDiff(), maxLinesPerSection);
927
+ if (staged) {
928
+ sections.push("## Staged Changes (about to be committed)\n```diff\n" + staged + "\n```");
929
+ }
930
+
931
+ const uncommitted = truncateDiff(getUncommittedDiff(), maxLinesPerSection);
932
+ if (uncommitted) {
933
+ sections.push("## Uncommitted Changes (work in progress)\n```diff\n" + uncommitted + "\n```");
934
+ }
935
+
936
+ const recent = truncateDiff(getRecentCommitsDiff(hoursAgo), maxLinesPerSection);
937
+ if (recent) {
938
+ sections.push(`## Recent Commits (last ${hoursAgo} hours)\n\`\`\`diff\n` + recent + "\n```");
939
+ }
940
+
941
+ return sections.join("\n\n");
942
+ }
943
+
944
+ // =============================================================================
945
+ // Helpers - parent session context
946
+ // =============================================================================
947
+
948
+ // Environment variables used to pass parent session info to daemons
949
+ const AX_DAEMON_PARENT_SESSION_ENV = "AX_DAEMON_PARENT_SESSION";
950
+ const AX_DAEMON_PARENT_UUID_ENV = "AX_DAEMON_PARENT_UUID";
951
+
952
+ /**
953
+ * @returns {ParentSession | null}
954
+ */
955
+ function findCurrentClaudeSession() {
956
+ // If we're inside a tmux session, check if it's a Claude session
957
+ const current = tmuxCurrentSession();
958
+ if (current) {
959
+ const parsed = parseSessionName(current);
960
+ if (parsed?.tool === "claude" && !parsed.daemonName && parsed.uuid) {
961
+ return { session: current, uuid: parsed.uuid };
962
+ }
963
+ }
964
+
965
+ // We might be running from Claude but not inside tmux (e.g., VSCode, Cursor)
966
+ // Find Claude sessions in the same cwd and pick the most recently active one
967
+ const callerPid = findCallerPid();
968
+ if (!callerPid) return null; // Not running from Claude
969
+
970
+ const cwd = process.cwd();
971
+ const sessions = tmuxListSessions();
972
+ const candidates = [];
973
+
974
+ for (const session of sessions) {
975
+ const parsed = parseSessionName(session);
976
+ if (!parsed || parsed.tool !== "claude") continue;
977
+ if (parsed.daemonName) continue;
978
+ if (!parsed.uuid) continue;
979
+
980
+ const sessionCwd = getTmuxSessionCwd(session);
981
+ if (sessionCwd !== cwd) continue;
982
+
983
+ // Check log file modification time
984
+ const logPath = findClaudeLogPath(parsed.uuid, session);
985
+ if (logPath && existsSync(logPath)) {
986
+ try {
987
+ const stat = statSync(logPath);
988
+ candidates.push({ session, uuid: parsed.uuid, mtime: stat.mtimeMs });
989
+ } catch (err) {
990
+ debugError("findCurrentClaudeSession:stat", err);
991
+ }
992
+ }
993
+ }
994
+
995
+ // Also check non-tmux Claude sessions by scanning the project's log directory
996
+ const projectPath = getClaudeProjectPath(cwd);
997
+ const claudeProjectDir = path.join(CLAUDE_CONFIG_DIR, "projects", projectPath);
998
+ if (existsSync(claudeProjectDir)) {
999
+ try {
1000
+ const files = readdirSync(claudeProjectDir).filter(f => f.endsWith(".jsonl"));
1001
+ for (const file of files) {
1002
+ const uuid = file.replace(".jsonl", "");
1003
+ // Skip if we already have this from tmux sessions
1004
+ if (candidates.some(c => c.uuid === uuid)) continue;
1005
+
1006
+ const logPath = path.join(claudeProjectDir, file);
1007
+ try {
1008
+ const stat = statSync(logPath);
1009
+ // Only consider logs modified in the last hour (active sessions)
1010
+ if (Date.now() - stat.mtimeMs < MAILBOX_MAX_AGE_MS) {
1011
+ candidates.push({ session: null, uuid, mtime: stat.mtimeMs, logPath });
1012
+ }
1013
+ } catch (err) {
1014
+ debugError("findCurrentClaudeSession:logStat", err);
1015
+ }
1016
+ }
1017
+ } catch (err) {
1018
+ debugError("findCurrentClaudeSession:readdir", err);
1019
+ }
1020
+ }
1021
+
1022
+ if (candidates.length === 0) return null;
1023
+
1024
+ // Return the most recently active session
1025
+ candidates.sort((a, b) => b.mtime - a.mtime);
1026
+ return { session: candidates[0].session, uuid: candidates[0].uuid };
1027
+ }
1028
+
1029
+ /**
1030
+ * @returns {ParentSession | null}
1031
+ */
1032
+ function findParentSession() {
1033
+ // First check if parent session was passed via environment (for daemons)
1034
+ const envUuid = process.env[AX_DAEMON_PARENT_UUID_ENV];
1035
+ if (envUuid) {
1036
+ // Session name is optional (may be null for non-tmux sessions)
1037
+ const envSession = process.env[AX_DAEMON_PARENT_SESSION_ENV] || null;
1038
+ return { session: envSession, uuid: envUuid };
1039
+ }
1040
+
1041
+ // Fallback to detecting current session (shouldn't be needed for daemons)
1042
+ return findCurrentClaudeSession();
1043
+ }
1044
+
1045
+ /**
1046
+ * @param {number} [maxEntries]
1047
+ * @returns {string}
1048
+ */
1049
+ function getParentSessionContext(maxEntries = 20) {
1050
+ const parent = findParentSession();
1051
+ if (!parent) return "";
1052
+
1053
+ const logPath = findClaudeLogPath(parent.uuid, parent.session);
1054
+ if (!logPath || !existsSync(logPath)) return "";
1055
+
1056
+ try {
1057
+ const content = readFileSync(logPath, "utf-8");
1058
+ const lines = content.trim().split("\n").filter(Boolean);
1059
+
1060
+ // Go back further to find meaningful entries (not just tool uses)
1061
+ const recent = lines.slice(-maxEntries * 10);
1062
+ /** @type {{type: string, text: string}[]} */
1063
+ const entries = [];
1064
+ /** @type {string | null} */
1065
+ let planPath = null;
1066
+
1067
+ for (const line of recent) {
1068
+ try {
1069
+ const entry = JSON.parse(line);
1070
+
1071
+ // Look for plan file path in the log content
1072
+ if (!planPath) {
1073
+ const planMatch = line.match(/\/Users\/[^"]+\/\.claude\/plans\/[^"]+\.md/);
1074
+ if (planMatch) planPath = planMatch[0];
1075
+ }
1076
+
1077
+ if (entry.type === "user") {
1078
+ const c = entry.message?.content;
1079
+ // Only include user messages with actual text (not just tool results)
1080
+ if (typeof c === "string" && c.length > 10) {
1081
+ entries.push({ type: "user", text: c });
1082
+ } else if (Array.isArray(c)) {
1083
+ const text = c.find(/** @param {{type: string, text?: string}} x */ (x) => x.type === "text")?.text;
1084
+ if (text && text.length > 10) {
1085
+ entries.push({ type: "user", text });
1086
+ }
1087
+ }
1088
+ } else if (entry.type === "assistant") {
1089
+ /** @type {{type: string, text?: string}[]} */
1090
+ const parts = entry.message?.content || [];
1091
+ const text = parts.filter((p) => p.type === "text").map((p) => p.text || "").join("\n");
1092
+ // Only include assistant responses with meaningful text
1093
+ if (text && text.length > 20) {
1094
+ entries.push({ type: "assistant", text });
1095
+ }
1096
+ }
1097
+ } catch (err) {
1098
+ debugError("getParentSessionContext:parseLine", err);
1099
+ }
1100
+ }
1101
+
1102
+ // Format recent conversation
1103
+ const formatted = entries.slice(-maxEntries).map(e => {
1104
+ const preview = e.text.slice(0, 500).replace(/\n/g, " ");
1105
+ return `**${e.type === "user" ? "User" : "Assistant"}**: ${preview}`;
1106
+ });
1107
+
1108
+ let result = formatted.join("\n\n");
1109
+
1110
+ // If we found a plan file, include its contents
1111
+ if (planPath && existsSync(planPath)) {
1112
+ try {
1113
+ const planContent = readFileSync(planPath, "utf-8").trim();
1114
+ if (planContent) {
1115
+ result += "\n\n## Current Plan\n\n" + planContent.slice(0, 2000);
1116
+ }
1117
+ } catch (err) {
1118
+ debugError("getParentSessionContext:readPlan", err);
1119
+ }
1120
+ }
1121
+
1122
+ return result;
1123
+ } catch (err) {
1124
+ debugError("getParentSessionContext", err);
1125
+ return "";
1126
+ }
1127
+ }
1128
+
1129
+ // =============================================================================
1130
+ // JSONL extraction for intent matching
1131
+ // =============================================================================
1132
+
1133
+ /**
1134
+ * @param {string | null} logPath
1135
+ * @param {string} filePath
1136
+ * @returns {FileEditContext | null}
1137
+ */
1138
+ function extractFileEditContext(logPath, filePath) {
1139
+ if (!logPath || !existsSync(logPath)) return null;
1140
+
1141
+ const content = readFileSync(logPath, "utf-8");
1142
+ const lines = content.trim().split("\n").filter(Boolean);
1143
+
1144
+ // Parse all entries
1145
+ /** @type {any[]} */
1146
+ const entries = lines.map((line, idx) => {
1147
+ try { return { idx, ...JSON.parse(line) }; }
1148
+ catch (err) { debugError("extractFileEditContext:parse", err); return null; }
1149
+ }).filter(Boolean);
1150
+
1151
+ // Find Write/Edit tool calls for this file (scan backwards, want most recent)
1152
+ /** @type {any} */
1153
+ let editEntry = null;
1154
+ let editIdx = -1;
1155
+
1156
+ for (let i = entries.length - 1; i >= 0; i--) {
1157
+ const entry = entries[i];
1158
+ if (entry.type !== "assistant") continue;
1159
+
1160
+ /** @type {any[]} */
1161
+ const msgContent = entry.message?.content || [];
1162
+ const toolCalls = msgContent.filter((/** @type {any} */ c) =>
1163
+ (c.type === "tool_use" || c.type === "tool_call") &&
1164
+ (c.name === "Write" || c.name === "Edit")
1165
+ );
1166
+
1167
+ for (const tc of toolCalls) {
1168
+ const input = tc.input || tc.arguments || {};
1169
+ if (input.file_path === filePath || input.file_path?.endsWith("/" + filePath)) {
1170
+ editEntry = { entry, toolCall: tc, content: msgContent };
1171
+ editIdx = i;
1172
+ break;
1173
+ }
1174
+ }
1175
+ if (editEntry) break;
1176
+ }
1177
+
1178
+ if (!editEntry) return null;
1179
+
1180
+ // Extract intent: text blocks from same assistant message
1181
+ const intent = editEntry.content
1182
+ .filter((/** @type {any} */ c) => c.type === "text")
1183
+ .map((/** @type {any} */ c) => c.text)
1184
+ .join("\n")
1185
+ .trim();
1186
+
1187
+ // Look forward for Bash errors
1188
+ /** @type {string[]} */
1189
+ const subsequentErrors = [];
1190
+ for (let i = editIdx + 1; i < entries.length && i < editIdx + 10; i++) {
1191
+ const entry = entries[i];
1192
+ // Check user messages for tool_result with errors
1193
+ if (entry.type === "user") {
1194
+ /** @type {any[]} */
1195
+ const msgContent = entry.message?.content || [];
1196
+ if (Array.isArray(msgContent)) {
1197
+ for (const c of msgContent) {
1198
+ if (c.type === "tool_result" && c.is_error) {
1199
+ subsequentErrors.push(c.content?.slice(0, 500) || "error");
1200
+ }
1201
+ }
1202
+ }
1203
+ }
1204
+ }
1205
+
1206
+ // Look backward for Read calls (what context did agent have?)
1207
+ /** @type {string[]} */
1208
+ const readsBefore = [];
1209
+ for (let i = editIdx - 1; i >= 0 && i > editIdx - 20; i--) {
1210
+ const entry = entries[i];
1211
+ if (entry.type !== "assistant") continue;
1212
+
1213
+ /** @type {any[]} */
1214
+ const msgContent = entry.message?.content || [];
1215
+ const readCalls = msgContent.filter((/** @type {any} */ c) =>
1216
+ (c.type === "tool_use" || c.type === "tool_call") && c.name === "Read"
1217
+ );
1218
+
1219
+ for (const rc of readCalls) {
1220
+ const input = rc.input || rc.arguments || {};
1221
+ if (input.file_path) readsBefore.push(input.file_path);
1222
+ }
1223
+ }
1224
+
1225
+ // Count edit sequence (how many times was this file edited?)
1226
+ let editSequence = 0;
1227
+ for (const entry of entries) {
1228
+ if (entry.type !== "assistant") continue;
1229
+ /** @type {any[]} */
1230
+ const msgContent = entry.message?.content || [];
1231
+ const edits = msgContent.filter((/** @type {any} */ c) =>
1232
+ (c.type === "tool_use" || c.type === "tool_call") &&
1233
+ (c.name === "Write" || c.name === "Edit")
1234
+ );
1235
+ for (const e of edits) {
1236
+ const input = e.input || e.arguments || {};
1237
+ if (input.file_path === filePath || input.file_path?.endsWith("/" + filePath)) {
1238
+ editSequence++;
1239
+ }
1240
+ }
1241
+ }
1242
+
1243
+ return {
1244
+ intent,
1245
+ toolCall: {
1246
+ name: editEntry.toolCall.name,
1247
+ input: editEntry.toolCall.input || editEntry.toolCall.arguments,
1248
+ id: editEntry.toolCall.id
1249
+ },
1250
+ subsequentErrors,
1251
+ readsBefore: [...new Set(readsBefore)].slice(0, 10),
1252
+ editSequence
1253
+ };
1254
+ }
1255
+
1256
+ // =============================================================================
1257
+ // Helpers - file watching
1258
+ // =============================================================================
1259
+
1260
+ /**
1261
+ * @param {string} pattern
1262
+ * @returns {string}
1263
+ */
1264
+ function getBaseDir(pattern) {
1265
+ // Extract base directory from glob pattern
1266
+ // e.g., "src/**/*.ts" -> "src"
1267
+ // e.g., "**/*.ts" -> "."
1268
+ const parts = pattern.split("/");
1269
+ /** @type {string[]} */
1270
+ const baseParts = [];
1271
+ for (const part of parts) {
1272
+ if (part.includes("*") || part.includes("?") || part.includes("[")) break;
1273
+ baseParts.push(part);
1274
+ }
1275
+ return baseParts.length > 0 ? baseParts.join("/") : ".";
1276
+ }
1277
+
1278
+ /**
1279
+ * @param {string} filename
1280
+ * @param {string} pattern
1281
+ * @returns {boolean}
1282
+ */
1283
+ function matchesPattern(filename, pattern) {
1284
+ return path.matchesGlob(filename, pattern);
1285
+ }
1286
+
1287
+ // Default exclusions - always applied
1288
+ const DEFAULT_EXCLUDE_PATTERNS = [
1289
+ "**/node_modules/**",
1290
+ "**/.git/**",
1291
+ "**/dist/**",
1292
+ "**/build/**",
1293
+ "**/.next/**",
1294
+ "**/coverage/**",
1295
+ ];
1296
+
1297
+ /**
1298
+ * @param {string[]} patterns
1299
+ * @param {(filePath: string) => void} callback
1300
+ * @returns {() => void}
1301
+ */
1302
+ function watchForChanges(patterns, callback) {
1303
+ // Separate include and exclude patterns
1304
+ const includePatterns = patterns.filter((p) => !p.startsWith("!"));
1305
+ const userExcludePatterns = patterns.filter((p) => p.startsWith("!")).map((p) => p.slice(1));
1306
+ const excludePatterns = [...DEFAULT_EXCLUDE_PATTERNS, ...userExcludePatterns];
1307
+
1308
+ /** @type {import('node:fs').FSWatcher[]} */
1309
+ const watchers = [];
1310
+ /** @type {Set<string>} */
1311
+ const watchedDirs = new Set();
1312
+
1313
+ for (const pattern of includePatterns) {
1314
+ const dir = getBaseDir(pattern);
1315
+ if (watchedDirs.has(dir)) continue;
1316
+ if (!existsSync(dir)) continue;
1317
+
1318
+ watchedDirs.add(dir);
1319
+
1320
+ try {
1321
+ const watcher = watch(dir, { recursive: true }, (_eventType, filename) => {
1322
+ if (!filename) return;
1323
+ const fullPath = path.join(dir, filename);
1324
+
1325
+ // Check exclusions first
1326
+ for (const ex of excludePatterns) {
1327
+ if (matchesPattern(fullPath, ex) || matchesPattern(filename, ex)) {
1328
+ return; // Excluded
1329
+ }
1330
+ }
1331
+
1332
+ // Check if this file matches any include pattern
1333
+ for (const p of includePatterns) {
1334
+ if (matchesPattern(fullPath, p) || matchesPattern(filename, p)) {
1335
+ callback(fullPath);
1336
+ break;
1337
+ }
1338
+ }
1339
+ });
1340
+ watchers.push(watcher);
1341
+ } catch (err) {
1342
+ console.error(`Warning: Failed to watch ${dir}: ${err instanceof Error ? err.message : err}`);
1343
+ }
1344
+ }
1345
+
1346
+ return () => { for (const w of watchers) w.close(); };
1347
+ }
1348
+
1349
+
1350
+ // =============================================================================
1351
+ // State
1352
+ // =============================================================================
1353
+
1354
+ const State = {
1355
+ NO_SESSION: "no_session",
1356
+ STARTING: "starting",
1357
+ UPDATE_PROMPT: "update_prompt",
1358
+ READY: "ready",
1359
+ THINKING: "thinking",
1360
+ CONFIRMING: "confirming",
1361
+ RATE_LIMITED: "rate_limited",
1362
+ };
1363
+
1364
+ /**
1365
+ * Pure function to detect agent state from screen content.
1366
+ * @param {string} screen - The screen content to analyze
1367
+ * @param {Object} config - Agent configuration for pattern matching
1368
+ * @param {string} config.promptSymbol - Symbol indicating ready state
1369
+ * @param {string[]} [config.spinners] - Spinner characters indicating thinking
1370
+ * @param {RegExp} [config.rateLimitPattern] - Pattern for rate limit detection
1371
+ * @param {string[]} [config.thinkingPatterns] - Text patterns indicating thinking
1372
+ * @param {(string | ((lines: string) => boolean))[]} [config.confirmPatterns] - Patterns for confirmation dialogs
1373
+ * @param {{screen: string[], lastLines: string[]} | null} [config.updatePromptPatterns] - Patterns for update prompts
1374
+ * @returns {string} The detected state
1375
+ */
1376
+ function detectState(screen, config) {
1377
+ if (!screen) return State.STARTING;
1378
+
1379
+ const lines = screen.trim().split("\n");
1380
+ const lastLines = lines.slice(-8).join("\n");
1381
+ // Larger range for confirmation detection (catches dialogs that scrolled slightly)
1382
+ const recentLines = lines.slice(-15).join("\n");
1383
+
1384
+ // Rate limited - check full screen (rate limit messages can appear anywhere)
1385
+ if (config.rateLimitPattern && config.rateLimitPattern.test(screen)) {
1386
+ return State.RATE_LIMITED;
1387
+ }
1388
+
1389
+ // Thinking - spinners (full screen, they're unique UI elements)
1390
+ const spinners = config.spinners || [];
1391
+ if (spinners.some((s) => screen.includes(s))) {
1392
+ return State.THINKING;
1393
+ }
1394
+ // Thinking - text patterns (last lines)
1395
+ const thinkingPatterns = config.thinkingPatterns || [];
1396
+ if (thinkingPatterns.some((p) => lastLines.includes(p))) {
1397
+ return State.THINKING;
1398
+ }
1399
+
1400
+ // Update prompt
1401
+ if (config.updatePromptPatterns) {
1402
+ const { screen: sp, lastLines: lp } = config.updatePromptPatterns;
1403
+ if (sp && sp.some((p) => screen.includes(p)) && lp && lp.some((p) => lastLines.includes(p))) {
1404
+ return State.UPDATE_PROMPT;
1405
+ }
1406
+ }
1407
+
1408
+ // Confirming - check recent lines (not full screen to avoid history false positives)
1409
+ const confirmPatterns = config.confirmPatterns || [];
1410
+ for (const pattern of confirmPatterns) {
1411
+ if (typeof pattern === "function") {
1412
+ // Functions check lastLines first (most specific), then recentLines
1413
+ if (pattern(lastLines)) return State.CONFIRMING;
1414
+ if (pattern(recentLines)) return State.CONFIRMING;
1415
+ } else {
1416
+ // String patterns check recentLines (bounded range)
1417
+ if (recentLines.includes(pattern)) return State.CONFIRMING;
1418
+ }
1419
+ }
1420
+
1421
+ // Ready - only if prompt symbol is visible AND not followed by pasted content
1422
+ // "[Pasted text" indicates user has pasted content and Claude is still processing
1423
+ if (lastLines.includes(config.promptSymbol)) {
1424
+ // Check if any line has the prompt followed by pasted content indicator
1425
+ const linesArray = lastLines.split("\n");
1426
+ const promptWithPaste = linesArray.some(
1427
+ (l) => l.includes(config.promptSymbol) && l.includes("[Pasted text")
1428
+ );
1429
+ if (!promptWithPaste) {
1430
+ return State.READY;
1431
+ }
1432
+ // If prompt has pasted content, Claude is still processing - not ready yet
1433
+ }
1434
+
1435
+ return State.STARTING;
1436
+ }
1437
+
1438
+ // =============================================================================
1439
+ // Agent base class
1440
+ // =============================================================================
1441
+
1442
+ /**
1443
+ * @typedef {string | ((lines: string) => boolean)} ConfirmPattern
1444
+ */
1445
+
1446
+ /**
1447
+ * @typedef {Object} UpdatePromptPatterns
1448
+ * @property {string[]} screen
1449
+ * @property {string[]} lastLines
1450
+ */
1451
+
1452
+ /**
1453
+ * @typedef {Object} AgentConfigInput
1454
+ * @property {string} name
1455
+ * @property {string} startCommand
1456
+ * @property {string} yoloCommand
1457
+ * @property {string} promptSymbol
1458
+ * @property {string[]} [spinners]
1459
+ * @property {RegExp} [rateLimitPattern]
1460
+ * @property {string[]} [thinkingPatterns]
1461
+ * @property {ConfirmPattern[]} [confirmPatterns]
1462
+ * @property {UpdatePromptPatterns | null} [updatePromptPatterns]
1463
+ * @property {string[]} [responseMarkers]
1464
+ * @property {string[]} [chromePatterns]
1465
+ * @property {Record<string, string> | null} [reviewOptions]
1466
+ * @property {string} envVar
1467
+ * @property {string} [approveKey]
1468
+ * @property {string} [rejectKey]
1469
+ * @property {string} [safeAllowedTools]
1470
+ */
1471
+
1472
+ class Agent {
1473
+ /**
1474
+ * @param {AgentConfigInput} config
1475
+ */
1476
+ constructor(config) {
1477
+ /** @type {string} */
1478
+ this.name = config.name;
1479
+ /** @type {string} */
1480
+ this.startCommand = config.startCommand;
1481
+ /** @type {string} */
1482
+ this.yoloCommand = config.yoloCommand;
1483
+ /** @type {string} */
1484
+ this.promptSymbol = config.promptSymbol;
1485
+ /** @type {string[]} */
1486
+ this.spinners = config.spinners || [];
1487
+ /** @type {RegExp | undefined} */
1488
+ this.rateLimitPattern = config.rateLimitPattern;
1489
+ /** @type {string[]} */
1490
+ this.thinkingPatterns = config.thinkingPatterns || [];
1491
+ /** @type {ConfirmPattern[]} */
1492
+ this.confirmPatterns = config.confirmPatterns || [];
1493
+ /** @type {UpdatePromptPatterns | null} */
1494
+ this.updatePromptPatterns = config.updatePromptPatterns || null;
1495
+ /** @type {string[]} */
1496
+ this.responseMarkers = config.responseMarkers || [];
1497
+ /** @type {string[]} */
1498
+ this.chromePatterns = config.chromePatterns || [];
1499
+ /** @type {Record<string, string> | null | undefined} */
1500
+ this.reviewOptions = config.reviewOptions ?? null;
1501
+ /** @type {string} */
1502
+ this.envVar = config.envVar;
1503
+ /** @type {string} */
1504
+ this.approveKey = config.approveKey || "y";
1505
+ /** @type {string} */
1506
+ this.rejectKey = config.rejectKey || "n";
1507
+ /** @type {string | undefined} */
1508
+ this.safeAllowedTools = config.safeAllowedTools;
1509
+ }
1510
+
1511
+ /**
1512
+ * @param {boolean} [yolo]
1513
+ * @param {string | null} [sessionName]
1514
+ * @returns {string}
1515
+ */
1516
+ getCommand(yolo, sessionName = null) {
1517
+ let base;
1518
+ if (yolo) {
1519
+ base = this.yoloCommand;
1520
+ } else if (this.safeAllowedTools) {
1521
+ // Default: auto-approve safe read-only operations
1522
+ base = `${this.startCommand} --allowedTools "${this.safeAllowedTools}"`;
1523
+ } else {
1524
+ base = this.startCommand;
1525
+ }
1526
+ // Claude supports --session-id for deterministic session tracking
1527
+ if (this.name === "claude" && sessionName) {
1528
+ const parsed = parseSessionName(sessionName);
1529
+ if (parsed?.uuid) {
1530
+ return `${base} --session-id ${parsed.uuid}`;
1531
+ }
1532
+ }
1533
+ return base;
1534
+ }
1535
+
1536
+ getDefaultSession() {
1537
+ // Check env var for explicit session
1538
+ if (this.envVar && process.env[this.envVar]) {
1539
+ return process.env[this.envVar];
1540
+ }
1541
+
1542
+ const cwd = process.cwd();
1543
+ const childPattern = new RegExp(`^${this.name}-[0-9a-f-]{36}$`, "i");
1544
+
1545
+ // If inside tmux, look for existing agent session in same cwd
1546
+ const current = tmuxCurrentSession();
1547
+ if (current) {
1548
+ const sessions = tmuxListSessions();
1549
+ const existing = sessions.find((s) => {
1550
+ if (!childPattern.test(s)) return false;
1551
+ const sessionCwd = getTmuxSessionCwd(s);
1552
+ return sessionCwd === cwd;
1553
+ });
1554
+ if (existing) return existing;
1555
+ // No existing session in this cwd - will generate new one in cmdStart
1556
+ return null;
1557
+ }
1558
+
1559
+ // Walk up to find claude/codex ancestor and reuse its session (must match cwd)
1560
+ const callerPid = findCallerPid();
1561
+ if (callerPid) {
1562
+ const sessions = tmuxListSessions();
1563
+ const existing = sessions.find((s) => {
1564
+ if (!childPattern.test(s)) return false;
1565
+ const sessionCwd = getTmuxSessionCwd(s);
1566
+ return sessionCwd === cwd;
1567
+ });
1568
+ if (existing) return existing;
1569
+ }
1570
+
1571
+ // No existing session found
1572
+ return null;
1573
+ }
1574
+
1575
+ /**
1576
+ * @returns {string}
1577
+ */
1578
+ generateSession() {
1579
+ return generateSessionName(this.name);
1580
+ }
1581
+
1582
+ /**
1583
+ * Find the log file path for a session.
1584
+ * @param {string} sessionName
1585
+ * @returns {string | null}
1586
+ */
1587
+ findLogPath(sessionName) {
1588
+ const parsed = parseSessionName(sessionName);
1589
+ if (this.name === "claude") {
1590
+ const uuid = parsed?.uuid;
1591
+ if (uuid) return findClaudeLogPath(uuid, sessionName);
1592
+ }
1593
+ if (this.name === "codex") {
1594
+ return findCodexLogPath(sessionName);
1595
+ }
1596
+ return null;
1597
+ }
1598
+
1599
+ /**
1600
+ * @param {string} screen
1601
+ * @returns {string}
1602
+ */
1603
+ getState(screen) {
1604
+ return detectState(screen, {
1605
+ promptSymbol: this.promptSymbol,
1606
+ spinners: this.spinners,
1607
+ rateLimitPattern: this.rateLimitPattern,
1608
+ thinkingPatterns: this.thinkingPatterns,
1609
+ confirmPatterns: this.confirmPatterns,
1610
+ updatePromptPatterns: this.updatePromptPatterns,
1611
+ });
1612
+ }
1613
+
1614
+ /**
1615
+ * @param {string} screen
1616
+ * @returns {string}
1617
+ */
1618
+ parseRetryTime(screen) {
1619
+ const match = screen.match(/try again at ([0-9]{1,2}:[0-9]{2}\s*[AP]M)/i);
1620
+ return match ? match[1] : "unknown";
1621
+ }
1622
+
1623
+ /**
1624
+ * @param {string} screen
1625
+ * @returns {string}
1626
+ */
1627
+ parseAction(screen) {
1628
+ /** @param {string} s */
1629
+ // eslint-disable-next-line no-control-regex
1630
+ const clean = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").trim();
1631
+ const lines = screen.split("\n").map(clean);
1632
+
1633
+ for (const line of lines) {
1634
+ if (line.startsWith("$") || line.startsWith(">")) return line;
1635
+ if (/^(run|execute|create|delete|modify|write)/i.test(line)) return line;
1636
+ }
1637
+
1638
+ return lines.filter((l) => l && !l.match(/^[╭╮╰╯│─]+$/)).slice(0, 2).join(" | ") || "action";
1639
+ }
1640
+
1641
+ /**
1642
+ * @param {string} line
1643
+ * @returns {boolean}
1644
+ */
1645
+ isChromeLine(line) {
1646
+ const trimmed = line.trim();
1647
+ if (!trimmed) return true;
1648
+ // Box drawing characters only
1649
+ if (/^[╭╮╰╯│─┌┐└┘├┤┬┴┼\s]+$/.test(line)) return true;
1650
+ // Horizontal separators
1651
+ if (/^─{3,}$/.test(trimmed)) return true;
1652
+ // Status bar indicators (shortcuts help, connection status)
1653
+ if (/^\s*[?⧉◯●]\s/.test(line)) return true;
1654
+ // Logo/branding characters (block drawing)
1655
+ if (/[▐▛▜▌▝▘█▀▄]/.test(trimmed) && trimmed.length < 50) return true;
1656
+ // Version strings, model info
1657
+ if (/^(Claude Code|OpenAI Codex|Opus|gpt-|model:|directory:|cwd:)/i.test(trimmed)) return true;
1658
+ // Path-only lines (working directory display)
1659
+ if (/^~\/[^\s]*$/.test(trimmed)) return true;
1660
+ // Explicit chrome patterns from agent config
1661
+ if (this.chromePatterns.some((p) => trimmed.includes(p))) return true;
1662
+ return false;
1663
+ }
1664
+
1665
+ /**
1666
+ * @param {string} screen
1667
+ * @returns {string[]}
1668
+ */
1669
+ extractResponses(screen) {
1670
+ // eslint-disable-next-line no-control-regex
1671
+ const clean = screen.replace(/\x1b\[[0-9;]*m/g, "").replace(/\r/g, "");
1672
+ const lines = clean.split("\n");
1673
+ /** @type {string[]} */
1674
+ const responses = [];
1675
+ /** @type {string[]} */
1676
+ let current = [];
1677
+ let inResponse = false;
1678
+
1679
+ for (const line of lines) {
1680
+ // Skip chrome lines
1681
+ if (this.isChromeLine(line)) continue;
1682
+
1683
+ // User prompt marks end of previous response
1684
+ if (line.startsWith(this.promptSymbol)) {
1685
+ if (current.length) {
1686
+ responses.push(current.join("\n").trim());
1687
+ current = [];
1688
+ }
1689
+ inResponse = false;
1690
+ continue;
1691
+ }
1692
+
1693
+ // Response markers
1694
+ const isMarker = this.responseMarkers.some((m) => line.startsWith(m));
1695
+ if (isMarker) {
1696
+ if (!inResponse && current.length) {
1697
+ responses.push(current.join("\n").trim());
1698
+ current = [];
1699
+ }
1700
+ inResponse = true;
1701
+ current.push(line);
1702
+ } else if (inResponse && (line.startsWith(" ") || line.trim() === "")) {
1703
+ current.push(line);
1704
+ } else if (inResponse && line.trim()) {
1705
+ current.push(line);
1706
+ }
1707
+ }
1708
+ if (current.length) responses.push(current.join("\n").trim());
1709
+
1710
+ const filtered = responses.filter((r) => r.length > 0);
1711
+
1712
+ // Fallback: extract after last prompt
1713
+ if (filtered.length === 0) {
1714
+ const lastPromptIdx = lines.findLastIndex((/** @type {string} */ l) => l.startsWith(this.promptSymbol));
1715
+ if (lastPromptIdx >= 0 && lastPromptIdx < lines.length - 1) {
1716
+ const afterPrompt = lines
1717
+ .slice(lastPromptIdx + 1)
1718
+ .filter((/** @type {string} */ l) => !this.isChromeLine(l))
1719
+ .join("\n")
1720
+ .trim();
1721
+ if (afterPrompt) return [afterPrompt];
1722
+ }
1723
+
1724
+ // Second fallback: if the last prompt is empty (just ❯), look BEFORE it
1725
+ // This handles the case where Claude finished and shows a new empty prompt
1726
+ if (lastPromptIdx >= 0) {
1727
+ const lastPromptLine = lines[lastPromptIdx];
1728
+ const isEmptyPrompt = lastPromptLine.trim() === this.promptSymbol ||
1729
+ lastPromptLine.match(/^❯\s*$/);
1730
+ if (isEmptyPrompt) {
1731
+ // Find the previous prompt (user's input) and extract content between
1732
+ // Note: [Pasted text is Claude's truncated output indicator, NOT a prompt
1733
+ const prevPromptIdx = lines.slice(0, lastPromptIdx).findLastIndex(
1734
+ (/** @type {string} */ l) => l.startsWith(this.promptSymbol)
1735
+ );
1736
+ if (prevPromptIdx >= 0) {
1737
+ const betweenPrompts = lines
1738
+ .slice(prevPromptIdx + 1, lastPromptIdx)
1739
+ .filter((/** @type {string} */ l) => !this.isChromeLine(l))
1740
+ .join("\n")
1741
+ .trim();
1742
+ if (betweenPrompts) return [betweenPrompts];
1743
+ }
1744
+ }
1745
+ }
1746
+ }
1747
+
1748
+ return filtered;
1749
+ }
1750
+
1751
+ /**
1752
+ * @param {string} response
1753
+ * @returns {string}
1754
+ */
1755
+ cleanResponse(response) {
1756
+ return response
1757
+ // Remove tool call lines (Search, Read, Grep, etc.)
1758
+ .replace(/^[⏺•]\s*(Search|Read|Grep|Glob|Write|Edit|Bash)\([^)]*\).*$/gm, "")
1759
+ // Remove tool result lines
1760
+ .replace(/^⎿\s+.*$/gm, "")
1761
+ // Remove "Sautéed for Xs" timing lines
1762
+ .replace(/^✻\s+Sautéed for.*$/gm, "")
1763
+ // Remove expand hints
1764
+ .replace(/\(ctrl\+o to expand\)/g, "")
1765
+ // Clean up multiple blank lines
1766
+ .replace(/\n{3,}/g, "\n\n")
1767
+ // Original cleanup
1768
+ .replace(/^[•⏺-]\s*/, "")
1769
+ .replace(/^\*\*(.+)\*\*/, "$1")
1770
+ .replace(/\n /g, "\n")
1771
+ .replace(/─+\s*$/, "")
1772
+ .trim();
1773
+ }
1774
+
1775
+ /**
1776
+ * Get assistant response text, preferring JSONL log over screen scraping.
1777
+ * @param {string} session - tmux session name
1778
+ * @param {string} screen - captured screen content (fallback)
1779
+ * @param {number} [index=0] - 0 = last response, -1 = second-to-last, etc.
1780
+ * @returns {string | null}
1781
+ */
1782
+ getResponse(session, screen, index = 0) {
1783
+ // Try JSONL first (clean, no screen artifacts)
1784
+ const logPath = this.findLogPath(session);
1785
+ const jsonlText = logPath ? getAssistantText(logPath, index) : null;
1786
+ if (jsonlText) return jsonlText;
1787
+
1788
+ // Fallback to screen scraping
1789
+ const responses = this.extractResponses(screen);
1790
+ const i = responses.length - 1 + index;
1791
+ const response = responses[i];
1792
+ return response ? this.cleanResponse(response) : null;
1793
+ }
1794
+
1795
+ /**
1796
+ * @param {string} session
1797
+ */
1798
+ async handleUpdatePrompt(session) {
1799
+ // Default: skip update (send "2" then Enter)
1800
+ tmuxSend(session, "2");
1801
+ await sleep(300);
1802
+ tmuxSend(session, "Enter");
1803
+ await sleep(500);
1804
+ }
1805
+ }
1806
+
1807
+ // =============================================================================
1808
+ // CodexAgent
1809
+ // =============================================================================
1810
+
1811
+ const CodexAgent = new Agent({
1812
+ name: "codex",
1813
+ startCommand: "codex --sandbox read-only",
1814
+ yoloCommand: "codex --dangerously-bypass-approvals-and-sandbox",
1815
+ promptSymbol: "›",
1816
+ spinners: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
1817
+ rateLimitPattern: /■.*(?:usage limit|rate limit|try again at)/i,
1818
+ thinkingPatterns: ["Thinking…", "Thinking..."],
1819
+ confirmPatterns: [
1820
+ (lines) => lines.includes("[y]") && lines.includes("[n]"),
1821
+ "Run command?",
1822
+ (lines) => lines.includes("Allow") && lines.includes("Deny"),
1823
+ ],
1824
+ updatePromptPatterns: {
1825
+ screen: ["Update available"],
1826
+ lastLines: ["Skip"],
1827
+ },
1828
+ responseMarkers: ["•", "- ", "**"],
1829
+ chromePatterns: ["context left", "for shortcuts"],
1830
+ reviewOptions: { pr: "1", uncommitted: "2", commit: "3", custom: "4" },
1831
+ envVar: "AX_SESSION",
1832
+ });
1833
+
1834
+ // =============================================================================
1835
+ // ClaudeAgent
1836
+ // =============================================================================
1837
+
1838
+ const ClaudeAgent = new Agent({
1839
+ name: "claude",
1840
+ startCommand: "claude",
1841
+ yoloCommand: "claude --dangerously-skip-permissions",
1842
+ promptSymbol: "❯",
1843
+ spinners: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
1844
+ rateLimitPattern: /rate.?limit/i,
1845
+ thinkingPatterns: ["Thinking"],
1846
+ confirmPatterns: [
1847
+ "Do you want to make this edit",
1848
+ "Do you want to run this command",
1849
+ "Do you want to proceed",
1850
+ // Active menu: numbered options with Yes/No/Allow/Deny
1851
+ (lines) => /\d+\.\s*(Yes|No|Allow|Deny)/i.test(lines),
1852
+ ],
1853
+ updatePromptPatterns: null,
1854
+ responseMarkers: ["⏺", "•", "- ", "**"],
1855
+ chromePatterns: ["↵ send", "Esc to cancel", "shortcuts", "for more options", "docs.anthropic.com", "⏵⏵", "bypass permissions", "shift+Tab to cycle"],
1856
+ reviewOptions: null,
1857
+ safeAllowedTools: "Bash(git:*) Read Glob Grep", // Default: auto-approve read-only tools
1858
+ envVar: "AX_SESSION",
1859
+ approveKey: "1",
1860
+ rejectKey: "Escape",
1861
+ });
1862
+
1863
+ // =============================================================================
1864
+ // Commands
1865
+ // =============================================================================
1866
+
1867
+ /**
1868
+ * Wait until agent reaches a terminal state (ready, confirming, or rate limited).
1869
+ * Returns immediately if already in a terminal state.
1870
+ * @param {Agent} agent
1871
+ * @param {string} session
1872
+ * @param {number} [timeoutMs]
1873
+ * @returns {Promise<{state: string, screen: string}>}
1874
+ */
1875
+ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
1876
+ const start = Date.now();
1877
+ const initialScreen = tmuxCapture(session);
1878
+ const initialState = agent.getState(initialScreen);
1879
+
1880
+ // Already in terminal state
1881
+ if (initialState === State.RATE_LIMITED || initialState === State.CONFIRMING || initialState === State.READY) {
1882
+ return { state: initialState, screen: initialScreen };
1883
+ }
1884
+
1885
+ while (Date.now() - start < timeoutMs) {
1886
+ await sleep(POLL_MS);
1887
+ const screen = tmuxCapture(session);
1888
+ const state = agent.getState(screen);
1889
+
1890
+ if (state === State.RATE_LIMITED || state === State.CONFIRMING || state === State.READY) {
1891
+ return { state, screen };
1892
+ }
1893
+ }
1894
+ throw new Error("timeout");
1895
+ }
1896
+
1897
+ /**
1898
+ * Wait for agent to process a new message and respond.
1899
+ * Waits for screen activity before considering the response complete.
1900
+ * @param {Agent} agent
1901
+ * @param {string} session
1902
+ * @param {number} [timeoutMs]
1903
+ * @returns {Promise<{state: string, screen: string}>}
1904
+ */
1905
+ async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
1906
+ const start = Date.now();
1907
+ const initialScreen = tmuxCapture(session);
1908
+
1909
+ let lastScreen = initialScreen;
1910
+ let stableAt = null;
1911
+ let sawActivity = false;
1912
+
1913
+ while (Date.now() - start < timeoutMs) {
1914
+ await sleep(POLL_MS);
1915
+ const screen = tmuxCapture(session);
1916
+ const state = agent.getState(screen);
1917
+
1918
+ if (state === State.RATE_LIMITED || state === State.CONFIRMING) {
1919
+ return { state, screen };
1920
+ }
1921
+
1922
+ if (screen !== lastScreen) {
1923
+ lastScreen = screen;
1924
+ stableAt = Date.now();
1925
+ if (screen !== initialScreen) {
1926
+ sawActivity = true;
1927
+ }
1928
+ }
1929
+
1930
+ if (sawActivity && stableAt && Date.now() - stableAt >= STABLE_MS) {
1931
+ if (state === State.READY) {
1932
+ return { state, screen };
1933
+ }
1934
+ }
1935
+
1936
+ if (state === State.THINKING) {
1937
+ sawActivity = true;
1938
+ }
1939
+ }
1940
+ throw new Error("timeout");
1941
+ }
1942
+
1943
+ /**
1944
+ * Auto-approve loop that keeps approving confirmations until the agent is ready or rate limited.
1945
+ * Used by callers to implement yolo mode on sessions not started with native --yolo.
1946
+ * @param {Agent} agent
1947
+ * @param {string} session
1948
+ * @param {number} [timeoutMs]
1949
+ * @returns {Promise<{state: string, screen: string}>}
1950
+ */
1951
+ async function autoApproveLoop(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
1952
+ const deadline = Date.now() + timeoutMs;
1953
+
1954
+ while (Date.now() < deadline) {
1955
+ const remaining = deadline - Date.now();
1956
+ if (remaining <= 0) break;
1957
+
1958
+ const { state, screen } = await waitForResponse(agent, session, remaining);
1959
+
1960
+ if (state === State.RATE_LIMITED || state === State.READY) {
1961
+ return { state, screen };
1962
+ }
1963
+
1964
+ if (state === State.CONFIRMING) {
1965
+ tmuxSend(session, agent.approveKey);
1966
+ await sleep(APPROVE_DELAY_MS);
1967
+ continue;
1968
+ }
1969
+
1970
+ // Unexpected state - log and continue polling
1971
+ debugError("autoApproveLoop", new Error(`unexpected state: ${state}`));
1972
+ }
1973
+
1974
+ throw new Error("timeout");
1975
+ }
1976
+
1977
+ /**
1978
+ * @param {Agent} agent
1979
+ * @param {string | null | undefined} session
1980
+ * @param {Object} [options]
1981
+ * @param {boolean} [options.yolo]
1982
+ * @returns {Promise<string>}
1983
+ */
1984
+ async function cmdStart(agent, session, { yolo = false } = {}) {
1985
+ // Generate session name if not provided
1986
+ if (!session) {
1987
+ session = agent.generateSession();
1988
+ }
1989
+
1990
+ if (tmuxHasSession(session)) return session;
1991
+
1992
+ // Check agent CLI is installed before trying to start
1993
+ const cliCheck = spawnSync("which", [agent.name], { encoding: "utf-8" });
1994
+ if (cliCheck.status !== 0) {
1995
+ console.error(`ERROR: ${agent.name} CLI is not installed or not in PATH`);
1996
+ process.exit(1);
1997
+ }
1998
+
1999
+ const command = agent.getCommand(yolo, session);
2000
+ tmuxNewSession(session, command);
2001
+
2002
+ const start = Date.now();
2003
+ while (Date.now() - start < STARTUP_TIMEOUT_MS) {
2004
+ const screen = tmuxCapture(session);
2005
+ const state = agent.getState(screen);
2006
+
2007
+ if (state === State.UPDATE_PROMPT) {
2008
+ await agent.handleUpdatePrompt(session);
2009
+ continue;
2010
+ }
2011
+
2012
+ if (state === State.READY) return session;
2013
+
2014
+ await sleep(POLL_MS);
2015
+ }
2016
+
2017
+ console.log("ERROR: timeout");
2018
+ process.exit(1);
2019
+ }
2020
+
2021
+ // =============================================================================
2022
+ // Command: agents
2023
+ // =============================================================================
2024
+
2025
+ function cmdAgents() {
2026
+ const allSessions = tmuxListSessions();
2027
+
2028
+ // Filter to agent sessions (claude-uuid or codex-uuid format)
2029
+ const agentSessions = allSessions.filter((s) => parseSessionName(s));
2030
+
2031
+ if (agentSessions.length === 0) {
2032
+ console.log("No agents running");
2033
+ return;
2034
+ }
2035
+
2036
+ // Get info for each agent
2037
+ const agents = agentSessions.map((session) => {
2038
+ const parsed = /** @type {ParsedSession} */ (parseSessionName(session));
2039
+ const agent = parsed.tool === "claude" ? ClaudeAgent : CodexAgent;
2040
+ const screen = tmuxCapture(session);
2041
+ const state = agent.getState(screen);
2042
+ const logPath = agent.findLogPath(session);
2043
+ const type = parsed.daemonName ? "daemon" : "-";
2044
+
2045
+ return {
2046
+ session,
2047
+ tool: parsed.tool,
2048
+ state: state || "unknown",
2049
+ type,
2050
+ log: logPath || "-",
2051
+ };
2052
+ });
2053
+
2054
+ // Print table
2055
+ const maxSession = Math.max(7, ...agents.map((a) => a.session.length));
2056
+ const maxTool = Math.max(4, ...agents.map((a) => a.tool.length));
2057
+ const maxState = Math.max(5, ...agents.map((a) => a.state.length));
2058
+ const maxType = Math.max(4, ...agents.map((a) => a.type.length));
2059
+
2060
+ console.log(
2061
+ `${"SESSION".padEnd(maxSession)} ${"TOOL".padEnd(maxTool)} ${"STATE".padEnd(maxState)} ${"TYPE".padEnd(maxType)} LOG`
2062
+ );
2063
+ for (const a of agents) {
2064
+ console.log(
2065
+ `${a.session.padEnd(maxSession)} ${a.tool.padEnd(maxTool)} ${a.state.padEnd(maxState)} ${a.type.padEnd(maxType)} ${a.log}`
2066
+ );
2067
+ }
2068
+ }
2069
+
2070
+ // =============================================================================
2071
+ // Command: daemons
2072
+ // =============================================================================
2073
+
2074
+ /**
2075
+ * @param {string} pattern
2076
+ * @returns {string | undefined}
2077
+ */
2078
+ function findDaemonSession(pattern) {
2079
+ const sessions = tmuxListSessions();
2080
+ return sessions.find((s) => s.startsWith(pattern));
2081
+ }
2082
+
2083
+ /**
2084
+ * @param {DaemonConfig} config
2085
+ * @returns {string}
2086
+ */
2087
+ function generateDaemonSessionName(config) {
2088
+ return `${config.tool}-daemon-${config.name}-${randomUUID()}`;
2089
+ }
2090
+
2091
+ /**
2092
+ * @param {DaemonConfig} config
2093
+ * @param {ParentSession | null} [parentSession]
2094
+ */
2095
+ function startDaemonAgent(config, parentSession = null) {
2096
+ // Build environment with parent session info if available
2097
+ /** @type {NodeJS.ProcessEnv} */
2098
+ const env = { ...process.env };
2099
+ if (parentSession?.uuid) {
2100
+ // Session name may be null for non-tmux sessions, but uuid is required
2101
+ if (parentSession.session) {
2102
+ env[AX_DAEMON_PARENT_SESSION_ENV] = parentSession.session;
2103
+ }
2104
+ env[AX_DAEMON_PARENT_UUID_ENV] = parentSession.uuid;
2105
+ }
2106
+
2107
+ // Spawn ax.js daemon <name> as a detached background process
2108
+ const child = spawn("node", [process.argv[1], "daemon", config.name], {
2109
+ detached: true,
2110
+ stdio: "ignore",
2111
+ cwd: process.cwd(),
2112
+ env,
2113
+ });
2114
+ child.unref();
2115
+ console.log(`Starting daemon: ${config.name} (pid ${child.pid})${parentSession ? ` [parent: ${parentSession.session}]` : ""}`);
2116
+ }
2117
+
2118
+ // =============================================================================
2119
+ // Command: daemon (runs as the daemon process itself)
2120
+ // =============================================================================
2121
+
2122
+ /**
2123
+ * @param {string | undefined} agentName
2124
+ */
2125
+ async function cmdDaemon(agentName) {
2126
+ if (!agentName) {
2127
+ console.error("Usage: ./ax.js daemon <name>");
2128
+ process.exit(1);
2129
+ }
2130
+ // Load agent config
2131
+ const configPath = path.join(AGENTS_DIR, `${agentName}.md`);
2132
+ if (!existsSync(configPath)) {
2133
+ console.error(`[daemon:${agentName}] Config not found: ${configPath}`);
2134
+ process.exit(1);
2135
+ }
2136
+
2137
+ const content = readFileSync(configPath, "utf-8");
2138
+ const configResult = parseAgentConfig(`${agentName}.md`, content);
2139
+ if (!configResult || "error" in configResult) {
2140
+ console.error(`[daemon:${agentName}] Invalid config`);
2141
+ process.exit(1);
2142
+ }
2143
+ const config = configResult;
2144
+
2145
+ const agent = config.tool === "claude" ? ClaudeAgent : CodexAgent;
2146
+ const sessionName = generateDaemonSessionName(config);
2147
+
2148
+ // Check agent CLI is installed before trying to start
2149
+ const cliCheck = spawnSync("which", [agent.name], { encoding: "utf-8" });
2150
+ if (cliCheck.status !== 0) {
2151
+ console.error(`[daemon:${agentName}] ERROR: ${agent.name} CLI is not installed or not in PATH`);
2152
+ process.exit(1);
2153
+ }
2154
+
2155
+ // Start the agent session with safe defaults (auto-approve read-only operations)
2156
+ const command = agent.getCommand(false, sessionName);
2157
+ tmuxNewSession(sessionName, command);
2158
+
2159
+ // Wait for agent to be ready
2160
+ const start = Date.now();
2161
+ while (Date.now() - start < DAEMON_STARTUP_TIMEOUT_MS) {
2162
+ const screen = tmuxCapture(sessionName);
2163
+ const state = agent.getState(screen);
2164
+
2165
+ if (state === State.UPDATE_PROMPT) {
2166
+ await agent.handleUpdatePrompt(sessionName);
2167
+ continue;
2168
+ }
2169
+
2170
+ // Handle bypass permissions confirmation dialog (Claude Code shows this for --dangerously-skip-permissions)
2171
+ if (screen.includes("Bypass Permissions mode") && screen.includes("Yes, I accept")) {
2172
+ console.log(`[daemon:${agentName}] Accepting bypass permissions dialog`);
2173
+ tmuxSend(sessionName, "2"); // Select "Yes, I accept"
2174
+ await sleep(300);
2175
+ tmuxSend(sessionName, "Enter");
2176
+ await sleep(500);
2177
+ continue;
2178
+ }
2179
+
2180
+ if (state === State.READY) {
2181
+ console.log(`[daemon:${agentName}] Started session: ${sessionName}`);
2182
+ break;
2183
+ }
2184
+
2185
+ await sleep(POLL_MS);
2186
+ }
2187
+
2188
+ // Load the base prompt from config
2189
+ const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2190
+ const promptMatch = normalized.match(/^---[\s\S]*?---\n([\s\S]*)$/);
2191
+ const basePrompt = promptMatch ? promptMatch[1].trim() : "Review for issues.";
2192
+
2193
+ // File watching state
2194
+ /** @type {Set<string>} */
2195
+ let changedFiles = new Set();
2196
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
2197
+ let debounceTimer = undefined;
2198
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
2199
+ let maxWaitTimer = undefined;
2200
+ let isProcessing = false;
2201
+ const intervalMs = config.interval * 1000;
2202
+
2203
+ async function processChanges() {
2204
+ clearTimeout(debounceTimer);
2205
+ clearTimeout(maxWaitTimer);
2206
+ debounceTimer = undefined;
2207
+ maxWaitTimer = undefined;
2208
+
2209
+ if (changedFiles.size === 0 || isProcessing) return;
2210
+ isProcessing = true;
2211
+
2212
+ const files = [...changedFiles];
2213
+ changedFiles = new Set(); // atomic swap to avoid losing changes during processing
2214
+
2215
+ try {
2216
+ // Get parent session log path for JSONL extraction
2217
+ const parent = findParentSession();
2218
+ const logPath = parent ? findClaudeLogPath(parent.uuid, parent.session) : null;
2219
+
2220
+ // Build file-specific context from JSONL
2221
+ const fileContexts = [];
2222
+ for (const file of files.slice(0, 5)) { // Limit to 5 files
2223
+ const ctx = extractFileEditContext(logPath, file);
2224
+ if (ctx) {
2225
+ fileContexts.push({ file, ...ctx });
2226
+ }
2227
+ }
2228
+
2229
+ // Build the prompt
2230
+ let prompt = basePrompt;
2231
+
2232
+ if (fileContexts.length > 0) {
2233
+ prompt += "\n\n## Recent Edits (from parent session)\n";
2234
+
2235
+ for (const ctx of fileContexts) {
2236
+ prompt += `\n### ${ctx.file}\n`;
2237
+ prompt += `**Intent:** ${ctx.intent.slice(0, 500)}\n`;
2238
+ prompt += `**Action:** ${ctx.toolCall.name}\n`;
2239
+
2240
+ if (ctx.editSequence > 1) {
2241
+ prompt += `**Note:** This is edit #${ctx.editSequence} to this file (refinement)\n`;
2242
+ }
2243
+
2244
+ if (ctx.subsequentErrors.length > 0) {
2245
+ prompt += `**Errors after:** ${ctx.subsequentErrors[0].slice(0, 200)}\n`;
2246
+ }
2247
+
2248
+ if (ctx.readsBefore.length > 0) {
2249
+ const reads = ctx.readsBefore.map(f => f.split("/").pop()).join(", ");
2250
+ prompt += `**Files read before:** ${reads}\n`;
2251
+ }
2252
+ }
2253
+
2254
+ prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
2255
+
2256
+ const gitContext = buildGitContext(DAEMON_GIT_CONTEXT_HOURS, DAEMON_GIT_CONTEXT_MAX_LINES);
2257
+ if (gitContext) {
2258
+ prompt += "\n\n## Git Context\n\n" + gitContext;
2259
+ }
2260
+
2261
+ prompt += '\n\nReview these changes in the context of what the user is working on. Report any issues found. Keep your response concise.\nIf there are no significant issues, respond with just "No issues found."';
2262
+ } else {
2263
+ // Fallback: no JSONL context available, use conversation + git context
2264
+ const parentContext = getParentSessionContext(DAEMON_PARENT_CONTEXT_ENTRIES);
2265
+ const gitContext = buildGitContext(DAEMON_GIT_CONTEXT_HOURS, DAEMON_GIT_CONTEXT_MAX_LINES);
2266
+
2267
+ if (parentContext) {
2268
+ prompt += "\n\n## Main Session Context\n\nThe user is currently working on:\n\n" + parentContext;
2269
+ }
2270
+
2271
+ prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
2272
+
2273
+ if (gitContext) {
2274
+ prompt += "\n\n## Git Context\n\n" + gitContext;
2275
+ }
2276
+
2277
+ prompt += '\n\nReview these changes in the context of what the user is working on. Report any issues found. Keep your response concise.\nIf there are no significant issues, respond with just "No issues found."';
2278
+ }
2279
+
2280
+
2281
+ // Check session still exists
2282
+ if (!tmuxHasSession(sessionName)) {
2283
+ console.log(`[daemon:${agentName}] Session gone, exiting`);
2284
+ process.exit(0);
2285
+ }
2286
+
2287
+ // Wait for ready
2288
+ const screen = tmuxCapture(sessionName);
2289
+ const state = agent.getState(screen);
2290
+
2291
+ if (state === State.RATE_LIMITED) {
2292
+ console.error(`[daemon:${agentName}] Rate limited - stopping`);
2293
+ process.exit(2);
2294
+ }
2295
+
2296
+ if (state !== State.READY) {
2297
+ console.log(`[daemon:${agentName}] Agent not ready (${state}), skipping`);
2298
+ isProcessing = false;
2299
+ return;
2300
+ }
2301
+
2302
+ // Send prompt
2303
+ tmuxSendLiteral(sessionName, prompt);
2304
+ await sleep(200); // Allow time for large prompts to be processed
2305
+ tmuxSend(sessionName, "Enter");
2306
+ await sleep(100); // Ensure Enter is processed
2307
+
2308
+ // Wait for response
2309
+ const { state: endState, screen: afterScreen } = await waitForResponse(agent, sessionName, DAEMON_RESPONSE_TIMEOUT_MS);
2310
+
2311
+ if (endState === State.RATE_LIMITED) {
2312
+ console.error(`[daemon:${agentName}] Rate limited - stopping`);
2313
+ process.exit(2);
2314
+ }
2315
+
2316
+
2317
+ const cleanedResponse = agent.getResponse(sessionName, afterScreen) || "";
2318
+
2319
+ // Sanity check: skip garbage responses (screen scraping artifacts)
2320
+ const isGarbage = cleanedResponse.includes("[Pasted text") ||
2321
+ cleanedResponse.match(/^\+\d+ lines\]/) ||
2322
+ cleanedResponse.length < 20;
2323
+
2324
+ if (cleanedResponse && !isGarbage && !cleanedResponse.toLowerCase().includes("no issues found")) {
2325
+ writeToMailbox({
2326
+ agent: /** @type {string} */ (agentName),
2327
+ session: sessionName,
2328
+ branch: getCurrentBranch(),
2329
+ commit: getCurrentCommit(),
2330
+ files,
2331
+ message: cleanedResponse.slice(0, 1000),
2332
+ });
2333
+ console.log(`[daemon:${agentName}] Wrote observation for ${files.length} file(s)`);
2334
+ } else if (isGarbage) {
2335
+ console.log(`[daemon:${agentName}] Skipped garbage response`);
2336
+ }
2337
+ } catch (err) {
2338
+ console.error(`[daemon:${agentName}] Error:`, err instanceof Error ? err.message : err);
2339
+ }
2340
+
2341
+ isProcessing = false;
2342
+ }
2343
+
2344
+ function scheduleProcessChanges() {
2345
+ processChanges().catch((err) => {
2346
+ console.error(`[daemon:${agentName}] Unhandled error:`, err instanceof Error ? err.message : err);
2347
+ });
2348
+ }
2349
+
2350
+ // Set up file watching
2351
+ const stopWatching = watchForChanges(config.watch, (filePath) => {
2352
+ changedFiles.add(filePath);
2353
+
2354
+ // Debounce: reset timer on each change
2355
+ clearTimeout(debounceTimer);
2356
+ debounceTimer = setTimeout(scheduleProcessChanges, intervalMs);
2357
+
2358
+ // Max wait: force trigger after 5x interval to prevent starvation
2359
+ if (!maxWaitTimer) {
2360
+ maxWaitTimer = setTimeout(scheduleProcessChanges, intervalMs * 5);
2361
+ }
2362
+ });
2363
+
2364
+ // Check if session still exists periodically
2365
+ const sessionCheck = setInterval(() => {
2366
+ if (!tmuxHasSession(sessionName)) {
2367
+ console.log(`[daemon:${agentName}] Session gone, exiting`);
2368
+ stopWatching();
2369
+ clearInterval(sessionCheck);
2370
+ process.exit(0);
2371
+ }
2372
+ }, DAEMON_HEALTH_CHECK_MS);
2373
+
2374
+ // Handle graceful shutdown
2375
+ process.on("SIGTERM", () => {
2376
+ console.log(`[daemon:${agentName}] Received SIGTERM, shutting down`);
2377
+ stopWatching();
2378
+ clearInterval(sessionCheck);
2379
+ tmuxSend(sessionName, "C-c");
2380
+ setTimeout(() => {
2381
+ tmuxKill(sessionName);
2382
+ process.exit(0);
2383
+ }, 500);
2384
+ });
2385
+
2386
+ process.on("SIGINT", () => {
2387
+ console.log(`[daemon:${agentName}] Received SIGINT, shutting down`);
2388
+ stopWatching();
2389
+ clearInterval(sessionCheck);
2390
+ tmuxSend(sessionName, "C-c");
2391
+ setTimeout(() => {
2392
+ tmuxKill(sessionName);
2393
+ process.exit(0);
2394
+ }, 500);
2395
+ });
2396
+
2397
+ console.log(`[daemon:${agentName}] Watching: ${config.watch.join(", ")}`);
2398
+
2399
+ // Keep the process alive
2400
+ await new Promise(() => {});
2401
+ }
2402
+
2403
+ /**
2404
+ * @param {string} action
2405
+ * @param {string | null} [daemonName]
2406
+ */
2407
+ async function cmdDaemons(action, daemonName = null) {
2408
+ if (action !== "start" && action !== "stop" && action !== "init") {
2409
+ console.log("Usage: ./ax.js daemons <start|stop|init> [name]");
2410
+ process.exit(1);
2411
+ }
2412
+
2413
+ // Handle init action separately
2414
+ if (action === "init") {
2415
+ if (!daemonName) {
2416
+ console.log("Usage: ./ax.js daemons init <name>");
2417
+ console.log("Example: ./ax.js daemons init reviewer");
2418
+ process.exit(1);
2419
+ }
2420
+
2421
+ // Validate name (alphanumeric, dashes, underscores only)
2422
+ if (!/^[a-zA-Z0-9_-]+$/.test(daemonName)) {
2423
+ console.log("ERROR: Daemon name must contain only letters, numbers, dashes, and underscores");
2424
+ process.exit(1);
2425
+ }
2426
+
2427
+ const agentPath = path.join(AGENTS_DIR, `${daemonName}.md`);
2428
+ if (existsSync(agentPath)) {
2429
+ console.log(`ERROR: Agent config already exists: ${agentPath}`);
2430
+ process.exit(1);
2431
+ }
2432
+
2433
+ // Create agents directory if needed
2434
+ if (!existsSync(AGENTS_DIR)) {
2435
+ mkdirSync(AGENTS_DIR, { recursive: true });
2436
+ }
2437
+
2438
+ const template = `---
2439
+ tool: claude
2440
+ watch: ["**/*.{ts,tsx,js,jsx,mjs,mts}"]
2441
+ interval: 30
2442
+ ---
2443
+
2444
+ Review changed files for bugs, type errors, and edge cases.
2445
+ `;
2446
+
2447
+ writeFileSync(agentPath, template);
2448
+ console.log(`Created agent config: ${agentPath}`);
2449
+ console.log(`Edit the file to customize the daemon, then run: ./ax.js daemons start ${daemonName}`);
2450
+ return;
2451
+ }
2452
+
2453
+ const configs = loadAgentConfigs();
2454
+
2455
+ if (configs.length === 0) {
2456
+ console.log(`No agent configs found in ${AGENTS_DIR}/`);
2457
+ return;
2458
+ }
2459
+
2460
+ // Filter to specific daemon if name provided
2461
+ const targetConfigs = daemonName
2462
+ ? configs.filter((c) => c.name === daemonName)
2463
+ : configs;
2464
+
2465
+ if (daemonName && targetConfigs.length === 0) {
2466
+ console.log(`ERROR: daemon '${daemonName}' not found in ${AGENTS_DIR}/`);
2467
+ process.exit(1);
2468
+ }
2469
+
2470
+ // Ensure hook script exists on start
2471
+ if (action === "start") {
2472
+ ensureMailboxHookScript();
2473
+ }
2474
+
2475
+ // Find current Claude session to pass as parent (if we're inside one)
2476
+ const parentSession = action === "start" ? findCurrentClaudeSession() : null;
2477
+ if (action === "start") {
2478
+ if (parentSession) {
2479
+ console.log(`Parent session: ${parentSession.session || "(non-tmux)"} [${parentSession.uuid}]`);
2480
+ } else {
2481
+ console.log("Parent session: null (not running from Claude or no active sessions)");
2482
+ }
2483
+ }
2484
+
2485
+ for (const config of targetConfigs) {
2486
+ const sessionPattern = getDaemonSessionPattern(config);
2487
+ const existing = findDaemonSession(sessionPattern);
2488
+
2489
+ if (action === "stop") {
2490
+ if (existing) {
2491
+ tmuxSend(existing, "C-c");
2492
+ await sleep(300);
2493
+ tmuxKill(existing);
2494
+ console.log(`Stopped daemon: ${config.name} (${existing})`);
2495
+ } else {
2496
+ console.log(`Daemon not running: ${config.name}`);
2497
+ }
2498
+ } else if (action === "start") {
2499
+ if (!existing) {
2500
+ startDaemonAgent(config, parentSession);
2501
+ } else {
2502
+ console.log(`Daemon already running: ${config.name} (${existing})`);
2503
+ }
2504
+ }
2505
+ }
2506
+
2507
+ // GC mailbox on start
2508
+ if (action === "start") {
2509
+ gcMailbox(24);
2510
+ }
2511
+ }
2512
+
2513
+ // Version of the hook script template - bump when making changes
2514
+ const HOOK_SCRIPT_VERSION = "2";
2515
+
2516
+ function ensureMailboxHookScript() {
2517
+ const hooksDir = HOOKS_DIR;
2518
+ const scriptPath = path.join(hooksDir, "mailbox-inject.js");
2519
+ const versionMarker = `// VERSION: ${HOOK_SCRIPT_VERSION}`;
2520
+
2521
+ // Check if script exists and is current version
2522
+ if (existsSync(scriptPath)) {
2523
+ const existing = readFileSync(scriptPath, "utf-8");
2524
+ if (existing.includes(versionMarker)) return;
2525
+ // Outdated version, regenerate
2526
+ }
2527
+
2528
+ if (!existsSync(hooksDir)) {
2529
+ mkdirSync(hooksDir, { recursive: true });
2530
+ }
2531
+
2532
+ // Inject absolute paths into the generated script
2533
+ const mailboxPath = path.join(AI_DIR, "mailbox.jsonl");
2534
+ const lastSeenPath = path.join(AI_DIR, "mailbox-last-seen");
2535
+
2536
+ const hookCode = `#!/usr/bin/env node
2537
+ ${versionMarker}
2538
+ // Auto-generated hook script - do not edit manually
2539
+
2540
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2541
+
2542
+ const DEBUG = process.env.AX_DEBUG === "1";
2543
+ const MAILBOX = "${mailboxPath}";
2544
+ const LAST_SEEN = "${lastSeenPath}";
2545
+ const MAX_AGE_MS = 60 * 60 * 1000; // 1 hour (matches MAILBOX_MAX_AGE_MS)
2546
+
2547
+ if (!existsSync(MAILBOX)) process.exit(0);
2548
+
2549
+ // Note: commit filtering removed - age + lastSeen is sufficient
2550
+
2551
+ // Read last seen timestamp
2552
+ let lastSeen = 0;
2553
+ try {
2554
+ if (existsSync(LAST_SEEN)) {
2555
+ lastSeen = parseInt(readFileSync(LAST_SEEN, "utf-8").trim(), 10) || 0;
2556
+ }
2557
+ } catch (err) {
2558
+ if (DEBUG) console.error("[hook] readLastSeen:", err.message);
2559
+ }
2560
+
2561
+ const now = Date.now();
2562
+ const lines = readFileSync(MAILBOX, "utf-8").trim().split("\\n").filter(Boolean);
2563
+ const relevant = [];
2564
+
2565
+ for (const line of lines) {
2566
+ try {
2567
+ const entry = JSON.parse(line);
2568
+ const ts = new Date(entry.timestamp).getTime();
2569
+ const age = now - ts;
2570
+
2571
+ // Only show observations within max age and not yet seen
2572
+ // (removed commit filter - too strict when HEAD moves during a session)
2573
+ if (age < MAX_AGE_MS && ts > lastSeen) {
2574
+ // Extract session prefix (without UUID) for shorter log command
2575
+ const session = entry.payload.session || "";
2576
+ const sessionPrefix = session.replace(/-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "");
2577
+ relevant.push({ agent: entry.payload.agent, sessionPrefix, message: entry.payload.message });
2578
+ }
2579
+ } catch (err) {
2580
+ if (DEBUG) console.error("[hook] parseLine:", err.message);
2581
+ }
2582
+ }
2583
+
2584
+ if (relevant.length > 0) {
2585
+ console.log("## Background Agents");
2586
+ console.log("");
2587
+ console.log("Background agents watching your files found:");
2588
+ console.log("");
2589
+ const sessionPrefixes = new Set();
2590
+ for (const { agent, sessionPrefix, message } of relevant) {
2591
+ if (sessionPrefix) sessionPrefixes.add(sessionPrefix);
2592
+ console.log("**[" + agent + "]**");
2593
+ console.log("");
2594
+ console.log(message);
2595
+ console.log("");
2596
+ }
2597
+ const sessionList = [...sessionPrefixes].map(s => "\\\`./ax.js log " + s + "\\\`").join(" or ");
2598
+ console.log("> For more context: \\\`./ax.js mailbox\\\`" + (sessionList ? " or " + sessionList : ""));
2599
+ // Update last seen timestamp
2600
+ writeFileSync(LAST_SEEN, now.toString());
2601
+ }
2602
+
2603
+ process.exit(0);
2604
+ `;
2605
+
2606
+ writeFileSync(scriptPath, hookCode);
2607
+ console.log(`Generated hook script: ${scriptPath}`);
2608
+
2609
+ // Configure the hook in .claude/settings.json at the same time
2610
+ const configuredHook = ensureClaudeHookConfig();
2611
+ if (!configuredHook) {
2612
+ const hookScriptPath = path.join(HOOKS_DIR, "mailbox-inject.js");
2613
+ console.log(`\nTo enable manually, add to .claude/settings.json:\n`);
2614
+ console.log(`{
2615
+ "hooks": {
2616
+ "UserPromptSubmit": [
2617
+ {
2618
+ "matcher": "",
2619
+ "hooks": [
2620
+ {
2621
+ "type": "command",
2622
+ "command": "node ${hookScriptPath}",
2623
+ "timeout": 5
2624
+ }
2625
+ ]
2626
+ }
2627
+ ]
2628
+ }
2629
+ }`);
2630
+ }
2631
+ }
2632
+
2633
+ function ensureClaudeHookConfig() {
2634
+ const settingsDir = ".claude";
2635
+ const settingsPath = path.join(settingsDir, "settings.json");
2636
+ const hookScriptPath = path.join(HOOKS_DIR, "mailbox-inject.js");
2637
+ const hookCommand = `node ${hookScriptPath}`;
2638
+
2639
+ try {
2640
+ /** @type {ClaudeSettings} */
2641
+ let settings = {};
2642
+
2643
+ // Load existing settings if present
2644
+ if (existsSync(settingsPath)) {
2645
+ const content = readFileSync(settingsPath, "utf-8");
2646
+ settings = JSON.parse(content);
2647
+ } else {
2648
+ // Create directory if needed
2649
+ if (!existsSync(settingsDir)) {
2650
+ mkdirSync(settingsDir, { recursive: true });
2651
+ }
2652
+ }
2653
+
2654
+ // Ensure hooks structure exists
2655
+ if (!settings.hooks) settings.hooks = {};
2656
+ if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
2657
+
2658
+ // Check if our hook is already configured
2659
+ const hookExists = settings.hooks.UserPromptSubmit.some(
2660
+ /** @param {{hooks?: Array<{command: string}>}} entry */
2661
+ (entry) => entry.hooks?.some(/** @param {{command: string}} h */ (h) => h.command === hookCommand)
2662
+ );
2663
+
2664
+ if (hookExists) {
2665
+ return true; // Already configured
2666
+ }
2667
+
2668
+ // Add the hook
2669
+ settings.hooks.UserPromptSubmit.push({
2670
+ matcher: "",
2671
+ hooks: [
2672
+ {
2673
+ type: "command",
2674
+ command: hookCommand,
2675
+ timeout: 5,
2676
+ },
2677
+ ],
2678
+ });
2679
+
2680
+ // Write settings
2681
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2682
+ console.log(`Configured hook in: ${settingsPath}`);
2683
+ return true;
2684
+ } catch {
2685
+ // If we can't configure automatically, return false so manual instructions are shown
2686
+ return false;
2687
+ }
2688
+ }
2689
+
2690
+ /**
2691
+ * @param {string | null | undefined} session
2692
+ * @param {{all?: boolean}} [options]
2693
+ */
2694
+ function cmdKill(session, { all = false } = {}) {
2695
+ // If specific session provided, kill just that one
2696
+ if (session) {
2697
+ if (!tmuxHasSession(session)) {
2698
+ console.log("ERROR: session not found");
2699
+ process.exit(1);
2700
+ }
2701
+ tmuxKill(session);
2702
+ console.log(`Killed: ${session}`);
2703
+ return;
2704
+ }
2705
+
2706
+ const allSessions = tmuxListSessions();
2707
+ const agentSessions = allSessions.filter((s) => parseSessionName(s));
2708
+
2709
+ if (agentSessions.length === 0) {
2710
+ console.log("No agents running");
2711
+ return;
2712
+ }
2713
+
2714
+ // Filter to current project unless --all specified
2715
+ let sessionsToKill = agentSessions;
2716
+ if (!all) {
2717
+ const currentProject = PROJECT_ROOT;
2718
+ sessionsToKill = agentSessions.filter((s) => {
2719
+ const cwd = getTmuxSessionCwd(s);
2720
+ return cwd && cwd.startsWith(currentProject);
2721
+ });
2722
+
2723
+ if (sessionsToKill.length === 0) {
2724
+ console.log(`No agents running in ${currentProject}`);
2725
+ console.log(`(Use --all to kill all ${agentSessions.length} agent(s) across all projects)`);
2726
+ return;
2727
+ }
2728
+ }
2729
+
2730
+ for (const s of sessionsToKill) {
2731
+ tmuxKill(s);
2732
+ console.log(`Killed: ${s}`);
2733
+ }
2734
+ console.log(`Killed ${sessionsToKill.length} agent(s)`);
2735
+ }
2736
+
2737
+ /**
2738
+ * @param {string | null | undefined} session
2739
+ */
2740
+ function cmdAttach(session) {
2741
+ if (!session) {
2742
+ console.log("ERROR: no session specified. Run 'agents' to list sessions.");
2743
+ process.exit(1);
2744
+ }
2745
+
2746
+ // Resolve partial session name
2747
+ const resolved = resolveSessionName(session);
2748
+ if (!resolved || !tmuxHasSession(resolved)) {
2749
+ console.log("ERROR: session not found");
2750
+ process.exit(1);
2751
+ }
2752
+
2753
+ // Hand over to tmux attach
2754
+ const result = spawnSync("tmux", ["attach", "-t", resolved], { stdio: "inherit" });
2755
+ process.exit(result.status || 0);
2756
+ }
2757
+
2758
+ /**
2759
+ * @param {string | null | undefined} sessionName
2760
+ * @param {{tail?: number, reasoning?: boolean, follow?: boolean}} [options]
2761
+ */
2762
+ function cmdLog(sessionName, { tail = 50, reasoning = false, follow = false } = {}) {
2763
+ if (!sessionName) {
2764
+ console.log("ERROR: no session specified. Run 'agents' to list sessions.");
2765
+ process.exit(1);
2766
+ }
2767
+
2768
+ // Resolve partial session name
2769
+ const resolved = resolveSessionName(sessionName);
2770
+ if (!resolved) {
2771
+ console.log("ERROR: session not found");
2772
+ process.exit(1);
2773
+ }
2774
+ const parsed = parseSessionName(resolved);
2775
+ if (!parsed) {
2776
+ console.log("ERROR: invalid session name");
2777
+ process.exit(1);
2778
+ }
2779
+
2780
+ const agent = parsed.tool === "claude" ? ClaudeAgent : CodexAgent;
2781
+ const logPath = agent.findLogPath(resolved);
2782
+ if (!logPath || !existsSync(logPath)) {
2783
+ console.log("ERROR: log file not found");
2784
+ process.exit(1);
2785
+ }
2786
+
2787
+ const displayName = resolved;
2788
+
2789
+ // Print initial content
2790
+ let lastLineCount = 0;
2791
+ /** @type {string | null} */
2792
+ let lastTimestamp = null;
2793
+
2794
+ /**
2795
+ * @param {boolean} [isInitial]
2796
+ */
2797
+ function printLog(isInitial = false) {
2798
+ const content = readFileSync(/** @type {string} */ (logPath), "utf-8");
2799
+ const lines = content.trim().split("\n").filter(Boolean);
2800
+
2801
+ // Handle log rotation: if file was truncated, reset our position
2802
+ if (lines.length < lastLineCount) {
2803
+ lastLineCount = 0;
2804
+ }
2805
+
2806
+ // For initial print, take last N. For follow, take only new lines.
2807
+ const startIdx = isInitial ? Math.max(0, lines.length - tail) : lastLineCount;
2808
+ const newLines = lines.slice(startIdx);
2809
+ lastLineCount = lines.length;
2810
+
2811
+ if (newLines.length === 0) return;
2812
+
2813
+ const entries = newLines.map((line) => {
2814
+ try {
2815
+ return JSON.parse(line);
2816
+ } catch {
2817
+ return null;
2818
+ }
2819
+ }).filter(Boolean);
2820
+
2821
+ const output = [];
2822
+ if (isInitial) {
2823
+ output.push(`## ${displayName}\n`);
2824
+ }
2825
+
2826
+ for (const entry of /** @type {any[]} */ (entries)) {
2827
+ const formatted = formatLogEntry(entry, { reasoning });
2828
+ if (formatted) {
2829
+ const ts = entry.timestamp || entry.ts || entry.createdAt;
2830
+ if (ts && ts !== lastTimestamp) {
2831
+ const date = new Date(ts);
2832
+ const timeStr = date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
2833
+ if (formatted.isUserMessage) {
2834
+ output.push(`\n### ${timeStr}\n`);
2835
+ }
2836
+ lastTimestamp = ts;
2837
+ }
2838
+ output.push(formatted.text);
2839
+ }
2840
+ }
2841
+
2842
+ if (output.length > 0) {
2843
+ console.log(output.join("\n"));
2844
+ }
2845
+ }
2846
+
2847
+ // Print initial content
2848
+ printLog(true);
2849
+
2850
+ if (!follow) return;
2851
+
2852
+ // Watch for changes
2853
+ const watcher = watch(logPath, () => {
2854
+ printLog(false);
2855
+ });
2856
+
2857
+ // Handle exit
2858
+ process.on("SIGINT", () => {
2859
+ watcher.close();
2860
+ process.exit(0);
2861
+ });
2862
+ }
2863
+
2864
+ /**
2865
+ * @param {any} entry
2866
+ * @param {{reasoning?: boolean}} [options]
2867
+ * @returns {{text: string, isUserMessage: boolean} | null}
2868
+ */
2869
+ function formatLogEntry(entry, { reasoning = false } = {}) {
2870
+ // Handle different log formats (Claude, Codex)
2871
+
2872
+ // Claude Code format: { type: "user" | "assistant", message: { content: ... } }
2873
+ // Codex format: { role: "user" | "assistant", content: ... }
2874
+
2875
+ const type = entry.type || entry.role;
2876
+ const message = entry.message || entry;
2877
+ const content = message.content;
2878
+
2879
+ if (type === "user" || type === "human") {
2880
+ const text = extractTextContent(content);
2881
+ if (text) {
2882
+ return { text: `**User**: ${truncate(text, TRUNCATE_USER_LEN)}\n`, isUserMessage: true };
2883
+ }
2884
+ }
2885
+
2886
+ if (type === "assistant") {
2887
+ const parts = [];
2888
+
2889
+ // Extract text response
2890
+ const text = extractTextContent(content);
2891
+ if (text) {
2892
+ parts.push(`**Assistant**: ${text}\n`);
2893
+ }
2894
+
2895
+ // Extract tool calls (compressed)
2896
+ const tools = extractToolCalls(content);
2897
+ if (tools.length > 0) {
2898
+ const toolSummary = tools.map((t) => {
2899
+ if (t.error) return `${t.name}(${t.target}) ✗`;
2900
+ return `${t.name}(${t.target})`;
2901
+ }).join(", ");
2902
+ parts.push(`> ${toolSummary}\n`);
2903
+ }
2904
+
2905
+ // Extract thinking/reasoning if requested
2906
+ if (reasoning) {
2907
+ const thinking = extractThinking(content);
2908
+ if (thinking) {
2909
+ parts.push(`> *Thinking*: ${truncate(thinking, TRUNCATE_THINKING_LEN)}\n`);
2910
+ }
2911
+ }
2912
+
2913
+ if (parts.length > 0) {
2914
+ return { text: parts.join(""), isUserMessage: false };
2915
+ }
2916
+ }
2917
+
2918
+ // Handle tool results with errors
2919
+ if (type === "tool_result" || type === "tool") {
2920
+ const error = entry.error || entry.is_error;
2921
+ if (error) {
2922
+ const name = entry.tool_name || entry.name || "tool";
2923
+ return { text: `> ${name} ✗ (${truncate(String(error), 100)})\n`, isUserMessage: false };
2924
+ }
2925
+ }
2926
+
2927
+ return null;
2928
+ }
2929
+
2930
+ /**
2931
+ * @param {any} content
2932
+ * @returns {string | null}
2933
+ */
2934
+ function extractTextContent(content) {
2935
+ if (typeof content === "string") return content;
2936
+ if (Array.isArray(content)) {
2937
+ const textParts = content
2938
+ .filter((c) => c.type === "text")
2939
+ .map((c) => c.text)
2940
+ .filter(Boolean);
2941
+ return textParts.join("\n");
2942
+ }
2943
+ if (content?.text) return content.text;
2944
+ return null;
2945
+ }
2946
+
2947
+ /**
2948
+ * @param {any} content
2949
+ * @returns {{name: string, target: string, error?: any}[]}
2950
+ */
2951
+ function extractToolCalls(content) {
2952
+ if (!Array.isArray(content)) return [];
2953
+
2954
+ return content
2955
+ .filter((c) => c.type === "tool_use" || c.type === "tool_call")
2956
+ .map((c) => {
2957
+ const name = c.name || c.tool || "tool";
2958
+ const input = c.input || c.arguments || {};
2959
+ // Extract a reasonable target from the input
2960
+ const target = input.file_path || input.path || input.command?.slice(0, 30) || input.pattern || "";
2961
+ const shortTarget = target.split("/").pop() || target.slice(0, 20);
2962
+ return { name, target: shortTarget, error: c.error };
2963
+ });
2964
+ }
2965
+
2966
+ /**
2967
+ * @param {any} content
2968
+ * @returns {string | null}
2969
+ */
2970
+ function extractThinking(content) {
2971
+ if (Array.isArray(content)) {
2972
+ const thinking = content.find((c) => c.type === "thinking");
2973
+ if (thinking) return thinking.thinking || thinking.text;
2974
+ }
2975
+ return null;
2976
+ }
2977
+
2978
+ /**
2979
+ * @param {string} str
2980
+ * @param {number} maxLen
2981
+ * @returns {string}
2982
+ */
2983
+ function truncate(str, maxLen) {
2984
+ if (!str) return "";
2985
+ if (str.length <= maxLen) return str;
2986
+ return str.slice(0, maxLen) + "...";
2987
+ }
2988
+
2989
+ /**
2990
+ * @param {{limit?: number, branch?: string | null, all?: boolean}} [options]
2991
+ */
2992
+ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
2993
+ const maxAge = all ? Infinity : MAILBOX_MAX_AGE_MS;
2994
+ const entries = readMailbox({ maxAge, branch, limit });
2995
+
2996
+ if (entries.length === 0) {
2997
+ console.log("No mailbox entries" + (branch ? ` for branch '${branch}'` : ""));
2998
+ return;
2999
+ }
3000
+
3001
+ console.log("## Mailbox\n");
3002
+
3003
+ for (const entry of entries) {
3004
+ const ts = new Date(entry.timestamp);
3005
+ const timeStr = ts.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
3006
+ const dateStr = ts.toLocaleDateString("en-GB", { month: "short", day: "numeric" });
3007
+ const p = entry.payload || {};
3008
+
3009
+ console.log(`### [${p.agent || "unknown"}] ${dateStr} ${timeStr}\n`);
3010
+
3011
+ if (p.branch || p.commit) {
3012
+ console.log(`**Branch**: ${p.branch || "?"} @ ${p.commit || "?"}\n`);
3013
+ }
3014
+
3015
+ if (p.message) {
3016
+ console.log(`**Assistant**: ${p.message}\n`);
3017
+ }
3018
+
3019
+ if (p.files?.length > 0) {
3020
+ const fileList = p.files.map((f) => f.split("/").pop()).join(", ");
3021
+ const more = p.files.length > 5 ? ` (+${p.files.length - 5} more)` : "";
3022
+ console.log(`> Read(${fileList}${more})\n`);
3023
+ }
3024
+
3025
+ console.log("---\n");
3026
+ }
3027
+ }
3028
+
3029
+ /**
3030
+ * @param {Agent} agent
3031
+ * @param {string | null | undefined} session
3032
+ * @param {string} message
3033
+ * @param {{noWait?: boolean, yolo?: boolean, timeoutMs?: number}} [options]
3034
+ */
3035
+ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, timeoutMs } = {}) {
3036
+ const sessionExists = session != null && tmuxHasSession(session);
3037
+ const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
3038
+
3039
+ // Cannot use --yolo --no-wait on a safe session: we need to stay and auto-approve
3040
+ if (yolo && noWait && sessionExists && !nativeYolo) {
3041
+ console.log("ERROR: --yolo requires waiting on a session not started with --yolo");
3042
+ console.log("Restart the session with --yolo, or allow waiting for auto-approval");
3043
+ process.exit(1);
3044
+ }
3045
+
3046
+ /** @type {string} */
3047
+ const activeSession = sessionExists ? /** @type {string} */ (session) : await cmdStart(agent, session, { yolo });
3048
+
3049
+ tmuxSendLiteral(activeSession, message);
3050
+ await sleep(50);
3051
+ tmuxSend(activeSession, "Enter");
3052
+
3053
+ if (noWait) return;
3054
+
3055
+ // Yolo mode on a safe session: auto-approve until done
3056
+ const useAutoApprove = yolo && !nativeYolo;
3057
+
3058
+ const { state, screen } = useAutoApprove
3059
+ ? await autoApproveLoop(agent, activeSession, timeoutMs)
3060
+ : await waitForResponse(agent, activeSession, timeoutMs);
3061
+
3062
+ if (state === State.RATE_LIMITED) {
3063
+ console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
3064
+ process.exit(2);
3065
+ }
3066
+
3067
+ if (state === State.CONFIRMING) {
3068
+ console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3069
+ process.exit(3);
3070
+ }
3071
+
3072
+ const output = agent.getResponse(activeSession, screen);
3073
+ if (output) {
3074
+ console.log(output);
3075
+ }
3076
+ }
3077
+
3078
+ /**
3079
+ * @param {Agent} agent
3080
+ * @param {string | null | undefined} session
3081
+ * @param {{wait?: boolean, timeoutMs?: number}} [options]
3082
+ */
3083
+ async function cmdApprove(agent, session, { wait = false, timeoutMs } = {}) {
3084
+ if (!session || !tmuxHasSession(session)) {
3085
+ console.log("ERROR: no session");
3086
+ process.exit(1);
3087
+ }
3088
+
3089
+ const before = tmuxCapture(session);
3090
+ if (agent.getState(before) !== State.CONFIRMING) {
3091
+ console.log("ERROR: not confirming");
3092
+ process.exit(1);
3093
+ }
3094
+
3095
+ tmuxSend(session, agent.approveKey);
3096
+
3097
+ if (!wait) return;
3098
+
3099
+ const { state, screen } = await waitForResponse(agent, session, timeoutMs);
3100
+
3101
+ if (state === State.RATE_LIMITED) {
3102
+ console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
3103
+ process.exit(2);
3104
+ }
3105
+
3106
+ if (state === State.CONFIRMING) {
3107
+ console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3108
+ process.exit(3);
3109
+ }
3110
+
3111
+ const response = agent.getResponse(session, screen);
3112
+ console.log(response || "");
3113
+ }
3114
+
3115
+ /**
3116
+ * @param {Agent} agent
3117
+ * @param {string | null | undefined} session
3118
+ * @param {{wait?: boolean, timeoutMs?: number}} [options]
3119
+ */
3120
+ async function cmdReject(agent, session, { wait = false, timeoutMs } = {}) {
3121
+ if (!session || !tmuxHasSession(session)) {
3122
+ console.log("ERROR: no session");
3123
+ process.exit(1);
3124
+ }
3125
+
3126
+ tmuxSend(session, agent.rejectKey);
3127
+
3128
+ if (!wait) return;
3129
+
3130
+ const { state, screen } = await waitForResponse(agent, session, timeoutMs);
3131
+
3132
+ if (state === State.RATE_LIMITED) {
3133
+ console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
3134
+ process.exit(2);
3135
+ }
3136
+
3137
+ const response = agent.getResponse(session, screen);
3138
+ console.log(response || "");
3139
+ }
3140
+
3141
+ /**
3142
+ * @param {Agent} agent
3143
+ * @param {string | null | undefined} session
3144
+ * @param {string | null | undefined} option
3145
+ * @param {string | null | undefined} customInstructions
3146
+ * @param {{wait?: boolean, yolo?: boolean, fresh?: boolean, timeoutMs?: number}} [options]
3147
+ */
3148
+ async function cmdReview(agent, session, option, customInstructions, { wait = true, yolo = true, fresh = false, timeoutMs = REVIEW_TIMEOUT_MS } = {}) {
3149
+ const sessionExists = session != null && tmuxHasSession(session);
3150
+
3151
+ // Reset conversation if --fresh and session exists
3152
+ if (fresh && sessionExists) {
3153
+ tmuxSendLiteral(/** @type {string} */ (session), "/new");
3154
+ await sleep(50);
3155
+ tmuxSend(/** @type {string} */ (session), "Enter");
3156
+ await waitUntilReady(agent, /** @type {string} */ (session), STARTUP_TIMEOUT_MS);
3157
+ }
3158
+
3159
+ // Claude: use prompt-based review (no /review command)
3160
+ if (!agent.reviewOptions) {
3161
+ /** @type {Record<string, string>} */
3162
+ const reviewPrompts = {
3163
+ pr: "Review the current PR.",
3164
+ uncommitted: "Review uncommitted changes.",
3165
+ commit: "Review the most recent git commit.",
3166
+ custom: customInstructions || "Review the code.",
3167
+ };
3168
+ const prompt = (option && reviewPrompts[option]) || reviewPrompts.commit;
3169
+ return cmdAsk(agent, session, prompt, { noWait: !wait, yolo, timeoutMs });
3170
+ }
3171
+
3172
+ // AX_REVIEW_MODE=exec: bypass /review command, send instructions directly
3173
+ if (process.env.AX_REVIEW_MODE === "exec" && option === "custom" && customInstructions) {
3174
+ return cmdAsk(agent, session, customInstructions, { noWait: !wait, yolo, timeoutMs });
3175
+ }
3176
+ const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
3177
+
3178
+ // Cannot use --yolo without --wait on a safe session: we need to stay and auto-approve
3179
+ if (yolo && !wait && sessionExists && !nativeYolo) {
3180
+ console.log("ERROR: --yolo requires waiting on a session not started with --yolo");
3181
+ console.log("Restart the session with --yolo, or allow waiting for auto-approval");
3182
+ process.exit(1);
3183
+ }
3184
+
3185
+ /** @type {string} */
3186
+ const activeSession = sessionExists ? /** @type {string} */ (session) : await cmdStart(agent, session, { yolo });
3187
+
3188
+ tmuxSendLiteral(activeSession, "/review");
3189
+ await sleep(50);
3190
+ tmuxSend(activeSession, "Enter");
3191
+
3192
+ await waitFor(activeSession, (s) => s.includes("Select a review preset") || s.includes("review"));
3193
+
3194
+ if (option) {
3195
+ const key = agent.reviewOptions[option] || option;
3196
+ tmuxSend(activeSession, key);
3197
+
3198
+ if (option === "custom" && customInstructions) {
3199
+ await waitFor(activeSession, (s) => s.includes("custom") || s.includes("instructions"));
3200
+ tmuxSendLiteral(activeSession, customInstructions);
3201
+ await sleep(50);
3202
+ tmuxSend(activeSession, "Enter");
3203
+ }
3204
+ }
3205
+
3206
+ if (!wait) return;
3207
+
3208
+ // Yolo mode on a safe session: auto-approve until done
3209
+ const useAutoApprove = yolo && !nativeYolo;
3210
+
3211
+ const { state, screen } = useAutoApprove
3212
+ ? await autoApproveLoop(agent, activeSession, timeoutMs)
3213
+ : await waitForResponse(agent, activeSession, timeoutMs);
3214
+
3215
+ if (state === State.RATE_LIMITED) {
3216
+ console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
3217
+ process.exit(2);
3218
+ }
3219
+
3220
+ if (state === State.CONFIRMING) {
3221
+ console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3222
+ process.exit(3);
3223
+ }
3224
+
3225
+ const response = agent.getResponse(activeSession, screen);
3226
+ console.log(response || "");
3227
+ }
3228
+
3229
+ /**
3230
+ * @param {Agent} agent
3231
+ * @param {string | null | undefined} session
3232
+ * @param {number} [index]
3233
+ * @param {{wait?: boolean, timeoutMs?: number}} [options]
3234
+ */
3235
+ async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs } = {}) {
3236
+ if (!session || !tmuxHasSession(session)) {
3237
+ console.log("ERROR: no session");
3238
+ process.exit(1);
3239
+ }
3240
+
3241
+ let screen;
3242
+ if (wait) {
3243
+ const result = await waitUntilReady(agent, session, timeoutMs);
3244
+ screen = result.screen;
3245
+ } else {
3246
+ screen = tmuxCapture(session, 500);
3247
+ }
3248
+
3249
+ const state = agent.getState(screen);
3250
+
3251
+ if (state === State.RATE_LIMITED) {
3252
+ console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
3253
+ process.exit(2);
3254
+ }
3255
+
3256
+ if (state === State.CONFIRMING) {
3257
+ console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3258
+ process.exit(3);
3259
+ }
3260
+
3261
+ if (state === State.THINKING) {
3262
+ console.log("THINKING");
3263
+ process.exit(4);
3264
+ }
3265
+
3266
+ const output = agent.getResponse(session, screen, index);
3267
+ if (output) {
3268
+ console.log(output);
3269
+ }
3270
+ }
3271
+
3272
+ /**
3273
+ * @param {Agent} agent
3274
+ * @param {string | null | undefined} session
3275
+ */
3276
+ function cmdStatus(agent, session) {
3277
+ if (!session || !tmuxHasSession(session)) {
3278
+ console.log("NO_SESSION");
3279
+ process.exit(1);
3280
+ }
3281
+
3282
+ const screen = tmuxCapture(session);
3283
+ const state = agent.getState(screen);
3284
+
3285
+ if (state === State.RATE_LIMITED) {
3286
+ console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
3287
+ process.exit(2);
3288
+ }
3289
+
3290
+ if (state === State.CONFIRMING) {
3291
+ console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3292
+ process.exit(3);
3293
+ }
3294
+
3295
+ if (state === State.THINKING) {
3296
+ console.log("THINKING");
3297
+ process.exit(4);
3298
+ }
3299
+ }
3300
+
3301
+ /**
3302
+ * @param {Agent} agent
3303
+ * @param {string | null | undefined} session
3304
+ * @param {{scrollback?: number}} [options]
3305
+ */
3306
+ function cmdDebug(agent, session, { scrollback = 0 } = {}) {
3307
+ if (!session || !tmuxHasSession(session)) {
3308
+ console.log("ERROR: no session");
3309
+ process.exit(1);
3310
+ }
3311
+
3312
+ const screen = tmuxCapture(session, scrollback);
3313
+ const state = agent.getState(screen);
3314
+
3315
+ console.log(`=== Session: ${session} ===`);
3316
+ console.log(`=== State: ${state} ===`);
3317
+ console.log(`=== Screen ===`);
3318
+ console.log(screen);
3319
+ }
3320
+ /**
3321
+ * @param {string} input
3322
+ * @returns {{type: 'literal' | 'key', value: string}[]}
3323
+ */
3324
+ function parseKeySequence(input) {
3325
+ // Parse a string like "1[Enter]" or "[Escape]hello[C-c]" into a sequence of actions
3326
+ // [Enter], [Escape], [Up], [Down], [Tab], [C-c], etc. are special keys
3327
+ // Everything else is literal text
3328
+ /** @type {{type: 'literal' | 'key', value: string}[]} */
3329
+ const parts = [];
3330
+ let i = 0;
3331
+ let literal = "";
3332
+
3333
+ while (i < input.length) {
3334
+ if (input[i] === "[") {
3335
+ // Flush any accumulated literal text
3336
+ if (literal) {
3337
+ parts.push({ type: "literal", value: literal });
3338
+ literal = "";
3339
+ }
3340
+ // Find the closing bracket
3341
+ const end = input.indexOf("]", i);
3342
+ if (end === -1) {
3343
+ // No closing bracket, treat as literal
3344
+ literal += input[i];
3345
+ i++;
3346
+ } else {
3347
+ const key = input.slice(i + 1, end);
3348
+ // Skip empty brackets, but allow any non-empty key (let tmux validate)
3349
+ if (key) {
3350
+ parts.push({ type: "key", value: key });
3351
+ }
3352
+ i = end + 1;
3353
+ }
3354
+ } else {
3355
+ literal += input[i];
3356
+ i++;
3357
+ }
3358
+ }
3359
+
3360
+ // Flush remaining literal text
3361
+ if (literal) {
3362
+ parts.push({ type: "literal", value: literal });
3363
+ }
3364
+
3365
+ return parts;
3366
+ }
3367
+
3368
+ /**
3369
+ * @param {string | null | undefined} session
3370
+ * @param {string} input
3371
+ */
3372
+ function cmdSend(session, input) {
3373
+ if (!session || !tmuxHasSession(session)) {
3374
+ console.log("ERROR: no session");
3375
+ process.exit(1);
3376
+ }
3377
+
3378
+ const parts = parseKeySequence(input);
3379
+ for (const part of parts) {
3380
+ if (part.type === "literal") {
3381
+ tmuxSendLiteral(session, part.value);
3382
+ } else {
3383
+ tmuxSend(session, part.value);
3384
+ }
3385
+ }
3386
+ }
3387
+
3388
+ /**
3389
+ * @param {Agent} agent
3390
+ * @param {string | null | undefined} session
3391
+ * @param {string | number} n
3392
+ * @param {{wait?: boolean, timeoutMs?: number}} [options]
3393
+ */
3394
+ async function cmdSelect(agent, session, n, { wait = false, timeoutMs } = {}) {
3395
+ if (!session || !tmuxHasSession(session)) {
3396
+ console.log("ERROR: no session");
3397
+ process.exit(1);
3398
+ }
3399
+
3400
+ tmuxSend(session, n.toString());
3401
+
3402
+ if (!wait) return;
3403
+
3404
+ const { state, screen } = await waitForResponse(agent, session, timeoutMs);
3405
+
3406
+ if (state === State.RATE_LIMITED) {
3407
+ console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
3408
+ process.exit(2);
3409
+ }
3410
+
3411
+ if (state === State.CONFIRMING) {
3412
+ console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3413
+ process.exit(3);
3414
+ }
3415
+
3416
+ const response = agent.getResponse(session, screen);
3417
+ console.log(response || "");
3418
+ }
3419
+
3420
+ // =============================================================================
3421
+ // CLI
3422
+ // =============================================================================
3423
+
3424
+ /**
3425
+ * @returns {Agent}
3426
+ */
3427
+ function getAgentFromInvocation() {
3428
+ const invoked = path.basename(process.argv[1], ".js");
3429
+ if (invoked === "axclaude" || invoked === "claude") return ClaudeAgent;
3430
+ if (invoked === "axcodex" || invoked === "codex") return CodexAgent;
3431
+
3432
+ // Default based on AX_DEFAULT_TOOL env var, or codex if not set
3433
+ const defaultTool = process.env.AX_DEFAULT_TOOL;
3434
+ if (defaultTool === "claude") return ClaudeAgent;
3435
+ if (defaultTool === "codex" || !defaultTool) return CodexAgent;
3436
+
3437
+ console.error(`WARNING: invalid AX_DEFAULT_TOOL="${defaultTool}", using codex`);
3438
+ return CodexAgent;
3439
+ }
3440
+
3441
+ /**
3442
+ * @param {Agent} agent
3443
+ * @param {string} cliName
3444
+ */
3445
+ function printHelp(agent, cliName) {
3446
+ const name = cliName;
3447
+ const backendName = agent.name === "codex" ? "OpenAI Codex" : "Claude";
3448
+ const hasReview = !!agent.reviewOptions;
3449
+
3450
+ console.log(`${name}.js - agentic assistant CLI (${backendName})
3451
+
3452
+ Commands:
3453
+ agents List all running agents with state and log paths
3454
+ attach [SESSION] Attach to agent session interactively
3455
+ log SESSION View conversation log (--tail=N, --follow, --reasoning)
3456
+ mailbox View daemon observations (--limit=N, --branch=X, --all)
3457
+ daemons start [name] Start daemon agents (all, or by name)
3458
+ daemons stop [name] Stop daemon agents (all, or by name)
3459
+ kill Kill sessions in current project (--all for all, --session=NAME for one)
3460
+ status Check state (exit: 0=ready, 2=rate_limited, 3=confirming, 4=thinking)
3461
+ output [-N] Show response (0=last, -1=prev, -2=older)
3462
+ debug Show raw screen output and detected state${hasReview ? `
3463
+ review [TYPE] Review code: pr, uncommitted, commit, custom` : ""}
3464
+ select N Select menu option N
3465
+ approve Approve pending action (send 'y')
3466
+ reject Reject pending action (send 'n')
3467
+ send KEYS Send key sequence (e.g. "1[Enter]", "[Escape]")
3468
+ compact Summarize conversation (when context is full)
3469
+ reset Start fresh conversation
3470
+ <message> Send message to ${name}
3471
+
3472
+ Flags:
3473
+ --tool=NAME Use specific agent (codex, claude)
3474
+ --session=NAME Target session by name, daemon name, or UUID prefix (self = current)
3475
+ --wait Wait for response (for review, approve, etc)
3476
+ --no-wait Don't wait (for messages, which wait by default)
3477
+ --timeout=N Set timeout in seconds (default: 120)
3478
+ --yolo Skip all confirmations (dangerous)
3479
+ --fresh Reset conversation before review
3480
+
3481
+ Environment:
3482
+ AX_DEFAULT_TOOL Default agent when using 'ax' (claude or codex, default: codex)
3483
+ ${agent.envVar} Override default session name
3484
+ AX_CLAUDE_CONFIG_DIR Override Claude config directory (default: ~/.claude)
3485
+ AX_CODEX_CONFIG_DIR Override Codex config directory (default: ~/.codex)
3486
+ AX_REVIEW_MODE=exec Bypass /review, send instructions directly (codex only)
3487
+ AX_DEBUG=1 Enable debug logging
3488
+
3489
+ Examples:
3490
+ ./${name}.js "explain this codebase"
3491
+ ./${name}.js "please review the error handling" # Auto custom review
3492
+ ./${name}.js review uncommitted --wait
3493
+ ./${name}.js approve --wait
3494
+ ./${name}.js kill # Kill agents in current project
3495
+ ./${name}.js kill --all # Kill all agents across all projects
3496
+ ./${name}.js kill --session=NAME # Kill specific session
3497
+ ./${name}.js send "1[Enter]" # Recovery: select option 1 and press Enter
3498
+ ./${name}.js send "[Escape][Escape]" # Recovery: escape out of a dialog
3499
+
3500
+ Daemon Agents:
3501
+ ./${name}.js daemons start # Start all daemons from .ai/agents/*.md
3502
+ ./${name}.js daemons stop # Stop all daemons
3503
+ ./${name}.js daemons init <name> # Create new daemon config
3504
+ ./${name}.js agents # List all agents (shows TYPE=daemon)`);
3505
+ }
3506
+
3507
+ async function main() {
3508
+ // Check tmux is installed
3509
+ const tmuxCheck = spawnSync("tmux", ["-V"], { encoding: "utf-8" });
3510
+ if (tmuxCheck.error || tmuxCheck.status !== 0) {
3511
+ console.error("ERROR: tmux is not installed or not in PATH");
3512
+ console.error("Install with: brew install tmux (macOS) or apt install tmux (Linux)");
3513
+ process.exit(1);
3514
+ }
3515
+
3516
+ const args = process.argv.slice(2);
3517
+ const cliName = path.basename(process.argv[1], ".js");
3518
+
3519
+ // Parse flags
3520
+ const wait = args.includes("--wait");
3521
+ const noWait = args.includes("--no-wait");
3522
+ const yolo = args.includes("--yolo");
3523
+ const fresh = args.includes("--fresh");
3524
+ const reasoning = args.includes("--reasoning");
3525
+ const follow = args.includes("--follow") || args.includes("-f");
3526
+
3527
+ // Agent selection
3528
+ let agent = getAgentFromInvocation();
3529
+ const toolArg = args.find((a) => a.startsWith("--tool="));
3530
+ if (toolArg) {
3531
+ const tool = toolArg.split("=")[1];
3532
+ if (tool === "claude") agent = ClaudeAgent;
3533
+ else if (tool === "codex") agent = CodexAgent;
3534
+ else {
3535
+ console.log(`ERROR: unknown tool '${tool}'`);
3536
+ process.exit(1);
3537
+ }
3538
+ }
3539
+
3540
+ // Session resolution
3541
+ let session = agent.getDefaultSession();
3542
+ const sessionArg = args.find((a) => a.startsWith("--session="));
3543
+ if (sessionArg) {
3544
+ const val = sessionArg.split("=")[1];
3545
+ if (val === "self") {
3546
+ const current = tmuxCurrentSession();
3547
+ if (!current) {
3548
+ console.log("ERROR: --session=self requires running inside tmux");
3549
+ process.exit(1);
3550
+ }
3551
+ session = current;
3552
+ } else {
3553
+ // Resolve partial names, daemon names, and UUID prefixes
3554
+ session = resolveSessionName(val);
3555
+ }
3556
+ }
3557
+
3558
+ // Timeout
3559
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
3560
+ const timeoutArg = args.find((a) => a.startsWith("--timeout="));
3561
+ if (timeoutArg) {
3562
+ const val = parseInt(timeoutArg.split("=")[1], 10);
3563
+ if (isNaN(val) || val <= 0) {
3564
+ console.log("ERROR: invalid timeout");
3565
+ process.exit(1);
3566
+ }
3567
+ timeoutMs = val * 1000;
3568
+ }
3569
+
3570
+ // Tail (for log command)
3571
+ let tail = 50;
3572
+ const tailArg = args.find((a) => a.startsWith("--tail="));
3573
+ if (tailArg) {
3574
+ tail = parseInt(tailArg.split("=")[1], 10) || 50;
3575
+ }
3576
+
3577
+ // Limit (for mailbox command)
3578
+ let limit = 20;
3579
+ const limitArg = args.find((a) => a.startsWith("--limit="));
3580
+ if (limitArg) {
3581
+ limit = parseInt(limitArg.split("=")[1], 10) || 20;
3582
+ }
3583
+
3584
+ // Branch filter (for mailbox command)
3585
+ let branch = null;
3586
+ const branchArg = args.find((a) => a.startsWith("--branch="));
3587
+ if (branchArg) {
3588
+ branch = branchArg.split("=")[1] || null;
3589
+ }
3590
+
3591
+ // All flag (for mailbox command - show all regardless of age)
3592
+ const all = args.includes("--all");
3593
+
3594
+ // Filter out flags
3595
+ const filteredArgs = args.filter(
3596
+ (a) =>
3597
+ !["--wait", "--no-wait", "--yolo", "--reasoning", "--follow", "-f", "--all"].includes(a) &&
3598
+ !a.startsWith("--timeout") &&
3599
+ !a.startsWith("--session") &&
3600
+ !a.startsWith("--tool") &&
3601
+ !a.startsWith("--tail") &&
3602
+ !a.startsWith("--limit") &&
3603
+ !a.startsWith("--branch")
3604
+ );
3605
+ const cmd = filteredArgs[0];
3606
+
3607
+ // Dispatch commands
3608
+ if (cmd === "agents") return cmdAgents();
3609
+ if (cmd === "daemons") return cmdDaemons(filteredArgs[1], filteredArgs[2]);
3610
+ if (cmd === "daemon") return cmdDaemon(filteredArgs[1]);
3611
+ if (cmd === "kill") return cmdKill(session, { all });
3612
+ if (cmd === "attach") return cmdAttach(filteredArgs[1] || session);
3613
+ if (cmd === "log") return cmdLog(filteredArgs[1] || session, { tail, reasoning, follow });
3614
+ if (cmd === "mailbox") return cmdMailbox({ limit, branch, all });
3615
+ if (cmd === "approve") return cmdApprove(agent, session, { wait, timeoutMs });
3616
+ if (cmd === "reject") return cmdReject(agent, session, { wait, timeoutMs });
3617
+ if (cmd === "review") return cmdReview(agent, session, filteredArgs[1], filteredArgs[2], { wait, yolo, fresh, timeoutMs });
3618
+ if (cmd === "status") return cmdStatus(agent, session);
3619
+ if (cmd === "debug") return cmdDebug(agent, session);
3620
+ if (cmd === "output") {
3621
+ const indexArg = filteredArgs[1];
3622
+ const index = indexArg?.startsWith("-") ? parseInt(indexArg, 10) : 0;
3623
+ return cmdOutput(agent, session, index, { wait, timeoutMs });
3624
+ }
3625
+ if (cmd === "send" && filteredArgs.length > 1) return cmdSend(session, filteredArgs.slice(1).join(" "));
3626
+ if (cmd === "compact") return cmdAsk(agent, session, "/compact", { noWait: true, timeoutMs });
3627
+ if (cmd === "reset") return cmdAsk(agent, session, "/new", { noWait: true, timeoutMs });
3628
+ if (cmd === "select" && filteredArgs[1]) return cmdSelect(agent, session, filteredArgs[1], { wait, timeoutMs });
3629
+
3630
+ // Default: send message
3631
+ let message = filteredArgs.join(" ");
3632
+ if (!message && hasStdinData()) {
3633
+ message = await readStdin();
3634
+ }
3635
+
3636
+ if (!message || cmd === "--help" || cmd === "-h") {
3637
+ printHelp(agent, cliName);
3638
+ process.exit(0);
3639
+ }
3640
+
3641
+ // Detect "please review" and route to custom review mode
3642
+ const reviewMatch = message.match(/^please review\s*(.*)/i);
3643
+ if (reviewMatch && agent.reviewOptions) {
3644
+ const customInstructions = reviewMatch[1].trim() || null;
3645
+ return cmdReview(agent, session, "custom", customInstructions, { wait: !noWait, yolo, timeoutMs });
3646
+ }
3647
+
3648
+ return cmdAsk(agent, session, message, { noWait, yolo, timeoutMs });
3649
+ }
3650
+
3651
+ // Run main() only when executed directly (not when imported for testing)
3652
+ // Use realpathSync to handle symlinks (e.g., axclaude, axcodex bin entries)
3653
+ const __filename = fileURLToPath(import.meta.url);
3654
+ const isDirectRun = process.argv[1] && (() => {
3655
+ try {
3656
+ return realpathSync(process.argv[1]) === __filename;
3657
+ } catch {
3658
+ return false;
3659
+ }
3660
+ })();
3661
+ if (isDirectRun) {
3662
+ main().catch((err) => {
3663
+ console.log(`ERROR: ${err.message}`);
3664
+ process.exit(1);
3665
+ });
3666
+ }
3667
+
3668
+ // Exports for testing (pure functions only)
3669
+ export {
3670
+ parseSessionName,
3671
+ parseAgentConfig,
3672
+ parseKeySequence,
3673
+ getClaudeProjectPath,
3674
+ matchesPattern,
3675
+ getBaseDir,
3676
+ truncate,
3677
+ truncateDiff,
3678
+ extractTextContent,
3679
+ extractToolCalls,
3680
+ extractThinking,
3681
+ detectState,
3682
+ State,
3683
+ };
3684
+