ax-agents 0.0.1-alpha.1 → 0.0.1-alpha.10

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 (3) hide show
  1. package/README.md +20 -3
  2. package/ax.js +1332 -572
  3. package/package.json +1 -1
package/ax.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // ax.js - CLI for interacting with AI agents (Codex, Claude) via tmux
3
+ // ax - CLI for interacting with AI agents (Codex, Claude) via `tmux`.
4
+ // Usage: ax --help
4
5
  //
5
6
  // Exit codes:
6
7
  // 0 - success / ready
@@ -8,17 +9,34 @@
8
9
  // 2 - rate limited
9
10
  // 3 - awaiting confirmation
10
11
  // 4 - thinking
11
- //
12
- // Usage: ./ax.js --help
13
- // ./axcodex.js --help (symlink)
14
- // ./axclaude.js --help (symlink)
15
12
 
16
13
  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";
14
+ import {
15
+ fstatSync,
16
+ statSync,
17
+ readFileSync,
18
+ readdirSync,
19
+ existsSync,
20
+ appendFileSync,
21
+ mkdirSync,
22
+ writeFileSync,
23
+ renameSync,
24
+ realpathSync,
25
+ watch,
26
+ openSync,
27
+ readSync,
28
+ closeSync,
29
+ } from "node:fs";
30
+ import { randomUUID, createHash } from "node:crypto";
19
31
  import { fileURLToPath } from "node:url";
20
32
  import path from "node:path";
21
33
  import os from "node:os";
34
+ import { parseArgs } from "node:util";
35
+
36
+ const __filename = fileURLToPath(import.meta.url);
37
+ const __dirname = path.dirname(__filename);
38
+ const packageJson = JSON.parse(readFileSync(path.join(__dirname, "package.json"), "utf-8"));
39
+ const VERSION = packageJson.version;
22
40
 
23
41
  /**
24
42
  * @typedef {'claude' | 'codex'} ToolName
@@ -27,12 +45,12 @@ import os from "node:os";
27
45
  /**
28
46
  * @typedef {Object} ParsedSession
29
47
  * @property {string} tool
30
- * @property {string} [daemonName]
48
+ * @property {string} [archangelName]
31
49
  * @property {string} [uuid]
32
50
  */
33
51
 
34
52
  /**
35
- * @typedef {Object} DaemonConfig
53
+ * @typedef {Object} ArchangelConfig
36
54
  * @property {string} name
37
55
  * @property {ToolName} tool
38
56
  * @property {string[]} watch
@@ -105,8 +123,9 @@ import os from "node:os";
105
123
  */
106
124
 
107
125
  /**
126
+ * @typedef {{matcher: string, hooks: Array<{type: string, command: string, timeout?: number}>}} ClaudeHookEntry
108
127
  * @typedef {Object} ClaudeSettings
109
- * @property {{UserPromptSubmit?: Array<{matcher: string, hooks: Array<{type: string, command: string, timeout?: number}>}>}} [hooks]
128
+ * @property {{UserPromptSubmit?: ClaudeHookEntry[], PostToolUse?: ClaudeHookEntry[], Stop?: ClaudeHookEntry[], [key: string]: ClaudeHookEntry[] | undefined}} [hooks]
110
129
  */
111
130
 
112
131
  const DEBUG = process.env.AX_DEBUG === "1";
@@ -216,7 +235,9 @@ function tmuxKill(session) {
216
235
  */
217
236
  function tmuxNewSession(session, command) {
218
237
  // Use spawnSync to avoid command injection via session/command
219
- const result = spawnSync("tmux", ["new-session", "-d", "-s", session, command], { encoding: "utf-8" });
238
+ const result = spawnSync("tmux", ["new-session", "-d", "-s", session, command], {
239
+ encoding: "utf-8",
240
+ });
220
241
  if (result.status !== 0) throw new Error(result.stderr || "tmux new-session failed");
221
242
  }
222
243
 
@@ -225,7 +246,9 @@ function tmuxNewSession(session, command) {
225
246
  */
226
247
  function tmuxCurrentSession() {
227
248
  if (!process.env.TMUX) return null;
228
- const result = spawnSync("tmux", ["display-message", "-p", "#S"], { encoding: "utf-8" });
249
+ const result = spawnSync("tmux", ["display-message", "-p", "#S"], {
250
+ encoding: "utf-8",
251
+ });
229
252
  if (result.status !== 0) return null;
230
253
  return result.stdout.trim();
231
254
  }
@@ -237,9 +260,13 @@ function tmuxCurrentSession() {
237
260
  */
238
261
  function isYoloSession(session) {
239
262
  try {
240
- const result = spawnSync("tmux", ["display-message", "-t", session, "-p", "#{pane_start_command}"], {
241
- encoding: "utf-8",
242
- });
263
+ const result = spawnSync(
264
+ "tmux",
265
+ ["display-message", "-t", session, "-p", "#{pane_start_command}"],
266
+ {
267
+ encoding: "utf-8",
268
+ },
269
+ );
243
270
  if (result.status !== 0) return false;
244
271
  const cmd = result.stdout.trim();
245
272
  return cmd.includes("--dangerously-");
@@ -259,9 +286,15 @@ const POLL_MS = parseInt(process.env.AX_POLL_MS || "200", 10);
259
286
  const DEFAULT_TIMEOUT_MS = parseInt(process.env.AX_TIMEOUT_MS || "120000", 10);
260
287
  const REVIEW_TIMEOUT_MS = parseInt(process.env.AX_REVIEW_TIMEOUT_MS || "900000", 10); // 15 minutes
261
288
  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);
289
+ const ARCHANGEL_STARTUP_TIMEOUT_MS = parseInt(
290
+ process.env.AX_ARCHANGEL_STARTUP_TIMEOUT_MS || "60000",
291
+ 10,
292
+ );
293
+ const ARCHANGEL_RESPONSE_TIMEOUT_MS = parseInt(
294
+ process.env.AX_ARCHANGEL_RESPONSE_TIMEOUT_MS || "300000",
295
+ 10,
296
+ ); // 5 minutes
297
+ const ARCHANGEL_HEALTH_CHECK_MS = parseInt(process.env.AX_ARCHANGEL_HEALTH_CHECK_MS || "30000", 10);
265
298
  const STABLE_MS = parseInt(process.env.AX_STABLE_MS || "1000", 10);
266
299
  const APPROVE_DELAY_MS = parseInt(process.env.AX_APPROVE_DELAY_MS || "100", 10);
267
300
  const MAILBOX_MAX_AGE_MS = parseInt(process.env.AX_MAILBOX_MAX_AGE_MS || "3600000", 10); // 1 hour
@@ -269,9 +302,35 @@ const CLAUDE_CONFIG_DIR = process.env.AX_CLAUDE_CONFIG_DIR || path.join(os.homed
269
302
  const CODEX_CONFIG_DIR = process.env.AX_CODEX_CONFIG_DIR || path.join(os.homedir(), ".codex");
270
303
  const TRUNCATE_USER_LEN = 500;
271
304
  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;
305
+ const ARCHANGEL_GIT_CONTEXT_HOURS = 4;
306
+ const ARCHANGEL_GIT_CONTEXT_MAX_LINES = 200;
307
+ const ARCHANGEL_PARENT_CONTEXT_ENTRIES = 10;
308
+ const ARCHANGEL_PREAMBLE = `## Guidelines
309
+
310
+ - If you have nothing to report, you MUST respond with ONLY "EMPTY_RESPONSE".
311
+ - Investigate before speaking. If uncertain, read more code and trace the logic until you're confident.
312
+ - Explain WHY something is an issue, not just that it is.
313
+ - Focus on your area of expertise.
314
+ - Calibrate to the task or plan. Don't suggest refactors during a bug fix.
315
+ - Be clear. Brief is fine, but never sacrifice clarity.
316
+ - For critical issues, request for them to be added to the todo list.
317
+ - Don't repeat observations you've already made unless you have more to say or better clarity.
318
+ - Make judgment calls - don't ask questions.`;
319
+
320
+ /**
321
+ * @param {string} session
322
+ * @param {(screen: string) => boolean} predicate
323
+ * @param {number} [timeoutMs]
324
+ * @returns {Promise<string>}
325
+ */
326
+ class TimeoutError extends Error {
327
+ /** @param {string} [session] */
328
+ constructor(session) {
329
+ super("timeout");
330
+ this.name = "TimeoutError";
331
+ this.session = session;
332
+ }
333
+ }
275
334
 
276
335
  /**
277
336
  * @param {string} session
@@ -286,7 +345,7 @@ async function waitFor(session, predicate, timeoutMs = STARTUP_TIMEOUT_MS) {
286
345
  if (predicate(screen)) return screen;
287
346
  await sleep(POLL_MS);
288
347
  }
289
- throw new Error("timeout");
348
+ throw new TimeoutError(session);
290
349
  }
291
350
 
292
351
  // =============================================================================
@@ -299,7 +358,9 @@ async function waitFor(session, predicate, timeoutMs = STARTUP_TIMEOUT_MS) {
299
358
  function findCallerPid() {
300
359
  let pid = process.ppid;
301
360
  while (pid > 1) {
302
- const result = spawnSync("ps", ["-p", pid.toString(), "-o", "ppid=,comm="], { encoding: "utf-8" });
361
+ const result = spawnSync("ps", ["-p", pid.toString(), "-o", "ppid=,comm="], {
362
+ encoding: "utf-8",
363
+ });
303
364
  if (result.status !== 0) break;
304
365
  const parts = result.stdout.trim().split(/\s+/);
305
366
  const ppid = parseInt(parts[0], 10);
@@ -312,6 +373,38 @@ function findCallerPid() {
312
373
  return null;
313
374
  }
314
375
 
376
+ /**
377
+ * Find orphaned claude/codex processes (PPID=1, reparented to init/launchd)
378
+ * @returns {{pid: string, command: string}[]}
379
+ */
380
+ function findOrphanedProcesses() {
381
+ const result = spawnSync("ps", ["-eo", "pid=,ppid=,args="], { encoding: "utf-8" });
382
+
383
+ if (result.status !== 0 || !result.stdout.trim()) {
384
+ return [];
385
+ }
386
+
387
+ const orphans = [];
388
+ for (const line of result.stdout.trim().split("\n")) {
389
+ // Parse: " PID PPID command args..."
390
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/);
391
+ if (!match) continue;
392
+
393
+ const [, pid, ppid, args] = match;
394
+
395
+ // Must have PPID=1 (orphaned/reparented to init)
396
+ if (ppid !== "1") continue;
397
+
398
+ // Command must START with claude or codex (excludes tmux which also has PPID=1)
399
+ const cmd = args.split(/\s+/)[0];
400
+ if (cmd !== "claude" && cmd !== "codex") continue;
401
+
402
+ orphans.push({ pid, command: args.slice(0, 60) });
403
+ }
404
+
405
+ return orphans;
406
+ }
407
+
315
408
  // =============================================================================
316
409
  // Helpers - stdin
317
410
  // =============================================================================
@@ -340,6 +433,86 @@ async function readStdin() {
340
433
  }
341
434
 
342
435
  // =============================================================================
436
+ // =============================================================================
437
+ // Helpers - CLI argument parsing
438
+ // =============================================================================
439
+
440
+ /**
441
+ * Parse CLI arguments using Node.js built-in parseArgs.
442
+ * @param {string[]} args - Command line arguments (without node and script path)
443
+ * @returns {{ flags: ParsedFlags, positionals: string[] }}
444
+ *
445
+ * @typedef {Object} ParsedFlags
446
+ * @property {boolean} wait
447
+ * @property {boolean} noWait
448
+ * @property {boolean} yolo
449
+ * @property {boolean} fresh
450
+ * @property {boolean} reasoning
451
+ * @property {boolean} follow
452
+ * @property {boolean} all
453
+ * @property {boolean} orphans
454
+ * @property {boolean} force
455
+ * @property {boolean} version
456
+ * @property {boolean} help
457
+ * @property {string} [tool]
458
+ * @property {string} [session]
459
+ * @property {number} [timeout]
460
+ * @property {number} [tail]
461
+ * @property {number} [limit]
462
+ * @property {string} [branch]
463
+ */
464
+ function parseCliArgs(args) {
465
+ const { values, positionals } = parseArgs({
466
+ args,
467
+ options: {
468
+ // Boolean flags
469
+ wait: { type: "boolean", default: false },
470
+ "no-wait": { type: "boolean", default: false },
471
+ yolo: { type: "boolean", default: false },
472
+ fresh: { type: "boolean", default: false },
473
+ reasoning: { type: "boolean", default: false },
474
+ follow: { type: "boolean", short: "f", default: false },
475
+ all: { type: "boolean", default: false },
476
+ orphans: { type: "boolean", default: false },
477
+ force: { type: "boolean", default: false },
478
+ version: { type: "boolean", short: "V", default: false },
479
+ help: { type: "boolean", short: "h", default: false },
480
+ // Value flags
481
+ tool: { type: "string" },
482
+ session: { type: "string" },
483
+ timeout: { type: "string" },
484
+ tail: { type: "string" },
485
+ limit: { type: "string" },
486
+ branch: { type: "string" },
487
+ },
488
+ allowPositionals: true,
489
+ strict: false, // Don't error on unknown flags
490
+ });
491
+
492
+ return {
493
+ flags: {
494
+ wait: Boolean(values.wait),
495
+ noWait: Boolean(values["no-wait"]),
496
+ yolo: Boolean(values.yolo),
497
+ fresh: Boolean(values.fresh),
498
+ reasoning: Boolean(values.reasoning),
499
+ follow: Boolean(values.follow),
500
+ all: Boolean(values.all),
501
+ orphans: Boolean(values.orphans),
502
+ force: Boolean(values.force),
503
+ version: Boolean(values.version),
504
+ help: Boolean(values.help),
505
+ tool: /** @type {string | undefined} */ (values.tool),
506
+ session: /** @type {string | undefined} */ (values.session),
507
+ timeout: values.timeout !== undefined ? Number(values.timeout) : undefined,
508
+ tail: values.tail !== undefined ? Number(values.tail) : undefined,
509
+ limit: values.limit !== undefined ? Number(values.limit) : undefined,
510
+ branch: /** @type {string | undefined} */ (values.branch),
511
+ },
512
+ positionals,
513
+ };
514
+ }
515
+
343
516
  // Helpers - session tracking
344
517
  // =============================================================================
345
518
 
@@ -354,14 +527,18 @@ function parseSessionName(session) {
354
527
  const tool = match[1].toLowerCase();
355
528
  const rest = match[2];
356
529
 
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] };
530
+ // Archangel: {tool}-archangel-{name}-{uuid}
531
+ const archangelMatch = rest.match(
532
+ /^archangel-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i,
533
+ );
534
+ if (archangelMatch) {
535
+ return { tool, archangelName: archangelMatch[1], uuid: archangelMatch[2] };
361
536
  }
362
537
 
363
538
  // 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);
539
+ const partnerMatch = rest.match(
540
+ /^partner-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i,
541
+ );
365
542
  if (partnerMatch) {
366
543
  return { tool, uuid: partnerMatch[1] };
367
544
  }
@@ -378,6 +555,16 @@ function generateSessionName(tool) {
378
555
  return `${tool}-partner-${randomUUID()}`;
379
556
  }
380
557
 
558
+ /**
559
+ * Quick hash for change detection (not cryptographic).
560
+ * @param {string | null | undefined} str
561
+ * @returns {string | null}
562
+ */
563
+ function quickHash(str) {
564
+ if (!str) return null;
565
+ return createHash("md5").update(str).digest("hex").slice(0, 8);
566
+ }
567
+
381
568
  /**
382
569
  * @param {string} cwd
383
570
  * @returns {string}
@@ -394,9 +581,13 @@ function getClaudeProjectPath(cwd) {
394
581
  */
395
582
  function getTmuxSessionCwd(sessionName) {
396
583
  try {
397
- const result = spawnSync("tmux", ["display-message", "-t", sessionName, "-p", "#{pane_current_path}"], {
398
- encoding: "utf-8",
399
- });
584
+ const result = spawnSync(
585
+ "tmux",
586
+ ["display-message", "-t", sessionName, "-p", "#{pane_current_path}"],
587
+ {
588
+ encoding: "utf-8",
589
+ },
590
+ );
400
591
  if (result.status === 0) return result.stdout.trim();
401
592
  } catch (err) {
402
593
  debugError("getTmuxSessionCwd", err);
@@ -420,7 +611,9 @@ function findClaudeLogPath(sessionId, sessionName) {
420
611
  if (existsSync(indexPath)) {
421
612
  try {
422
613
  const index = JSON.parse(readFileSync(indexPath, "utf-8"));
423
- const entry = index.entries?.find(/** @param {{sessionId: string, fullPath?: string}} e */ (e) => e.sessionId === sessionId);
614
+ const entry = index.entries?.find(
615
+ /** @param {{sessionId: string, fullPath?: string}} e */ (e) => e.sessionId === sessionId,
616
+ );
424
617
  if (entry?.fullPath) return entry.fullPath;
425
618
  } catch (err) {
426
619
  debugError("findClaudeLogPath", err);
@@ -442,9 +635,13 @@ function findCodexLogPath(sessionName) {
442
635
  // For Codex, we need to match by timing since we can't control the session ID
443
636
  // Get tmux session creation time
444
637
  try {
445
- const result = spawnSync("tmux", ["display-message", "-t", sessionName, "-p", "#{session_created}"], {
446
- encoding: "utf-8",
447
- });
638
+ const result = spawnSync(
639
+ "tmux",
640
+ ["display-message", "-t", sessionName, "-p", "#{session_created}"],
641
+ {
642
+ encoding: "utf-8",
643
+ },
644
+ );
448
645
  if (result.status !== 0) return null;
449
646
  const createdTs = parseInt(result.stdout.trim(), 10) * 1000; // tmux gives seconds, we need ms
450
647
  if (isNaN(createdTs)) return null;
@@ -478,7 +675,11 @@ function findCodexLogPath(sessionName) {
478
675
  // Log file should be created shortly after session start
479
676
  // Allow small negative diff (-2s) for clock skew, up to 60s for slow starts
480
677
  if (diff >= -2000 && diff < 60000) {
481
- candidates.push({ file, diff: Math.abs(diff), path: path.join(dayDir, file) });
678
+ candidates.push({
679
+ file,
680
+ diff: Math.abs(diff),
681
+ path: path.join(dayDir, file),
682
+ });
482
683
  }
483
684
  }
484
685
 
@@ -491,6 +692,92 @@ function findCodexLogPath(sessionName) {
491
692
  }
492
693
  }
493
694
 
695
+ /**
696
+ * @typedef {Object} SessionMeta
697
+ * @property {string | null} slug - Plan identifier (if plan is active)
698
+ * @property {Array<{content: string, status: string, id?: string}> | null} todos - Current todos
699
+ * @property {string | null} permissionMode - "default", "acceptEdits", "plan"
700
+ * @property {string | null} gitBranch - Current git branch
701
+ * @property {string | null} cwd - Working directory
702
+ */
703
+
704
+ /**
705
+ * Get metadata from a Claude session's JSONL file.
706
+ * Returns null for Codex sessions (different format, no equivalent metadata).
707
+ * @param {string} sessionName - The tmux session name
708
+ * @returns {SessionMeta | null}
709
+ */
710
+ function getSessionMeta(sessionName) {
711
+ const parsed = parseSessionName(sessionName);
712
+ if (!parsed) return null;
713
+
714
+ // Only Claude sessions have this metadata
715
+ if (parsed.tool !== "claude") return null;
716
+ if (!parsed.uuid) return null;
717
+
718
+ const logPath = findClaudeLogPath(parsed.uuid, sessionName);
719
+ if (!logPath || !existsSync(logPath)) return null;
720
+
721
+ try {
722
+ const content = readFileSync(logPath, "utf-8");
723
+ const lines = content.trim().split("\n").filter(Boolean);
724
+
725
+ // Read from end to find most recent entry with metadata
726
+ for (let i = lines.length - 1; i >= 0; i--) {
727
+ try {
728
+ const entry = JSON.parse(lines[i]);
729
+ // User entries typically have the metadata fields
730
+ if (entry.type === "user" || entry.slug || entry.gitBranch) {
731
+ return {
732
+ slug: entry.slug || null,
733
+ todos: entry.todos || null,
734
+ permissionMode: entry.permissionMode || null,
735
+ gitBranch: entry.gitBranch || null,
736
+ cwd: entry.cwd || null,
737
+ };
738
+ }
739
+ } catch {
740
+ // Skip malformed lines
741
+ }
742
+ }
743
+ return null;
744
+ } catch (err) {
745
+ debugError("getSessionMeta", err);
746
+ return null;
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Read a plan file by its slug.
752
+ * @param {string} slug - The plan slug (e.g., "curious-roaming-pascal")
753
+ * @returns {string | null} The plan content or null if not found
754
+ */
755
+ function readPlanFile(slug) {
756
+ const planPath = path.join(CLAUDE_CONFIG_DIR, "plans", `${slug}.md`);
757
+ try {
758
+ if (existsSync(planPath)) {
759
+ return readFileSync(planPath, "utf-8");
760
+ }
761
+ } catch (err) {
762
+ debugError("readPlanFile", err);
763
+ }
764
+ return null;
765
+ }
766
+
767
+ /**
768
+ * Format todos for display in a prompt.
769
+ * @param {Array<{content: string, status: string, id?: string}>} todos
770
+ * @returns {string}
771
+ */
772
+ function formatTodos(todos) {
773
+ if (!todos || todos.length === 0) return "";
774
+ return todos
775
+ .map((t) => {
776
+ const status = t.status === "completed" ? "[x]" : t.status === "in_progress" ? "[>]" : "[ ]";
777
+ return `${status} ${t.content || "(no content)"}`;
778
+ })
779
+ .join("\n");
780
+ }
494
781
 
495
782
  /**
496
783
  * Extract assistant text responses from a JSONL log file.
@@ -537,6 +824,130 @@ function getAssistantText(logPath, index = 0) {
537
824
  }
538
825
  }
539
826
 
827
+ /**
828
+ * Read new complete JSON lines from a log file since the given offset.
829
+ * @param {string | null} logPath
830
+ * @param {number} fromOffset
831
+ * @returns {{ entries: object[], newOffset: number }}
832
+ */
833
+ function tailJsonl(logPath, fromOffset) {
834
+ if (!logPath || !existsSync(logPath)) {
835
+ return { entries: [], newOffset: fromOffset };
836
+ }
837
+
838
+ const stats = statSync(logPath);
839
+ if (stats.size <= fromOffset) {
840
+ return { entries: [], newOffset: fromOffset };
841
+ }
842
+
843
+ const fd = openSync(logPath, "r");
844
+ const buffer = Buffer.alloc(stats.size - fromOffset);
845
+ readSync(fd, buffer, 0, buffer.length, fromOffset);
846
+ closeSync(fd);
847
+
848
+ const text = buffer.toString("utf-8");
849
+ const lines = text.split("\n");
850
+
851
+ // Last line may be incomplete - don't parse it yet
852
+ const complete = lines.slice(0, -1).filter(Boolean);
853
+ const incomplete = lines[lines.length - 1];
854
+
855
+ const entries = [];
856
+ for (const line of complete) {
857
+ try {
858
+ entries.push(JSON.parse(line));
859
+ } catch {
860
+ // Skip malformed lines
861
+ }
862
+ }
863
+
864
+ // Offset advances by complete lines only
865
+ const newOffset = fromOffset + text.length - incomplete.length;
866
+ return { entries, newOffset };
867
+ }
868
+
869
+ /**
870
+ * @typedef {{command?: string, file_path?: string, path?: string, pattern?: string}} ToolInput
871
+ */
872
+
873
+ /**
874
+ * Format a JSONL entry for streaming display.
875
+ * @param {{type?: string, message?: {content?: Array<{type?: string, text?: string, name?: string, input?: ToolInput, tool?: string, arguments?: ToolInput}>}}} entry
876
+ * @returns {string | null}
877
+ */
878
+ function formatEntry(entry) {
879
+ // Skip tool_result entries (they can be very verbose)
880
+ if (entry.type === "tool_result") return null;
881
+
882
+ // Only process assistant entries
883
+ if (entry.type !== "assistant") return null;
884
+
885
+ const parts = entry.message?.content || [];
886
+ const output = [];
887
+
888
+ for (const part of parts) {
889
+ if (part.type === "text" && part.text) {
890
+ output.push(part.text);
891
+ } else if (part.type === "tool_use" || part.type === "tool_call") {
892
+ const name = part.name || part.tool || "tool";
893
+ const input = part.input || part.arguments || {};
894
+ let summary;
895
+ if (name === "Bash" && input.command) {
896
+ summary = input.command.slice(0, 50);
897
+ } else {
898
+ const target = input.file_path || input.path || input.pattern || "";
899
+ summary = target.split("/").pop() || target.slice(0, 30);
900
+ }
901
+ output.push(`> ${name}(${summary})`);
902
+ }
903
+ // Skip thinking blocks - internal reasoning
904
+ }
905
+
906
+ return output.length > 0 ? output.join("\n") : null;
907
+ }
908
+
909
+ /**
910
+ * Extract pending tool from confirmation screen.
911
+ * @param {string} screen
912
+ * @returns {string | null}
913
+ */
914
+ function extractPendingToolFromScreen(screen) {
915
+ const lines = screen.split("\n");
916
+
917
+ // Check recent lines for tool confirmation patterns
918
+ for (let i = lines.length - 1; i >= Math.max(0, lines.length - 15); i--) {
919
+ const line = lines[i];
920
+ // Match tool confirmation patterns like "Bash: command" or "Write: /path/file"
921
+ const match = line.match(
922
+ /^\s*(Bash|Write|Edit|Read|Glob|Grep|Task|WebFetch|WebSearch|NotebookEdit|Skill|TodoWrite|TodoRead):\s*(.{1,40})/,
923
+ );
924
+ if (match) {
925
+ return `${match[1]}: ${match[2].trim()}`;
926
+ }
927
+ }
928
+
929
+ return null;
930
+ }
931
+
932
+ /**
933
+ * Format confirmation output with helpful commands
934
+ * @param {string} screen
935
+ * @param {Agent} _agent
936
+ * @returns {string}
937
+ */
938
+ function formatConfirmationOutput(screen, _agent) {
939
+ const pendingTool = extractPendingToolFromScreen(screen);
940
+ const cli = path.basename(process.argv[1], ".js");
941
+
942
+ let output = pendingTool || "Confirmation required";
943
+ output += "\n\ne.g.";
944
+ output += `\n ${cli} approve # for y/n prompts`;
945
+ output += `\n ${cli} reject`;
946
+ output += `\n ${cli} select N # for numbered menus`;
947
+
948
+ return output;
949
+ }
950
+
540
951
  /**
541
952
  * @returns {string[]}
542
953
  */
@@ -562,15 +973,15 @@ function resolveSessionName(partial) {
562
973
  // Exact match
563
974
  if (agentSessions.includes(partial)) return partial;
564
975
 
565
- // Daemon name match (e.g., "reviewer" matches "claude-daemon-reviewer-uuid")
566
- const daemonMatches = agentSessions.filter((s) => {
976
+ // Archangel name match (e.g., "reviewer" matches "claude-archangel-reviewer-uuid")
977
+ const archangelMatches = agentSessions.filter((s) => {
567
978
  const parsed = parseSessionName(s);
568
- return parsed?.daemonName === partial;
979
+ return parsed?.archangelName === partial;
569
980
  });
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}`);
981
+ if (archangelMatches.length === 1) return archangelMatches[0];
982
+ if (archangelMatches.length > 1) {
983
+ console.log("ERROR: ambiguous archangel name. Matches:");
984
+ for (const m of archangelMatches) console.log(` ${m}`);
574
985
  process.exit(1);
575
986
  }
576
987
 
@@ -599,25 +1010,25 @@ function resolveSessionName(partial) {
599
1010
  }
600
1011
 
601
1012
  // =============================================================================
602
- // Helpers - daemon agents
1013
+ // Helpers - archangels
603
1014
  // =============================================================================
604
1015
 
605
1016
  /**
606
- * @returns {DaemonConfig[]}
1017
+ * @returns {ArchangelConfig[]}
607
1018
  */
608
1019
  function loadAgentConfigs() {
609
1020
  const agentsDir = AGENTS_DIR;
610
1021
  if (!existsSync(agentsDir)) return [];
611
1022
 
612
1023
  const files = readdirSync(agentsDir).filter((f) => f.endsWith(".md"));
613
- /** @type {DaemonConfig[]} */
1024
+ /** @type {ArchangelConfig[]} */
614
1025
  const configs = [];
615
1026
 
616
1027
  for (const file of files) {
617
1028
  try {
618
1029
  const content = readFileSync(path.join(agentsDir, file), "utf-8");
619
1030
  const config = parseAgentConfig(file, content);
620
- if (config && 'error' in config) {
1031
+ if (config && "error" in config) {
621
1032
  console.error(`ERROR: ${file}: ${config.error}`);
622
1033
  continue;
623
1034
  }
@@ -633,7 +1044,7 @@ function loadAgentConfigs() {
633
1044
  /**
634
1045
  * @param {string} filename
635
1046
  * @param {string} content
636
- * @returns {DaemonConfig | {error: string} | null}
1047
+ * @returns {ArchangelConfig | {error: string} | null}
637
1048
  */
638
1049
  function parseAgentConfig(filename, content) {
639
1050
  const name = filename.replace(/\.md$/, "");
@@ -648,7 +1059,9 @@ function parseAgentConfig(filename, content) {
648
1059
  return { error: `Missing frontmatter. File must start with '---'` };
649
1060
  }
650
1061
  if (!normalized.includes("\n---\n")) {
651
- return { error: `Frontmatter not closed. Add '---' on its own line after the YAML block` };
1062
+ return {
1063
+ error: `Frontmatter not closed. Add '---' on its own line after the YAML block`,
1064
+ };
652
1065
  }
653
1066
  return { error: `Invalid frontmatter format` };
654
1067
  }
@@ -669,9 +1082,13 @@ function parseAgentConfig(filename, content) {
669
1082
  const fieldName = line.trim().match(/^(\w+):/)?.[1];
670
1083
  if (fieldName && !knownFields.includes(fieldName)) {
671
1084
  // Suggest closest match
672
- const suggestions = knownFields.filter((f) => f[0] === fieldName[0] || fieldName.includes(f.slice(0, 3)));
1085
+ const suggestions = knownFields.filter(
1086
+ (f) => f[0] === fieldName[0] || fieldName.includes(f.slice(0, 3)),
1087
+ );
673
1088
  const hint = suggestions.length > 0 ? ` Did you mean '${suggestions[0]}'?` : "";
674
- return { error: `Unknown field '${fieldName}'.${hint} Valid fields: ${knownFields.join(", ")}` };
1089
+ return {
1090
+ error: `Unknown field '${fieldName}'.${hint} Valid fields: ${knownFields.join(", ")}`,
1091
+ };
675
1092
  }
676
1093
  }
677
1094
 
@@ -689,7 +1106,9 @@ function parseAgentConfig(filename, content) {
689
1106
  const rawValue = intervalMatch[1].trim();
690
1107
  const parsed = parseInt(rawValue, 10);
691
1108
  if (isNaN(parsed)) {
692
- return { error: `Invalid interval '${rawValue}'. Must be a number (seconds)` };
1109
+ return {
1110
+ error: `Invalid interval '${rawValue}'. Must be a number (seconds)`,
1111
+ };
693
1112
  }
694
1113
  interval = Math.max(10, Math.min(3600, parsed)); // Clamp to 10s - 1hr
695
1114
  }
@@ -701,16 +1120,22 @@ function parseAgentConfig(filename, content) {
701
1120
  const rawWatch = watchLine[1].trim();
702
1121
  // Must be array format
703
1122
  if (!rawWatch.startsWith("[") || !rawWatch.endsWith("]")) {
704
- return { error: `Invalid watch format. Must be an array: watch: ["src/**/*.ts"]` };
1123
+ return {
1124
+ error: `Invalid watch format. Must be an array: watch: ["src/**/*.ts"]`,
1125
+ };
705
1126
  }
706
1127
  const inner = rawWatch.slice(1, -1).trim();
707
1128
  if (!inner) {
708
- return { error: `Empty watch array. Add at least one pattern: watch: ["**/*"]` };
1129
+ return {
1130
+ error: `Empty watch array. Add at least one pattern: watch: ["**/*"]`,
1131
+ };
709
1132
  }
710
1133
  watchPatterns = inner.split(",").map((p) => p.trim().replace(/^["']|["']$/g, ""));
711
1134
  // Validate patterns aren't empty
712
1135
  if (watchPatterns.some((p) => !p)) {
713
- return { error: `Invalid watch pattern. Check for trailing commas or empty values` };
1136
+ return {
1137
+ error: `Invalid watch pattern. Check for trailing commas or empty values`,
1138
+ };
714
1139
  }
715
1140
  }
716
1141
 
@@ -718,11 +1143,11 @@ function parseAgentConfig(filename, content) {
718
1143
  }
719
1144
 
720
1145
  /**
721
- * @param {DaemonConfig} config
1146
+ * @param {ArchangelConfig} config
722
1147
  * @returns {string}
723
1148
  */
724
- function getDaemonSessionPattern(config) {
725
- return `${config.tool}-daemon-${config.name}`;
1149
+ function getArchangelSessionPattern(config) {
1150
+ return `${config.tool}-archangel-${config.name}`;
726
1151
  }
727
1152
 
728
1153
  // =============================================================================
@@ -826,7 +1251,9 @@ function gcMailbox(maxAgeHours = 24) {
826
1251
  /** @returns {string} */
827
1252
  function getCurrentBranch() {
828
1253
  try {
829
- return execSync("git branch --show-current 2>/dev/null", { encoding: "utf-8" }).trim();
1254
+ return execSync("git branch --show-current 2>/dev/null", {
1255
+ encoding: "utf-8",
1256
+ }).trim();
830
1257
  } catch {
831
1258
  return "unknown";
832
1259
  }
@@ -835,7 +1262,9 @@ function getCurrentBranch() {
835
1262
  /** @returns {string} */
836
1263
  function getCurrentCommit() {
837
1264
  try {
838
- return execSync("git rev-parse --short HEAD 2>/dev/null", { encoding: "utf-8" }).trim();
1265
+ return execSync("git rev-parse --short HEAD 2>/dev/null", {
1266
+ encoding: "utf-8",
1267
+ }).trim();
839
1268
  } catch {
840
1269
  return "unknown";
841
1270
  }
@@ -859,7 +1288,9 @@ function getMainBranch() {
859
1288
  /** @returns {string} */
860
1289
  function getStagedDiff() {
861
1290
  try {
862
- return execSync("git diff --cached 2>/dev/null", { encoding: "utf-8" }).trim();
1291
+ return execSync("git diff --cached 2>/dev/null", {
1292
+ encoding: "utf-8",
1293
+ }).trim();
863
1294
  } catch {
864
1295
  return "";
865
1296
  }
@@ -884,20 +1315,18 @@ function getRecentCommitsDiff(hoursAgo = 4) {
884
1315
  const since = `--since="${hoursAgo} hours ago"`;
885
1316
 
886
1317
  // 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();
1318
+ const commits = execSync(`git log ${mainBranch}..HEAD ${since} --oneline 2>/dev/null`, {
1319
+ encoding: "utf-8",
1320
+ }).trim();
891
1321
 
892
1322
  if (!commits) return "";
893
1323
 
894
1324
  // Get diff for those commits
895
1325
  const firstCommit = commits.split("\n").filter(Boolean).pop()?.split(" ")[0];
896
1326
  if (!firstCommit) return "";
897
- return execSync(
898
- `git diff ${firstCommit}^..HEAD 2>/dev/null`,
899
- { encoding: "utf-8" }
900
- ).trim();
1327
+ return execSync(`git diff ${firstCommit}^..HEAD 2>/dev/null`, {
1328
+ encoding: "utf-8",
1329
+ }).trim();
901
1330
  } catch {
902
1331
  return "";
903
1332
  }
@@ -912,7 +1341,10 @@ function truncateDiff(diff, maxLines = 200) {
912
1341
  if (!diff) return "";
913
1342
  const lines = diff.split("\n");
914
1343
  if (lines.length <= maxLines) return diff;
915
- return lines.slice(0, maxLines).join("\n") + `\n\n... (truncated, ${lines.length - maxLines} more lines)`;
1344
+ return (
1345
+ lines.slice(0, maxLines).join("\n") +
1346
+ `\n\n... (truncated, ${lines.length - maxLines} more lines)`
1347
+ );
916
1348
  }
917
1349
 
918
1350
  /**
@@ -945,9 +1377,9 @@ function buildGitContext(hoursAgo = 4, maxLinesPerSection = 200) {
945
1377
  // Helpers - parent session context
946
1378
  // =============================================================================
947
1379
 
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";
1380
+ // Environment variables used to pass parent session info to archangels
1381
+ const AX_ARCHANGEL_PARENT_SESSION_ENV = "AX_ARCHANGEL_PARENT_SESSION";
1382
+ const AX_ARCHANGEL_PARENT_UUID_ENV = "AX_ARCHANGEL_PARENT_UUID";
951
1383
 
952
1384
  /**
953
1385
  * @returns {ParentSession | null}
@@ -957,7 +1389,7 @@ function findCurrentClaudeSession() {
957
1389
  const current = tmuxCurrentSession();
958
1390
  if (current) {
959
1391
  const parsed = parseSessionName(current);
960
- if (parsed?.tool === "claude" && !parsed.daemonName && parsed.uuid) {
1392
+ if (parsed?.tool === "claude" && !parsed.archangelName && parsed.uuid) {
961
1393
  return { session: current, uuid: parsed.uuid };
962
1394
  }
963
1395
  }
@@ -974,7 +1406,7 @@ function findCurrentClaudeSession() {
974
1406
  for (const session of sessions) {
975
1407
  const parsed = parseSessionName(session);
976
1408
  if (!parsed || parsed.tool !== "claude") continue;
977
- if (parsed.daemonName) continue;
1409
+ if (parsed.archangelName) continue;
978
1410
  if (!parsed.uuid) continue;
979
1411
 
980
1412
  const sessionCwd = getTmuxSessionCwd(session);
@@ -997,18 +1429,23 @@ function findCurrentClaudeSession() {
997
1429
  const claudeProjectDir = path.join(CLAUDE_CONFIG_DIR, "projects", projectPath);
998
1430
  if (existsSync(claudeProjectDir)) {
999
1431
  try {
1000
- const files = readdirSync(claudeProjectDir).filter(f => f.endsWith(".jsonl"));
1432
+ const files = readdirSync(claudeProjectDir).filter((f) => f.endsWith(".jsonl"));
1001
1433
  for (const file of files) {
1002
1434
  const uuid = file.replace(".jsonl", "");
1003
1435
  // Skip if we already have this from tmux sessions
1004
- if (candidates.some(c => c.uuid === uuid)) continue;
1436
+ if (candidates.some((c) => c.uuid === uuid)) continue;
1005
1437
 
1006
1438
  const logPath = path.join(claudeProjectDir, file);
1007
1439
  try {
1008
1440
  const stat = statSync(logPath);
1009
1441
  // Only consider logs modified in the last hour (active sessions)
1010
1442
  if (Date.now() - stat.mtimeMs < MAILBOX_MAX_AGE_MS) {
1011
- candidates.push({ session: null, uuid, mtime: stat.mtimeMs, logPath });
1443
+ candidates.push({
1444
+ session: null,
1445
+ uuid,
1446
+ mtime: stat.mtimeMs,
1447
+ logPath,
1448
+ });
1012
1449
  }
1013
1450
  } catch (err) {
1014
1451
  debugError("findCurrentClaudeSession:logStat", err);
@@ -1030,15 +1467,15 @@ function findCurrentClaudeSession() {
1030
1467
  * @returns {ParentSession | null}
1031
1468
  */
1032
1469
  function findParentSession() {
1033
- // First check if parent session was passed via environment (for daemons)
1034
- const envUuid = process.env[AX_DAEMON_PARENT_UUID_ENV];
1470
+ // First check if parent session was passed via environment (for archangels)
1471
+ const envUuid = process.env[AX_ARCHANGEL_PARENT_UUID_ENV];
1035
1472
  if (envUuid) {
1036
1473
  // Session name is optional (may be null for non-tmux sessions)
1037
- const envSession = process.env[AX_DAEMON_PARENT_SESSION_ENV] || null;
1474
+ const envSession = process.env[AX_ARCHANGEL_PARENT_SESSION_ENV] || null;
1038
1475
  return { session: envSession, uuid: envUuid };
1039
1476
  }
1040
1477
 
1041
- // Fallback to detecting current session (shouldn't be needed for daemons)
1478
+ // Fallback to detecting current session (shouldn't be needed for archangels)
1042
1479
  return findCurrentClaudeSession();
1043
1480
  }
1044
1481
 
@@ -1080,7 +1517,9 @@ function getParentSessionContext(maxEntries = 20) {
1080
1517
  if (typeof c === "string" && c.length > 10) {
1081
1518
  entries.push({ type: "user", text: c });
1082
1519
  } else if (Array.isArray(c)) {
1083
- const text = c.find(/** @param {{type: string, text?: string}} x */ (x) => x.type === "text")?.text;
1520
+ const text = c.find(
1521
+ /** @param {{type: string, text?: string}} x */ (x) => x.type === "text",
1522
+ )?.text;
1084
1523
  if (text && text.length > 10) {
1085
1524
  entries.push({ type: "user", text });
1086
1525
  }
@@ -1088,7 +1527,10 @@ function getParentSessionContext(maxEntries = 20) {
1088
1527
  } else if (entry.type === "assistant") {
1089
1528
  /** @type {{type: string, text?: string}[]} */
1090
1529
  const parts = entry.message?.content || [];
1091
- const text = parts.filter((p) => p.type === "text").map((p) => p.text || "").join("\n");
1530
+ const text = parts
1531
+ .filter((p) => p.type === "text")
1532
+ .map((p) => p.text || "")
1533
+ .join("\n");
1092
1534
  // Only include assistant responses with meaningful text
1093
1535
  if (text && text.length > 20) {
1094
1536
  entries.push({ type: "assistant", text });
@@ -1100,7 +1542,7 @@ function getParentSessionContext(maxEntries = 20) {
1100
1542
  }
1101
1543
 
1102
1544
  // Format recent conversation
1103
- const formatted = entries.slice(-maxEntries).map(e => {
1545
+ const formatted = entries.slice(-maxEntries).map((e) => {
1104
1546
  const preview = e.text.slice(0, 500).replace(/\n/g, " ");
1105
1547
  return `**${e.type === "user" ? "User" : "Assistant"}**: ${preview}`;
1106
1548
  });
@@ -1143,10 +1585,16 @@ function extractFileEditContext(logPath, filePath) {
1143
1585
 
1144
1586
  // Parse all entries
1145
1587
  /** @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);
1588
+ const entries = lines
1589
+ .map((line, idx) => {
1590
+ try {
1591
+ return { idx, ...JSON.parse(line) };
1592
+ } catch (err) {
1593
+ debugError("extractFileEditContext:parse", err);
1594
+ return null;
1595
+ }
1596
+ })
1597
+ .filter(Boolean);
1150
1598
 
1151
1599
  // Find Write/Edit tool calls for this file (scan backwards, want most recent)
1152
1600
  /** @type {any} */
@@ -1159,9 +1607,10 @@ function extractFileEditContext(logPath, filePath) {
1159
1607
 
1160
1608
  /** @type {any[]} */
1161
1609
  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")
1610
+ const toolCalls = msgContent.filter(
1611
+ (/** @type {any} */ c) =>
1612
+ (c.type === "tool_use" || c.type === "tool_call") &&
1613
+ (c.name === "Write" || c.name === "Edit"),
1165
1614
  );
1166
1615
 
1167
1616
  for (const tc of toolCalls) {
@@ -1212,8 +1661,9 @@ function extractFileEditContext(logPath, filePath) {
1212
1661
 
1213
1662
  /** @type {any[]} */
1214
1663
  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"
1664
+ const readCalls = msgContent.filter(
1665
+ (/** @type {any} */ c) =>
1666
+ (c.type === "tool_use" || c.type === "tool_call") && c.name === "Read",
1217
1667
  );
1218
1668
 
1219
1669
  for (const rc of readCalls) {
@@ -1228,9 +1678,10 @@ function extractFileEditContext(logPath, filePath) {
1228
1678
  if (entry.type !== "assistant") continue;
1229
1679
  /** @type {any[]} */
1230
1680
  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")
1681
+ const edits = msgContent.filter(
1682
+ (/** @type {any} */ c) =>
1683
+ (c.type === "tool_use" || c.type === "tool_call") &&
1684
+ (c.name === "Write" || c.name === "Edit"),
1234
1685
  );
1235
1686
  for (const e of edits) {
1236
1687
  const input = e.input || e.arguments || {};
@@ -1245,11 +1696,11 @@ function extractFileEditContext(logPath, filePath) {
1245
1696
  toolCall: {
1246
1697
  name: editEntry.toolCall.name,
1247
1698
  input: editEntry.toolCall.input || editEntry.toolCall.arguments,
1248
- id: editEntry.toolCall.id
1699
+ id: editEntry.toolCall.id,
1249
1700
  },
1250
1701
  subsequentErrors,
1251
1702
  readsBefore: [...new Set(readsBefore)].slice(0, 10),
1252
- editSequence
1703
+ editSequence,
1253
1704
  };
1254
1705
  }
1255
1706
 
@@ -1343,10 +1794,11 @@ function watchForChanges(patterns, callback) {
1343
1794
  }
1344
1795
  }
1345
1796
 
1346
- return () => { for (const w of watchers) w.close(); };
1797
+ return () => {
1798
+ for (const w of watchers) w.close();
1799
+ };
1347
1800
  }
1348
1801
 
1349
-
1350
1802
  // =============================================================================
1351
1803
  // State
1352
1804
  // =============================================================================
@@ -1368,7 +1820,7 @@ const State = {
1368
1820
  * @param {string} config.promptSymbol - Symbol indicating ready state
1369
1821
  * @param {string[]} [config.spinners] - Spinner characters indicating thinking
1370
1822
  * @param {RegExp} [config.rateLimitPattern] - Pattern for rate limit detection
1371
- * @param {string[]} [config.thinkingPatterns] - Text patterns indicating thinking
1823
+ * @param {(string | RegExp | ((lines: string) => boolean))[]} [config.thinkingPatterns] - Text patterns indicating thinking
1372
1824
  * @param {(string | ((lines: string) => boolean))[]} [config.confirmPatterns] - Patterns for confirmation dialogs
1373
1825
  * @param {{screen: string[], lastLines: string[]} | null} [config.updatePromptPatterns] - Patterns for update prompts
1374
1826
  * @returns {string} The detected state
@@ -1381,31 +1833,12 @@ function detectState(screen, config) {
1381
1833
  // Larger range for confirmation detection (catches dialogs that scrolled slightly)
1382
1834
  const recentLines = lines.slice(-15).join("\n");
1383
1835
 
1384
- // Rate limited - check full screen (rate limit messages can appear anywhere)
1385
- if (config.rateLimitPattern && config.rateLimitPattern.test(screen)) {
1836
+ // Rate limited - check recent lines (not full screen to avoid matching historical output)
1837
+ if (config.rateLimitPattern && config.rateLimitPattern.test(recentLines)) {
1386
1838
  return State.RATE_LIMITED;
1387
1839
  }
1388
1840
 
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)
1841
+ // Confirming - check before THINKING because "Running…" in tool output matches thinking patterns
1409
1842
  const confirmPatterns = config.confirmPatterns || [];
1410
1843
  for (const pattern of confirmPatterns) {
1411
1844
  if (typeof pattern === "function") {
@@ -1418,13 +1851,14 @@ function detectState(screen, config) {
1418
1851
  }
1419
1852
  }
1420
1853
 
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
1854
+ // Ready - check BEFORE thinking to avoid false positives from timing messages like "✻ Worked for 45s"
1855
+ // If the prompt symbol is visible, the agent is ready regardless of spinner characters in timing messages
1423
1856
  if (lastLines.includes(config.promptSymbol)) {
1424
1857
  // Check if any line has the prompt followed by pasted content indicator
1858
+ // "[Pasted text" indicates user has pasted content and Claude is still processing
1425
1859
  const linesArray = lastLines.split("\n");
1426
1860
  const promptWithPaste = linesArray.some(
1427
- (l) => l.includes(config.promptSymbol) && l.includes("[Pasted text")
1861
+ (l) => l.includes(config.promptSymbol) && l.includes("[Pasted text"),
1428
1862
  );
1429
1863
  if (!promptWithPaste) {
1430
1864
  return State.READY;
@@ -1432,6 +1866,31 @@ function detectState(screen, config) {
1432
1866
  // If prompt has pasted content, Claude is still processing - not ready yet
1433
1867
  }
1434
1868
 
1869
+ // Thinking - spinners (check last lines only)
1870
+ const spinners = config.spinners || [];
1871
+ if (spinners.some((s) => lastLines.includes(s))) {
1872
+ return State.THINKING;
1873
+ }
1874
+ // Thinking - text patterns (last lines) - supports strings, regexes, and functions
1875
+ const thinkingPatterns = config.thinkingPatterns || [];
1876
+ if (
1877
+ thinkingPatterns.some((p) => {
1878
+ if (typeof p === "function") return p(lastLines);
1879
+ if (p instanceof RegExp) return p.test(lastLines);
1880
+ return lastLines.includes(p);
1881
+ })
1882
+ ) {
1883
+ return State.THINKING;
1884
+ }
1885
+
1886
+ // Update prompt
1887
+ if (config.updatePromptPatterns) {
1888
+ const { screen: sp, lastLines: lp } = config.updatePromptPatterns;
1889
+ if (sp && sp.some((p) => screen.includes(p)) && lp && lp.some((p) => lastLines.includes(p))) {
1890
+ return State.UPDATE_PROMPT;
1891
+ }
1892
+ }
1893
+
1435
1894
  return State.STARTING;
1436
1895
  }
1437
1896
 
@@ -1452,12 +1911,13 @@ function detectState(screen, config) {
1452
1911
  /**
1453
1912
  * @typedef {Object} AgentConfigInput
1454
1913
  * @property {string} name
1914
+ * @property {string} displayName
1455
1915
  * @property {string} startCommand
1456
1916
  * @property {string} yoloCommand
1457
1917
  * @property {string} promptSymbol
1458
1918
  * @property {string[]} [spinners]
1459
1919
  * @property {RegExp} [rateLimitPattern]
1460
- * @property {string[]} [thinkingPatterns]
1920
+ * @property {(string | RegExp | ((lines: string) => boolean))[]} [thinkingPatterns]
1461
1921
  * @property {ConfirmPattern[]} [confirmPatterns]
1462
1922
  * @property {UpdatePromptPatterns | null} [updatePromptPatterns]
1463
1923
  * @property {string[]} [responseMarkers]
@@ -1467,6 +1927,8 @@ function detectState(screen, config) {
1467
1927
  * @property {string} [approveKey]
1468
1928
  * @property {string} [rejectKey]
1469
1929
  * @property {string} [safeAllowedTools]
1930
+ * @property {string | null} [sessionIdFlag]
1931
+ * @property {((sessionName: string) => string | null) | null} [logPathFinder]
1470
1932
  */
1471
1933
 
1472
1934
  class Agent {
@@ -1477,6 +1939,8 @@ class Agent {
1477
1939
  /** @type {string} */
1478
1940
  this.name = config.name;
1479
1941
  /** @type {string} */
1942
+ this.displayName = config.displayName;
1943
+ /** @type {string} */
1480
1944
  this.startCommand = config.startCommand;
1481
1945
  /** @type {string} */
1482
1946
  this.yoloCommand = config.yoloCommand;
@@ -1486,7 +1950,7 @@ class Agent {
1486
1950
  this.spinners = config.spinners || [];
1487
1951
  /** @type {RegExp | undefined} */
1488
1952
  this.rateLimitPattern = config.rateLimitPattern;
1489
- /** @type {string[]} */
1953
+ /** @type {(string | RegExp | ((lines: string) => boolean))[]} */
1490
1954
  this.thinkingPatterns = config.thinkingPatterns || [];
1491
1955
  /** @type {ConfirmPattern[]} */
1492
1956
  this.confirmPatterns = config.confirmPatterns || [];
@@ -1506,6 +1970,10 @@ class Agent {
1506
1970
  this.rejectKey = config.rejectKey || "n";
1507
1971
  /** @type {string | undefined} */
1508
1972
  this.safeAllowedTools = config.safeAllowedTools;
1973
+ /** @type {string | null} */
1974
+ this.sessionIdFlag = config.sessionIdFlag || null;
1975
+ /** @type {((sessionName: string) => string | null) | null} */
1976
+ this.logPathFinder = config.logPathFinder || null;
1509
1977
  }
1510
1978
 
1511
1979
  /**
@@ -1523,12 +1991,9 @@ class Agent {
1523
1991
  } else {
1524
1992
  base = this.startCommand;
1525
1993
  }
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
- }
1994
+ // Some agents support session ID flags for deterministic session tracking
1995
+ if (this.sessionIdFlag && sessionName) {
1996
+ return `${base} ${this.sessionIdFlag} ${sessionName}`;
1532
1997
  }
1533
1998
  return base;
1534
1999
  }
@@ -1540,7 +2005,7 @@ class Agent {
1540
2005
  }
1541
2006
 
1542
2007
  const cwd = process.cwd();
1543
- const childPattern = new RegExp(`^${this.name}-[0-9a-f-]{36}$`, "i");
2008
+ const childPattern = new RegExp(`^${this.name}-(partner-)?[0-9a-f-]{36}$`, "i");
1544
2009
 
1545
2010
  // If inside tmux, look for existing agent session in same cwd
1546
2011
  const current = tmuxCurrentSession();
@@ -1585,13 +2050,8 @@ class Agent {
1585
2050
  * @returns {string | null}
1586
2051
  */
1587
2052
  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);
2053
+ if (this.logPathFinder) {
2054
+ return this.logPathFinder(sessionName);
1595
2055
  }
1596
2056
  return null;
1597
2057
  }
@@ -1635,7 +2095,12 @@ class Agent {
1635
2095
  if (/^(run|execute|create|delete|modify|write)/i.test(line)) return line;
1636
2096
  }
1637
2097
 
1638
- return lines.filter((l) => l && !l.match(/^[╭╮╰╯│─]+$/)).slice(0, 2).join(" | ") || "action";
2098
+ return (
2099
+ lines
2100
+ .filter((l) => l && !l.match(/^[╭╮╰╯│─]+$/))
2101
+ .slice(0, 2)
2102
+ .join(" | ") || "action"
2103
+ );
1639
2104
  }
1640
2105
 
1641
2106
  /**
@@ -1711,7 +2176,9 @@ class Agent {
1711
2176
 
1712
2177
  // Fallback: extract after last prompt
1713
2178
  if (filtered.length === 0) {
1714
- const lastPromptIdx = lines.findLastIndex((/** @type {string} */ l) => l.startsWith(this.promptSymbol));
2179
+ const lastPromptIdx = lines.findLastIndex((/** @type {string} */ l) =>
2180
+ l.startsWith(this.promptSymbol),
2181
+ );
1715
2182
  if (lastPromptIdx >= 0 && lastPromptIdx < lines.length - 1) {
1716
2183
  const afterPrompt = lines
1717
2184
  .slice(lastPromptIdx + 1)
@@ -1725,14 +2192,14 @@ class Agent {
1725
2192
  // This handles the case where Claude finished and shows a new empty prompt
1726
2193
  if (lastPromptIdx >= 0) {
1727
2194
  const lastPromptLine = lines[lastPromptIdx];
1728
- const isEmptyPrompt = lastPromptLine.trim() === this.promptSymbol ||
1729
- lastPromptLine.match(/^❯\s*$/);
2195
+ const isEmptyPrompt =
2196
+ lastPromptLine.trim() === this.promptSymbol || lastPromptLine.match(/^❯\s*$/);
1730
2197
  if (isEmptyPrompt) {
1731
2198
  // Find the previous prompt (user's input) and extract content between
1732
2199
  // 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
- );
2200
+ const prevPromptIdx = lines
2201
+ .slice(0, lastPromptIdx)
2202
+ .findLastIndex((/** @type {string} */ l) => l.startsWith(this.promptSymbol));
1736
2203
  if (prevPromptIdx >= 0) {
1737
2204
  const betweenPrompts = lines
1738
2205
  .slice(prevPromptIdx + 1, lastPromptIdx)
@@ -1753,23 +2220,25 @@ class Agent {
1753
2220
  * @returns {string}
1754
2221
  */
1755
2222
  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();
2223
+ return (
2224
+ response
2225
+ // Remove tool call lines (Search, Read, Grep, etc.)
2226
+ .replace(/^[⏺•]\s*(Search|Read|Grep|Glob|Write|Edit|Bash)\([^)]*\).*$/gm, "")
2227
+ // Remove tool result lines
2228
+ .replace(/^⎿\s+.*$/gm, "")
2229
+ // Remove "Sautéed for Xs" timing lines
2230
+ .replace(/^✻\s+Sautéed for.*$/gm, "")
2231
+ // Remove expand hints
2232
+ .replace(/\(ctrl\+o to expand\)/g, "")
2233
+ // Clean up multiple blank lines
2234
+ .replace(/\n{3,}/g, "\n\n")
2235
+ // Original cleanup
2236
+ .replace(/^[•⏺-]\s*/, "")
2237
+ .replace(/^\*\*(.+)\*\*/, "$1")
2238
+ .replace(/\n /g, "\n")
2239
+ .replace(/─+\s*$/, "")
2240
+ .trim()
2241
+ );
1773
2242
  }
1774
2243
 
1775
2244
  /**
@@ -1810,6 +2279,7 @@ class Agent {
1810
2279
 
1811
2280
  const CodexAgent = new Agent({
1812
2281
  name: "codex",
2282
+ displayName: "Codex",
1813
2283
  startCommand: "codex --sandbox read-only",
1814
2284
  yoloCommand: "codex --dangerously-bypass-approvals-and-sandbox",
1815
2285
  promptSymbol: "›",
@@ -1829,6 +2299,7 @@ const CodexAgent = new Agent({
1829
2299
  chromePatterns: ["context left", "for shortcuts"],
1830
2300
  reviewOptions: { pr: "1", uncommitted: "2", commit: "3", custom: "4" },
1831
2301
  envVar: "AX_SESSION",
2302
+ logPathFinder: findCodexLogPath,
1832
2303
  });
1833
2304
 
1834
2305
  // =============================================================================
@@ -1837,12 +2308,15 @@ const CodexAgent = new Agent({
1837
2308
 
1838
2309
  const ClaudeAgent = new Agent({
1839
2310
  name: "claude",
2311
+ displayName: "Claude",
1840
2312
  startCommand: "claude",
1841
2313
  yoloCommand: "claude --dangerously-skip-permissions",
1842
2314
  promptSymbol: "❯",
1843
- spinners: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
2315
+ // Claude Code spinners: ·✢✳✶✻✽ (from cli.js source)
2316
+ spinners: ["·", "✢", "✳", "✶", "✻", "✽"],
1844
2317
  rateLimitPattern: /rate.?limit/i,
1845
- thinkingPatterns: ["Thinking"],
2318
+ // Claude uses whimsical verbs like "Wibbling…", "Dancing…", etc. Match any capitalized -ing word + ellipsis (… or ...)
2319
+ thinkingPatterns: ["Thinking", /[A-Z][a-z]+ing(…|\.\.\.)/],
1846
2320
  confirmPatterns: [
1847
2321
  "Do you want to make this edit",
1848
2322
  "Do you want to run this command",
@@ -1852,12 +2326,28 @@ const ClaudeAgent = new Agent({
1852
2326
  ],
1853
2327
  updatePromptPatterns: null,
1854
2328
  responseMarkers: ["⏺", "•", "- ", "**"],
1855
- chromePatterns: ["↵ send", "Esc to cancel", "shortcuts", "for more options", "docs.anthropic.com", "⏵⏵", "bypass permissions", "shift+Tab to cycle"],
2329
+ chromePatterns: [
2330
+ "↵ send",
2331
+ "Esc to cancel",
2332
+ "shortcuts",
2333
+ "for more options",
2334
+ "docs.anthropic.com",
2335
+ "⏵⏵",
2336
+ "bypass permissions",
2337
+ "shift+Tab to cycle",
2338
+ ],
1856
2339
  reviewOptions: null,
1857
- safeAllowedTools: "Bash(git:*) Read Glob Grep", // Default: auto-approve read-only tools
2340
+ safeAllowedTools: "Bash(git:*) Read Glob Grep", // Default: auto-approve read-only tools
1858
2341
  envVar: "AX_SESSION",
1859
2342
  approveKey: "1",
1860
2343
  rejectKey: "Escape",
2344
+ sessionIdFlag: "--session-id",
2345
+ logPathFinder: (sessionName) => {
2346
+ const parsed = parseSessionName(sessionName);
2347
+ const uuid = parsed?.uuid;
2348
+ if (uuid) return findClaudeLogPath(uuid, sessionName);
2349
+ return null;
2350
+ },
1861
2351
  });
1862
2352
 
1863
2353
  // =============================================================================
@@ -1878,7 +2368,11 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
1878
2368
  const initialState = agent.getState(initialScreen);
1879
2369
 
1880
2370
  // Already in terminal state
1881
- if (initialState === State.RATE_LIMITED || initialState === State.CONFIRMING || initialState === State.READY) {
2371
+ if (
2372
+ initialState === State.RATE_LIMITED ||
2373
+ initialState === State.CONFIRMING ||
2374
+ initialState === State.READY
2375
+ ) {
1882
2376
  return { state: initialState, screen: initialScreen };
1883
2377
  }
1884
2378
 
@@ -1891,30 +2385,38 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
1891
2385
  return { state, screen };
1892
2386
  }
1893
2387
  }
1894
- throw new Error("timeout");
2388
+ throw new TimeoutError(session);
1895
2389
  }
1896
2390
 
1897
2391
  /**
1898
- * Wait for agent to process a new message and respond.
1899
- * Waits for screen activity before considering the response complete.
2392
+ * Core polling loop for waiting on agent responses.
1900
2393
  * @param {Agent} agent
1901
2394
  * @param {string} session
1902
- * @param {number} [timeoutMs]
2395
+ * @param {number} timeoutMs
2396
+ * @param {{onPoll?: (screen: string, state: string) => void, onStateChange?: (state: string, lastState: string | null, screen: string) => void, onReady?: (screen: string) => void}} [hooks]
1903
2397
  * @returns {Promise<{state: string, screen: string}>}
1904
2398
  */
1905
- async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2399
+ async function pollForResponse(agent, session, timeoutMs, hooks = {}) {
2400
+ const { onPoll, onStateChange, onReady } = hooks;
1906
2401
  const start = Date.now();
1907
2402
  const initialScreen = tmuxCapture(session);
1908
2403
 
1909
2404
  let lastScreen = initialScreen;
2405
+ let lastState = null;
1910
2406
  let stableAt = null;
1911
2407
  let sawActivity = false;
1912
2408
 
1913
2409
  while (Date.now() - start < timeoutMs) {
1914
- await sleep(POLL_MS);
1915
2410
  const screen = tmuxCapture(session);
1916
2411
  const state = agent.getState(screen);
1917
2412
 
2413
+ if (onPoll) onPoll(screen, state);
2414
+
2415
+ if (state !== lastState) {
2416
+ if (onStateChange) onStateChange(state, lastState, screen);
2417
+ lastState = state;
2418
+ }
2419
+
1918
2420
  if (state === State.RATE_LIMITED || state === State.CONFIRMING) {
1919
2421
  return { state, screen };
1920
2422
  }
@@ -1929,6 +2431,7 @@ async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
1929
2431
 
1930
2432
  if (sawActivity && stableAt && Date.now() - stableAt >= STABLE_MS) {
1931
2433
  if (state === State.READY) {
2434
+ if (onReady) onReady(screen);
1932
2435
  return { state, screen };
1933
2436
  }
1934
2437
  }
@@ -1936,26 +2439,86 @@ async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
1936
2439
  if (state === State.THINKING) {
1937
2440
  sawActivity = true;
1938
2441
  }
2442
+
2443
+ await sleep(POLL_MS);
1939
2444
  }
1940
- throw new Error("timeout");
2445
+ throw new TimeoutError(session);
1941
2446
  }
1942
2447
 
1943
2448
  /**
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.
2449
+ * Wait for agent response without streaming output.
1946
2450
  * @param {Agent} agent
1947
2451
  * @param {string} session
1948
2452
  * @param {number} [timeoutMs]
1949
2453
  * @returns {Promise<{state: string, screen: string}>}
1950
2454
  */
1951
- async function autoApproveLoop(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2455
+ async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2456
+ return pollForResponse(agent, session, timeoutMs);
2457
+ }
2458
+
2459
+ /**
2460
+ * Wait for agent response with streaming output to console.
2461
+ * @param {Agent} agent
2462
+ * @param {string} session
2463
+ * @param {number} [timeoutMs]
2464
+ * @returns {Promise<{state: string, screen: string}>}
2465
+ */
2466
+ async function streamResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2467
+ let logPath = agent.findLogPath(session);
2468
+ let logOffset = logPath && existsSync(logPath) ? statSync(logPath).size : 0;
2469
+ let printedThinking = false;
2470
+
2471
+ const streamNewEntries = () => {
2472
+ if (!logPath) {
2473
+ logPath = agent.findLogPath(session);
2474
+ if (logPath && existsSync(logPath)) {
2475
+ logOffset = statSync(logPath).size;
2476
+ }
2477
+ }
2478
+ if (logPath) {
2479
+ const { entries, newOffset } = tailJsonl(logPath, logOffset);
2480
+ logOffset = newOffset;
2481
+ for (const entry of entries) {
2482
+ const formatted = formatEntry(entry);
2483
+ if (formatted) console.log(formatted);
2484
+ }
2485
+ }
2486
+ };
2487
+
2488
+ return pollForResponse(agent, session, timeoutMs, {
2489
+ onPoll: () => streamNewEntries(),
2490
+ onStateChange: (state, lastState, screen) => {
2491
+ if (state === State.THINKING && !printedThinking) {
2492
+ console.log("[THINKING]");
2493
+ printedThinking = true;
2494
+ } else if (state === State.CONFIRMING) {
2495
+ const pendingTool = extractPendingToolFromScreen(screen);
2496
+ console.log(pendingTool ? `[CONFIRMING] ${pendingTool}` : "[CONFIRMING]");
2497
+ }
2498
+ if (lastState === State.THINKING && state !== State.THINKING) {
2499
+ printedThinking = false;
2500
+ }
2501
+ },
2502
+ onReady: () => streamNewEntries(),
2503
+ });
2504
+ }
2505
+
2506
+ /**
2507
+ * Auto-approve loop that keeps approving confirmations until the agent is ready or rate limited.
2508
+ * @param {Agent} agent
2509
+ * @param {string} session
2510
+ * @param {number} timeoutMs
2511
+ * @param {Function} waitFn - waitForResponse or streamResponse
2512
+ * @returns {Promise<{state: string, screen: string}>}
2513
+ */
2514
+ async function autoApproveLoop(agent, session, timeoutMs, waitFn) {
1952
2515
  const deadline = Date.now() + timeoutMs;
1953
2516
 
1954
2517
  while (Date.now() < deadline) {
1955
2518
  const remaining = deadline - Date.now();
1956
2519
  if (remaining <= 0) break;
1957
2520
 
1958
- const { state, screen } = await waitForResponse(agent, session, remaining);
2521
+ const { state, screen } = await waitFn(agent, session, remaining);
1959
2522
 
1960
2523
  if (state === State.RATE_LIMITED || state === State.READY) {
1961
2524
  return { state, screen };
@@ -1967,11 +2530,10 @@ async function autoApproveLoop(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
1967
2530
  continue;
1968
2531
  }
1969
2532
 
1970
- // Unexpected state - log and continue polling
1971
2533
  debugError("autoApproveLoop", new Error(`unexpected state: ${state}`));
1972
2534
  }
1973
2535
 
1974
- throw new Error("timeout");
2536
+ throw new TimeoutError(session);
1975
2537
  }
1976
2538
 
1977
2539
  /**
@@ -2030,125 +2592,158 @@ function cmdAgents() {
2030
2592
 
2031
2593
  if (agentSessions.length === 0) {
2032
2594
  console.log("No agents running");
2595
+ // Still check for orphans
2596
+ const orphans = findOrphanedProcesses();
2597
+ if (orphans.length > 0) {
2598
+ console.log(`\nOrphaned (${orphans.length}):`);
2599
+ for (const { pid, command } of orphans) {
2600
+ console.log(` PID ${pid}: ${command}`);
2601
+ }
2602
+ console.log(`\n Run 'ax kill --orphans' to clean up`);
2603
+ }
2033
2604
  return;
2034
2605
  }
2035
2606
 
2607
+ // Get default session for each agent type
2608
+ const claudeDefault = ClaudeAgent.getDefaultSession();
2609
+ const codexDefault = CodexAgent.getDefaultSession();
2610
+
2036
2611
  // Get info for each agent
2037
2612
  const agents = agentSessions.map((session) => {
2038
2613
  const parsed = /** @type {ParsedSession} */ (parseSessionName(session));
2039
2614
  const agent = parsed.tool === "claude" ? ClaudeAgent : CodexAgent;
2040
2615
  const screen = tmuxCapture(session);
2041
2616
  const state = agent.getState(screen);
2042
- const logPath = agent.findLogPath(session);
2043
- const type = parsed.daemonName ? "daemon" : "-";
2617
+ const type = parsed.archangelName ? "archangel" : "-";
2618
+ const isDefault =
2619
+ (parsed.tool === "claude" && session === claudeDefault) ||
2620
+ (parsed.tool === "codex" && session === codexDefault);
2621
+
2622
+ // Get session metadata (Claude only)
2623
+ const meta = getSessionMeta(session);
2044
2624
 
2045
2625
  return {
2046
2626
  session,
2047
2627
  tool: parsed.tool,
2048
2628
  state: state || "unknown",
2629
+ target: isDefault ? "*" : "",
2049
2630
  type,
2050
- log: logPath || "-",
2631
+ plan: meta?.slug || "-",
2632
+ branch: meta?.gitBranch || "-",
2051
2633
  };
2052
2634
  });
2053
2635
 
2054
- // Print table
2636
+ // Print sessions table
2055
2637
  const maxSession = Math.max(7, ...agents.map((a) => a.session.length));
2056
2638
  const maxTool = Math.max(4, ...agents.map((a) => a.tool.length));
2057
2639
  const maxState = Math.max(5, ...agents.map((a) => a.state.length));
2640
+ const maxTarget = Math.max(6, ...agents.map((a) => a.target.length));
2058
2641
  const maxType = Math.max(4, ...agents.map((a) => a.type.length));
2642
+ const maxPlan = Math.max(4, ...agents.map((a) => a.plan.length));
2059
2643
 
2060
2644
  console.log(
2061
- `${"SESSION".padEnd(maxSession)} ${"TOOL".padEnd(maxTool)} ${"STATE".padEnd(maxState)} ${"TYPE".padEnd(maxType)} LOG`
2645
+ `${"SESSION".padEnd(maxSession)} ${"TOOL".padEnd(maxTool)} ${"STATE".padEnd(maxState)} ${"TARGET".padEnd(maxTarget)} ${"TYPE".padEnd(maxType)} ${"PLAN".padEnd(maxPlan)} BRANCH`,
2062
2646
  );
2063
2647
  for (const a of agents) {
2064
2648
  console.log(
2065
- `${a.session.padEnd(maxSession)} ${a.tool.padEnd(maxTool)} ${a.state.padEnd(maxState)} ${a.type.padEnd(maxType)} ${a.log}`
2649
+ `${a.session.padEnd(maxSession)} ${a.tool.padEnd(maxTool)} ${a.state.padEnd(maxState)} ${a.target.padEnd(maxTarget)} ${a.type.padEnd(maxType)} ${a.plan.padEnd(maxPlan)} ${a.branch}`,
2066
2650
  );
2067
2651
  }
2652
+
2653
+ // Print orphaned processes if any
2654
+ const orphans = findOrphanedProcesses();
2655
+ if (orphans.length > 0) {
2656
+ console.log(`\nOrphaned (${orphans.length}):`);
2657
+ for (const { pid, command } of orphans) {
2658
+ console.log(` PID ${pid}: ${command}`);
2659
+ }
2660
+ console.log(`\n Run 'ax kill --orphans' to clean up`);
2661
+ }
2068
2662
  }
2069
2663
 
2070
2664
  // =============================================================================
2071
- // Command: daemons
2665
+ // Command: summon/recall
2072
2666
  // =============================================================================
2073
2667
 
2074
2668
  /**
2075
2669
  * @param {string} pattern
2076
2670
  * @returns {string | undefined}
2077
2671
  */
2078
- function findDaemonSession(pattern) {
2672
+ function findArchangelSession(pattern) {
2079
2673
  const sessions = tmuxListSessions();
2080
2674
  return sessions.find((s) => s.startsWith(pattern));
2081
2675
  }
2082
2676
 
2083
2677
  /**
2084
- * @param {DaemonConfig} config
2678
+ * @param {ArchangelConfig} config
2085
2679
  * @returns {string}
2086
2680
  */
2087
- function generateDaemonSessionName(config) {
2088
- return `${config.tool}-daemon-${config.name}-${randomUUID()}`;
2681
+ function generateArchangelSessionName(config) {
2682
+ return `${config.tool}-archangel-${config.name}-${randomUUID()}`;
2089
2683
  }
2090
2684
 
2091
2685
  /**
2092
- * @param {DaemonConfig} config
2686
+ * @param {ArchangelConfig} config
2093
2687
  * @param {ParentSession | null} [parentSession]
2094
2688
  */
2095
- function startDaemonAgent(config, parentSession = null) {
2096
- // Build environment with parent session info if available
2689
+ function startArchangel(config, parentSession = null) {
2097
2690
  /** @type {NodeJS.ProcessEnv} */
2098
2691
  const env = { ...process.env };
2099
2692
  if (parentSession?.uuid) {
2100
- // Session name may be null for non-tmux sessions, but uuid is required
2101
2693
  if (parentSession.session) {
2102
- env[AX_DAEMON_PARENT_SESSION_ENV] = parentSession.session;
2694
+ env[AX_ARCHANGEL_PARENT_SESSION_ENV] = parentSession.session;
2103
2695
  }
2104
- env[AX_DAEMON_PARENT_UUID_ENV] = parentSession.uuid;
2696
+ env[AX_ARCHANGEL_PARENT_UUID_ENV] = parentSession.uuid;
2105
2697
  }
2106
2698
 
2107
- // Spawn ax.js daemon <name> as a detached background process
2108
- const child = spawn("node", [process.argv[1], "daemon", config.name], {
2699
+ const child = spawn("node", [process.argv[1], "archangel", config.name], {
2109
2700
  detached: true,
2110
2701
  stdio: "ignore",
2111
2702
  cwd: process.cwd(),
2112
2703
  env,
2113
2704
  });
2114
2705
  child.unref();
2115
- console.log(`Starting daemon: ${config.name} (pid ${child.pid})${parentSession ? ` [parent: ${parentSession.session}]` : ""}`);
2706
+ console.log(
2707
+ `Summoning: ${config.name} (pid ${child.pid})${parentSession ? ` [parent: ${parentSession.session}]` : ""}`,
2708
+ );
2116
2709
  }
2117
2710
 
2118
2711
  // =============================================================================
2119
- // Command: daemon (runs as the daemon process itself)
2712
+ // Command: archangel (runs as the archangel process itself)
2120
2713
  // =============================================================================
2121
2714
 
2122
2715
  /**
2123
2716
  * @param {string | undefined} agentName
2124
2717
  */
2125
- async function cmdDaemon(agentName) {
2718
+ async function cmdArchangel(agentName) {
2126
2719
  if (!agentName) {
2127
- console.error("Usage: ./ax.js daemon <name>");
2720
+ console.error("Usage: ./ax.js archangel <name>");
2128
2721
  process.exit(1);
2129
2722
  }
2130
2723
  // Load agent config
2131
2724
  const configPath = path.join(AGENTS_DIR, `${agentName}.md`);
2132
2725
  if (!existsSync(configPath)) {
2133
- console.error(`[daemon:${agentName}] Config not found: ${configPath}`);
2726
+ console.error(`[archangel:${agentName}] Config not found: ${configPath}`);
2134
2727
  process.exit(1);
2135
2728
  }
2136
2729
 
2137
2730
  const content = readFileSync(configPath, "utf-8");
2138
2731
  const configResult = parseAgentConfig(`${agentName}.md`, content);
2139
2732
  if (!configResult || "error" in configResult) {
2140
- console.error(`[daemon:${agentName}] Invalid config`);
2733
+ console.error(`[archangel:${agentName}] Invalid config`);
2141
2734
  process.exit(1);
2142
2735
  }
2143
2736
  const config = configResult;
2144
2737
 
2145
2738
  const agent = config.tool === "claude" ? ClaudeAgent : CodexAgent;
2146
- const sessionName = generateDaemonSessionName(config);
2739
+ const sessionName = generateArchangelSessionName(config);
2147
2740
 
2148
2741
  // Check agent CLI is installed before trying to start
2149
2742
  const cliCheck = spawnSync("which", [agent.name], { encoding: "utf-8" });
2150
2743
  if (cliCheck.status !== 0) {
2151
- console.error(`[daemon:${agentName}] ERROR: ${agent.name} CLI is not installed or not in PATH`);
2744
+ console.error(
2745
+ `[archangel:${agentName}] ERROR: ${agent.name} CLI is not installed or not in PATH`,
2746
+ );
2152
2747
  process.exit(1);
2153
2748
  }
2154
2749
 
@@ -2158,7 +2753,7 @@ async function cmdDaemon(agentName) {
2158
2753
 
2159
2754
  // Wait for agent to be ready
2160
2755
  const start = Date.now();
2161
- while (Date.now() - start < DAEMON_STARTUP_TIMEOUT_MS) {
2756
+ while (Date.now() - start < ARCHANGEL_STARTUP_TIMEOUT_MS) {
2162
2757
  const screen = tmuxCapture(sessionName);
2163
2758
  const state = agent.getState(screen);
2164
2759
 
@@ -2169,7 +2764,7 @@ async function cmdDaemon(agentName) {
2169
2764
 
2170
2765
  // Handle bypass permissions confirmation dialog (Claude Code shows this for --dangerously-skip-permissions)
2171
2766
  if (screen.includes("Bypass Permissions mode") && screen.includes("Yes, I accept")) {
2172
- console.log(`[daemon:${agentName}] Accepting bypass permissions dialog`);
2767
+ console.log(`[archangel:${agentName}] Accepting bypass permissions dialog`);
2173
2768
  tmuxSend(sessionName, "2"); // Select "Yes, I accept"
2174
2769
  await sleep(300);
2175
2770
  tmuxSend(sessionName, "Enter");
@@ -2178,7 +2773,7 @@ async function cmdDaemon(agentName) {
2178
2773
  }
2179
2774
 
2180
2775
  if (state === State.READY) {
2181
- console.log(`[daemon:${agentName}] Started session: ${sessionName}`);
2776
+ console.log(`[archangel:${agentName}] Started session: ${sessionName}`);
2182
2777
  break;
2183
2778
  }
2184
2779
 
@@ -2200,6 +2795,13 @@ async function cmdDaemon(agentName) {
2200
2795
  let isProcessing = false;
2201
2796
  const intervalMs = config.interval * 1000;
2202
2797
 
2798
+ // Hash tracking for incremental context updates
2799
+ /** @type {string | null} */
2800
+ let lastPlanHash = null;
2801
+ /** @type {string | null} */
2802
+ let lastTodosHash = null;
2803
+ let isFirstTrigger = true;
2804
+
2203
2805
  async function processChanges() {
2204
2806
  clearTimeout(debounceTimer);
2205
2807
  clearTimeout(maxWaitTimer);
@@ -2210,16 +2812,32 @@ async function cmdDaemon(agentName) {
2210
2812
  isProcessing = true;
2211
2813
 
2212
2814
  const files = [...changedFiles];
2213
- changedFiles = new Set(); // atomic swap to avoid losing changes during processing
2815
+ changedFiles = new Set(); // atomic swap to avoid losing changes during processing
2214
2816
 
2215
2817
  try {
2216
2818
  // Get parent session log path for JSONL extraction
2217
2819
  const parent = findParentSession();
2218
2820
  const logPath = parent ? findClaudeLogPath(parent.uuid, parent.session) : null;
2219
2821
 
2822
+ // Get orientation context (plan and todos) from parent session
2823
+ const meta = parent?.session ? getSessionMeta(parent.session) : null;
2824
+ const planContent = meta?.slug ? readPlanFile(meta.slug) : null;
2825
+ const todosContent = meta?.todos?.length ? formatTodos(meta.todos) : null;
2826
+
2827
+ // Check if plan/todos have changed since last trigger
2828
+ const planHash = quickHash(planContent);
2829
+ const todosHash = quickHash(todosContent);
2830
+ const includePlan = planHash !== lastPlanHash;
2831
+ const includeTodos = todosHash !== lastTodosHash;
2832
+
2833
+ // Update tracking for next trigger
2834
+ lastPlanHash = planHash;
2835
+ lastTodosHash = todosHash;
2836
+
2220
2837
  // Build file-specific context from JSONL
2221
2838
  const fileContexts = [];
2222
- for (const file of files.slice(0, 5)) { // Limit to 5 files
2839
+ for (const file of files.slice(0, 5)) {
2840
+ // Limit to 5 files
2223
2841
  const ctx = extractFileEditContext(logPath, file);
2224
2842
  if (ctx) {
2225
2843
  fileContexts.push({ file, ...ctx });
@@ -2227,7 +2845,18 @@ async function cmdDaemon(agentName) {
2227
2845
  }
2228
2846
 
2229
2847
  // Build the prompt
2230
- let prompt = basePrompt;
2848
+ // First trigger: include intro, guidelines, and focus (archangel has memory)
2849
+ let prompt = isFirstTrigger
2850
+ ? `You are the archangel of ${agentName}.\n\n${ARCHANGEL_PREAMBLE}\n\n## Focus\n\n${basePrompt}\n\n---`
2851
+ : "";
2852
+
2853
+ // Add orientation context (plan and todos) only if changed since last trigger
2854
+ if (includePlan && planContent) {
2855
+ prompt += (prompt ? "\n\n" : "") + "## Current Plan\n\n" + planContent;
2856
+ }
2857
+ if (includeTodos && todosContent) {
2858
+ prompt += (prompt ? "\n\n" : "") + "## Current Todos\n\n" + todosContent;
2859
+ }
2231
2860
 
2232
2861
  if (fileContexts.length > 0) {
2233
2862
  prompt += "\n\n## Recent Edits (from parent session)\n";
@@ -2246,26 +2875,33 @@ async function cmdDaemon(agentName) {
2246
2875
  }
2247
2876
 
2248
2877
  if (ctx.readsBefore.length > 0) {
2249
- const reads = ctx.readsBefore.map(f => f.split("/").pop()).join(", ");
2878
+ const reads = ctx.readsBefore.map((f) => f.split("/").pop()).join(", ");
2250
2879
  prompt += `**Files read before:** ${reads}\n`;
2251
2880
  }
2252
2881
  }
2253
2882
 
2254
2883
  prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
2255
2884
 
2256
- const gitContext = buildGitContext(DAEMON_GIT_CONTEXT_HOURS, DAEMON_GIT_CONTEXT_MAX_LINES);
2885
+ const gitContext = buildGitContext(
2886
+ ARCHANGEL_GIT_CONTEXT_HOURS,
2887
+ ARCHANGEL_GIT_CONTEXT_MAX_LINES,
2888
+ );
2257
2889
  if (gitContext) {
2258
2890
  prompt += "\n\n## Git Context\n\n" + gitContext;
2259
2891
  }
2260
2892
 
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."';
2893
+ prompt += "\n\nReview these changes.";
2262
2894
  } else {
2263
2895
  // 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);
2896
+ const parentContext = getParentSessionContext(ARCHANGEL_PARENT_CONTEXT_ENTRIES);
2897
+ const gitContext = buildGitContext(
2898
+ ARCHANGEL_GIT_CONTEXT_HOURS,
2899
+ ARCHANGEL_GIT_CONTEXT_MAX_LINES,
2900
+ );
2266
2901
 
2267
2902
  if (parentContext) {
2268
- prompt += "\n\n## Main Session Context\n\nThe user is currently working on:\n\n" + parentContext;
2903
+ prompt +=
2904
+ "\n\n## Main Session Context\n\nThe user is currently working on:\n\n" + parentContext;
2269
2905
  }
2270
2906
 
2271
2907
  prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
@@ -2274,13 +2910,12 @@ async function cmdDaemon(agentName) {
2274
2910
  prompt += "\n\n## Git Context\n\n" + gitContext;
2275
2911
  }
2276
2912
 
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."';
2913
+ prompt += "\n\nReview these changes.";
2278
2914
  }
2279
2915
 
2280
-
2281
2916
  // Check session still exists
2282
2917
  if (!tmuxHasSession(sessionName)) {
2283
- console.log(`[daemon:${agentName}] Session gone, exiting`);
2918
+ console.log(`[archangel:${agentName}] Session gone, exiting`);
2284
2919
  process.exit(0);
2285
2920
  }
2286
2921
 
@@ -2289,12 +2924,12 @@ async function cmdDaemon(agentName) {
2289
2924
  const state = agent.getState(screen);
2290
2925
 
2291
2926
  if (state === State.RATE_LIMITED) {
2292
- console.error(`[daemon:${agentName}] Rate limited - stopping`);
2927
+ console.error(`[archangel:${agentName}] Rate limited - stopping`);
2293
2928
  process.exit(2);
2294
2929
  }
2295
2930
 
2296
2931
  if (state !== State.READY) {
2297
- console.log(`[daemon:${agentName}] Agent not ready (${state}), skipping`);
2932
+ console.log(`[archangel:${agentName}] Agent not ready (${state}), skipping`);
2298
2933
  isProcessing = false;
2299
2934
  return;
2300
2935
  }
@@ -2304,38 +2939,37 @@ async function cmdDaemon(agentName) {
2304
2939
  await sleep(200); // Allow time for large prompts to be processed
2305
2940
  tmuxSend(sessionName, "Enter");
2306
2941
  await sleep(100); // Ensure Enter is processed
2942
+ isFirstTrigger = false;
2307
2943
 
2308
2944
  // Wait for response
2309
- const { state: endState, screen: afterScreen } = await waitForResponse(agent, sessionName, DAEMON_RESPONSE_TIMEOUT_MS);
2945
+ const { state: endState, screen: afterScreen } = await waitForResponse(
2946
+ agent,
2947
+ sessionName,
2948
+ ARCHANGEL_RESPONSE_TIMEOUT_MS,
2949
+ );
2310
2950
 
2311
2951
  if (endState === State.RATE_LIMITED) {
2312
- console.error(`[daemon:${agentName}] Rate limited - stopping`);
2952
+ console.error(`[archangel:${agentName}] Rate limited - stopping`);
2313
2953
  process.exit(2);
2314
2954
  }
2315
2955
 
2316
-
2317
2956
  const cleanedResponse = agent.getResponse(sessionName, afterScreen) || "";
2318
2957
 
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;
2958
+ const isSkippable = !cleanedResponse || cleanedResponse.trim() === "EMPTY_RESPONSE";
2323
2959
 
2324
- if (cleanedResponse && !isGarbage && !cleanedResponse.toLowerCase().includes("no issues found")) {
2960
+ if (!isSkippable) {
2325
2961
  writeToMailbox({
2326
2962
  agent: /** @type {string} */ (agentName),
2327
2963
  session: sessionName,
2328
2964
  branch: getCurrentBranch(),
2329
2965
  commit: getCurrentCommit(),
2330
2966
  files,
2331
- message: cleanedResponse.slice(0, 1000),
2967
+ message: cleanedResponse,
2332
2968
  });
2333
- console.log(`[daemon:${agentName}] Wrote observation for ${files.length} file(s)`);
2334
- } else if (isGarbage) {
2335
- console.log(`[daemon:${agentName}] Skipped garbage response`);
2969
+ console.log(`[archangel:${agentName}] Wrote observation for ${files.length} file(s)`);
2336
2970
  }
2337
2971
  } catch (err) {
2338
- console.error(`[daemon:${agentName}] Error:`, err instanceof Error ? err.message : err);
2972
+ console.error(`[archangel:${agentName}] Error:`, err instanceof Error ? err.message : err);
2339
2973
  }
2340
2974
 
2341
2975
  isProcessing = false;
@@ -2343,7 +2977,10 @@ async function cmdDaemon(agentName) {
2343
2977
 
2344
2978
  function scheduleProcessChanges() {
2345
2979
  processChanges().catch((err) => {
2346
- console.error(`[daemon:${agentName}] Unhandled error:`, err instanceof Error ? err.message : err);
2980
+ console.error(
2981
+ `[archangel:${agentName}] Unhandled error:`,
2982
+ err instanceof Error ? err.message : err,
2983
+ );
2347
2984
  });
2348
2985
  }
2349
2986
 
@@ -2364,16 +3001,16 @@ async function cmdDaemon(agentName) {
2364
3001
  // Check if session still exists periodically
2365
3002
  const sessionCheck = setInterval(() => {
2366
3003
  if (!tmuxHasSession(sessionName)) {
2367
- console.log(`[daemon:${agentName}] Session gone, exiting`);
3004
+ console.log(`[archangel:${agentName}] Session gone, exiting`);
2368
3005
  stopWatching();
2369
3006
  clearInterval(sessionCheck);
2370
3007
  process.exit(0);
2371
3008
  }
2372
- }, DAEMON_HEALTH_CHECK_MS);
3009
+ }, ARCHANGEL_HEALTH_CHECK_MS);
2373
3010
 
2374
3011
  // Handle graceful shutdown
2375
3012
  process.on("SIGTERM", () => {
2376
- console.log(`[daemon:${agentName}] Received SIGTERM, shutting down`);
3013
+ console.log(`[archangel:${agentName}] Received SIGTERM, shutting down`);
2377
3014
  stopWatching();
2378
3015
  clearInterval(sessionCheck);
2379
3016
  tmuxSend(sessionName, "C-c");
@@ -2384,7 +3021,7 @@ async function cmdDaemon(agentName) {
2384
3021
  });
2385
3022
 
2386
3023
  process.on("SIGINT", () => {
2387
- console.log(`[daemon:${agentName}] Received SIGINT, shutting down`);
3024
+ console.log(`[archangel:${agentName}] Received SIGINT, shutting down`);
2388
3025
  stopWatching();
2389
3026
  clearInterval(sessionCheck);
2390
3027
  tmuxSend(sessionName, "C-c");
@@ -2394,48 +3031,33 @@ async function cmdDaemon(agentName) {
2394
3031
  }, 500);
2395
3032
  });
2396
3033
 
2397
- console.log(`[daemon:${agentName}] Watching: ${config.watch.join(", ")}`);
3034
+ console.log(`[archangel:${agentName}] Watching: ${config.watch.join(", ")}`);
2398
3035
 
2399
3036
  // Keep the process alive
2400
3037
  await new Promise(() => {});
2401
3038
  }
2402
3039
 
2403
3040
  /**
2404
- * @param {string} action
2405
- * @param {string | null} [daemonName]
3041
+ * @param {string | null} [name]
2406
3042
  */
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
- }
3043
+ async function cmdSummon(name = null) {
3044
+ const configs = loadAgentConfigs();
2426
3045
 
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
- }
3046
+ // If name provided but doesn't exist, create it
3047
+ if (name) {
3048
+ const exists = configs.some((c) => c.name === name);
3049
+ if (!exists) {
3050
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
3051
+ console.log("ERROR: Name must contain only letters, numbers, dashes, and underscores");
3052
+ process.exit(1);
3053
+ }
2432
3054
 
2433
- // Create agents directory if needed
2434
- if (!existsSync(AGENTS_DIR)) {
2435
- mkdirSync(AGENTS_DIR, { recursive: true });
2436
- }
3055
+ if (!existsSync(AGENTS_DIR)) {
3056
+ mkdirSync(AGENTS_DIR, { recursive: true });
3057
+ }
2437
3058
 
2438
- const template = `---
3059
+ const agentPath = path.join(AGENTS_DIR, `${name}.md`);
3060
+ const template = `---
2439
3061
  tool: claude
2440
3062
  watch: ["**/*.{ts,tsx,js,jsx,mjs,mts}"]
2441
3063
  interval: 30
@@ -2443,75 +3065,76 @@ interval: 30
2443
3065
 
2444
3066
  Review changed files for bugs, type errors, and edge cases.
2445
3067
  `;
3068
+ writeFileSync(agentPath, template);
3069
+ console.log(`Created: ${agentPath}`);
3070
+ console.log(`Edit the file to customize, then run: ax summon ${name}`);
3071
+ return;
3072
+ }
3073
+ }
2446
3074
 
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}`);
3075
+ if (configs.length === 0) {
3076
+ console.log(`No archangels found in ${AGENTS_DIR}/`);
2450
3077
  return;
2451
3078
  }
2452
3079
 
3080
+ const targetConfigs = name ? configs.filter((c) => c.name === name) : configs;
3081
+
3082
+ ensureMailboxHookScript();
3083
+
3084
+ const parentSession = findCurrentClaudeSession();
3085
+ if (parentSession) {
3086
+ console.log(`Parent session: ${parentSession.session || "(non-tmux)"} [${parentSession.uuid}]`);
3087
+ }
3088
+
3089
+ for (const config of targetConfigs) {
3090
+ const sessionPattern = getArchangelSessionPattern(config);
3091
+ const existing = findArchangelSession(sessionPattern);
3092
+
3093
+ if (!existing) {
3094
+ startArchangel(config, parentSession);
3095
+ } else {
3096
+ console.log(`Already running: ${config.name} (${existing})`);
3097
+ }
3098
+ }
3099
+
3100
+ gcMailbox(24);
3101
+ }
3102
+
3103
+ /**
3104
+ * @param {string | null} [name]
3105
+ */
3106
+ async function cmdRecall(name = null) {
2453
3107
  const configs = loadAgentConfigs();
2454
3108
 
2455
3109
  if (configs.length === 0) {
2456
- console.log(`No agent configs found in ${AGENTS_DIR}/`);
3110
+ console.log(`No archangels found in ${AGENTS_DIR}/`);
2457
3111
  return;
2458
3112
  }
2459
3113
 
2460
- // Filter to specific daemon if name provided
2461
- const targetConfigs = daemonName
2462
- ? configs.filter((c) => c.name === daemonName)
2463
- : configs;
3114
+ const targetConfigs = name ? configs.filter((c) => c.name === name) : configs;
2464
3115
 
2465
- if (daemonName && targetConfigs.length === 0) {
2466
- console.log(`ERROR: daemon '${daemonName}' not found in ${AGENTS_DIR}/`);
3116
+ if (name && targetConfigs.length === 0) {
3117
+ console.log(`ERROR: archangel '${name}' not found in ${AGENTS_DIR}/`);
2467
3118
  process.exit(1);
2468
3119
  }
2469
3120
 
2470
- // Ensure hook script exists on start
2471
- if (action === "start") {
2472
- ensureMailboxHookScript();
2473
- }
3121
+ for (const config of targetConfigs) {
3122
+ const sessionPattern = getArchangelSessionPattern(config);
3123
+ const existing = findArchangelSession(sessionPattern);
2474
3124
 
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}]`);
3125
+ if (existing) {
3126
+ tmuxSend(existing, "C-c");
3127
+ await sleep(300);
3128
+ tmuxKill(existing);
3129
+ console.log(`Recalled: ${config.name} (${existing})`);
2480
3130
  } else {
2481
- console.log("Parent session: null (not running from Claude or no active sessions)");
3131
+ console.log(`Not running: ${config.name}`);
2482
3132
  }
2483
3133
  }
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
3134
  }
2512
3135
 
2513
3136
  // Version of the hook script template - bump when making changes
2514
- const HOOK_SCRIPT_VERSION = "2";
3137
+ const HOOK_SCRIPT_VERSION = "4";
2515
3138
 
2516
3139
  function ensureMailboxHookScript() {
2517
3140
  const hooksDir = HOOKS_DIR;
@@ -2529,34 +3152,54 @@ function ensureMailboxHookScript() {
2529
3152
  mkdirSync(hooksDir, { recursive: true });
2530
3153
  }
2531
3154
 
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
3155
  const hookCode = `#!/usr/bin/env node
2537
3156
  ${versionMarker}
2538
- // Auto-generated hook script - do not edit manually
2539
-
2540
3157
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
3158
+ import { dirname, join } from "node:path";
3159
+ import { fileURLToPath } from "node:url";
3160
+ import { createHash } from "node:crypto";
2541
3161
 
3162
+ const __dirname = dirname(fileURLToPath(import.meta.url));
3163
+ const AI_DIR = join(__dirname, "..");
2542
3164
  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)
3165
+ const MAILBOX = join(AI_DIR, "mailbox.jsonl");
3166
+ const MAX_AGE_MS = 60 * 60 * 1000;
2546
3167
 
2547
- if (!existsSync(MAILBOX)) process.exit(0);
3168
+ // Read hook input from stdin
3169
+ let hookInput = {};
3170
+ try {
3171
+ const stdinData = readFileSync(0, "utf-8").trim();
3172
+ if (stdinData) hookInput = JSON.parse(stdinData);
3173
+ } catch (err) {
3174
+ if (DEBUG) console.error("[hook] stdin parse:", err.message);
3175
+ }
3176
+
3177
+ const sessionId = hookInput.session_id || "";
3178
+ const hookEvent = hookInput.hook_event_name || "";
2548
3179
 
2549
- // Note: commit filtering removed - age + lastSeen is sufficient
3180
+ if (DEBUG) console.error("[hook] session:", sessionId, "event:", hookEvent);
2550
3181
 
2551
- // Read last seen timestamp
2552
- let lastSeen = 0;
3182
+ // NO-OP for archangel or partner sessions
3183
+ if (sessionId.includes("-archangel-") || sessionId.includes("-partner-")) {
3184
+ if (DEBUG) console.error("[hook] skipping non-parent session");
3185
+ process.exit(0);
3186
+ }
3187
+
3188
+ // Per-session last-seen tracking (single JSON file, self-cleaning)
3189
+ const sessionHash = sessionId ? createHash("md5").update(sessionId).digest("hex").slice(0, 8) : "default";
3190
+ const LAST_SEEN_FILE = join(AI_DIR, "mailbox-last-seen.json");
3191
+
3192
+ if (!existsSync(MAILBOX)) process.exit(0);
3193
+
3194
+ let lastSeenMap = {};
2553
3195
  try {
2554
- if (existsSync(LAST_SEEN)) {
2555
- lastSeen = parseInt(readFileSync(LAST_SEEN, "utf-8").trim(), 10) || 0;
3196
+ if (existsSync(LAST_SEEN_FILE)) {
3197
+ lastSeenMap = JSON.parse(readFileSync(LAST_SEEN_FILE, "utf-8"));
2556
3198
  }
2557
3199
  } catch (err) {
2558
3200
  if (DEBUG) console.error("[hook] readLastSeen:", err.message);
2559
3201
  }
3202
+ const lastSeen = lastSeenMap[sessionHash] || 0;
2560
3203
 
2561
3204
  const now = Date.now();
2562
3205
  const lines = readFileSync(MAILBOX, "utf-8").trim().split("\\n").filter(Boolean);
@@ -2567,11 +3210,7 @@ for (const line of lines) {
2567
3210
  const entry = JSON.parse(line);
2568
3211
  const ts = new Date(entry.timestamp).getTime();
2569
3212
  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
3213
  if (age < MAX_AGE_MS && ts > lastSeen) {
2574
- // Extract session prefix (without UUID) for shorter log command
2575
3214
  const session = entry.payload.session || "";
2576
3215
  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
3216
  relevant.push({ agent: entry.payload.agent, sessionPrefix, message: entry.payload.message });
@@ -2582,22 +3221,39 @@ for (const line of lines) {
2582
3221
  }
2583
3222
 
2584
3223
  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
3224
  const sessionPrefixes = new Set();
3225
+ let messageLines = [];
3226
+ messageLines.push("## Background Agents");
3227
+ messageLines.push("");
3228
+ messageLines.push("Background agents watching your files found:");
3229
+ messageLines.push("");
2590
3230
  for (const { agent, sessionPrefix, message } of relevant) {
2591
3231
  if (sessionPrefix) sessionPrefixes.add(sessionPrefix);
2592
- console.log("**[" + agent + "]**");
2593
- console.log("");
2594
- console.log(message);
2595
- console.log("");
3232
+ messageLines.push("**[" + agent + "]**");
3233
+ messageLines.push("");
3234
+ messageLines.push(message);
3235
+ messageLines.push("");
2596
3236
  }
2597
3237
  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());
3238
+ messageLines.push("> For more context: \\\`./ax.js mailbox\\\`" + (sessionList ? " or " + sessionList : ""));
3239
+
3240
+ const formattedMessage = messageLines.join("\\n");
3241
+
3242
+ // For Stop hook, return blocking JSON to force acknowledgment
3243
+ if (hookEvent === "Stop") {
3244
+ console.log(JSON.stringify({ decision: "block", reason: formattedMessage }));
3245
+ } else {
3246
+ // For other hooks, just output the context
3247
+ console.log(formattedMessage);
3248
+ }
3249
+
3250
+ // Update last-seen and prune entries older than 24 hours
3251
+ const PRUNE_AGE_MS = 24 * 60 * 60 * 1000;
3252
+ lastSeenMap[sessionHash] = now;
3253
+ for (const key of Object.keys(lastSeenMap)) {
3254
+ if (now - lastSeenMap[key] > PRUNE_AGE_MS) delete lastSeenMap[key];
3255
+ }
3256
+ writeFileSync(LAST_SEEN_FILE, JSON.stringify(lastSeenMap));
2601
3257
  }
2602
3258
 
2603
3259
  process.exit(0);
@@ -2609,22 +3265,12 @@ process.exit(0);
2609
3265
  // Configure the hook in .claude/settings.json at the same time
2610
3266
  const configuredHook = ensureClaudeHookConfig();
2611
3267
  if (!configuredHook) {
2612
- const hookScriptPath = path.join(HOOKS_DIR, "mailbox-inject.js");
2613
3268
  console.log(`\nTo enable manually, add to .claude/settings.json:\n`);
2614
3269
  console.log(`{
2615
3270
  "hooks": {
2616
- "UserPromptSubmit": [
2617
- {
2618
- "matcher": "",
2619
- "hooks": [
2620
- {
2621
- "type": "command",
2622
- "command": "node ${hookScriptPath}",
2623
- "timeout": 5
2624
- }
2625
- ]
2626
- }
2627
- ]
3271
+ "UserPromptSubmit": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }],
3272
+ "PostToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }],
3273
+ "Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }]
2628
3274
  }
2629
3275
  }`);
2630
3276
  }
@@ -2633,8 +3279,8 @@ process.exit(0);
2633
3279
  function ensureClaudeHookConfig() {
2634
3280
  const settingsDir = ".claude";
2635
3281
  const settingsPath = path.join(settingsDir, "settings.json");
2636
- const hookScriptPath = path.join(HOOKS_DIR, "mailbox-inject.js");
2637
- const hookCommand = `node ${hookScriptPath}`;
3282
+ const hookCommand = "node .ai/hooks/mailbox-inject.js";
3283
+ const hookEvents = ["UserPromptSubmit", "PostToolUse", "Stop"];
2638
3284
 
2639
3285
  try {
2640
3286
  /** @type {ClaudeSettings} */
@@ -2653,33 +3299,41 @@ function ensureClaudeHookConfig() {
2653
3299
 
2654
3300
  // Ensure hooks structure exists
2655
3301
  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
3302
 
2664
- if (hookExists) {
2665
- return true; // Already configured
3303
+ let anyAdded = false;
3304
+
3305
+ for (const eventName of hookEvents) {
3306
+ if (!settings.hooks[eventName]) settings.hooks[eventName] = [];
3307
+
3308
+ // Check if our hook is already configured for this event
3309
+ const hookExists = settings.hooks[eventName].some(
3310
+ /** @param {{hooks?: Array<{command: string}>}} entry */
3311
+ (entry) =>
3312
+ entry.hooks?.some(/** @param {{command: string}} h */ (h) => h.command === hookCommand),
3313
+ );
3314
+
3315
+ if (!hookExists) {
3316
+ // Add the hook for this event
3317
+ settings.hooks[eventName].push({
3318
+ matcher: "",
3319
+ hooks: [
3320
+ {
3321
+ type: "command",
3322
+ command: hookCommand,
3323
+ timeout: 5,
3324
+ },
3325
+ ],
3326
+ });
3327
+ anyAdded = true;
3328
+ }
2666
3329
  }
2667
3330
 
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
- });
3331
+ if (anyAdded) {
3332
+ // Write settings
3333
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
3334
+ console.log(`Configured hooks in: ${settingsPath}`);
3335
+ }
2679
3336
 
2680
- // Write settings
2681
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2682
- console.log(`Configured hook in: ${settingsPath}`);
2683
3337
  return true;
2684
3338
  } catch {
2685
3339
  // If we can't configure automatically, return false so manual instructions are shown
@@ -2689,9 +3343,31 @@ function ensureClaudeHookConfig() {
2689
3343
 
2690
3344
  /**
2691
3345
  * @param {string | null | undefined} session
2692
- * @param {{all?: boolean}} [options]
3346
+ * @param {{all?: boolean, orphans?: boolean, force?: boolean}} [options]
2693
3347
  */
2694
- function cmdKill(session, { all = false } = {}) {
3348
+ function cmdKill(session, { all = false, orphans = false, force = false } = {}) {
3349
+ // Handle orphaned processes
3350
+ if (orphans) {
3351
+ const orphanedProcesses = findOrphanedProcesses();
3352
+
3353
+ if (orphanedProcesses.length === 0) {
3354
+ console.log("No orphaned processes found");
3355
+ return;
3356
+ }
3357
+
3358
+ const signal = force ? "-9" : "-15"; // SIGKILL vs SIGTERM
3359
+ let killed = 0;
3360
+ for (const { pid, command } of orphanedProcesses) {
3361
+ const result = spawnSync("kill", [signal, pid]);
3362
+ if (result.status === 0) {
3363
+ console.log(`Killed: PID ${pid} (${command.slice(0, 40)})`);
3364
+ killed++;
3365
+ }
3366
+ }
3367
+ console.log(`Killed ${killed} orphaned process(es)${force ? " (forced)" : ""}`);
3368
+ return;
3369
+ }
3370
+
2695
3371
  // If specific session provided, kill just that one
2696
3372
  if (session) {
2697
3373
  if (!tmuxHasSession(session)) {
@@ -2751,7 +3427,9 @@ function cmdAttach(session) {
2751
3427
  }
2752
3428
 
2753
3429
  // Hand over to tmux attach
2754
- const result = spawnSync("tmux", ["attach", "-t", resolved], { stdio: "inherit" });
3430
+ const result = spawnSync("tmux", ["attach", "-t", resolved], {
3431
+ stdio: "inherit",
3432
+ });
2755
3433
  process.exit(result.status || 0);
2756
3434
  }
2757
3435
 
@@ -2810,13 +3488,15 @@ function cmdLog(sessionName, { tail = 50, reasoning = false, follow = false } =
2810
3488
 
2811
3489
  if (newLines.length === 0) return;
2812
3490
 
2813
- const entries = newLines.map((line) => {
2814
- try {
2815
- return JSON.parse(line);
2816
- } catch {
2817
- return null;
2818
- }
2819
- }).filter(Boolean);
3491
+ const entries = newLines
3492
+ .map((line) => {
3493
+ try {
3494
+ return JSON.parse(line);
3495
+ } catch {
3496
+ return null;
3497
+ }
3498
+ })
3499
+ .filter(Boolean);
2820
3500
 
2821
3501
  const output = [];
2822
3502
  if (isInitial) {
@@ -2829,7 +3509,10 @@ function cmdLog(sessionName, { tail = 50, reasoning = false, follow = false } =
2829
3509
  const ts = entry.timestamp || entry.ts || entry.createdAt;
2830
3510
  if (ts && ts !== lastTimestamp) {
2831
3511
  const date = new Date(ts);
2832
- const timeStr = date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
3512
+ const timeStr = date.toLocaleTimeString("en-GB", {
3513
+ hour: "2-digit",
3514
+ minute: "2-digit",
3515
+ });
2833
3516
  if (formatted.isUserMessage) {
2834
3517
  output.push(`\n### ${timeStr}\n`);
2835
3518
  }
@@ -2879,7 +3562,10 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2879
3562
  if (type === "user" || type === "human") {
2880
3563
  const text = extractTextContent(content);
2881
3564
  if (text) {
2882
- return { text: `**User**: ${truncate(text, TRUNCATE_USER_LEN)}\n`, isUserMessage: true };
3565
+ return {
3566
+ text: `**User**: ${truncate(text, TRUNCATE_USER_LEN)}\n`,
3567
+ isUserMessage: true,
3568
+ };
2883
3569
  }
2884
3570
  }
2885
3571
 
@@ -2895,10 +3581,12 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2895
3581
  // Extract tool calls (compressed)
2896
3582
  const tools = extractToolCalls(content);
2897
3583
  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(", ");
3584
+ const toolSummary = tools
3585
+ .map((t) => {
3586
+ if (t.error) return `${t.name}(${t.target}) ✗`;
3587
+ return `${t.name}(${t.target})`;
3588
+ })
3589
+ .join(", ");
2902
3590
  parts.push(`> ${toolSummary}\n`);
2903
3591
  }
2904
3592
 
@@ -2920,7 +3608,10 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2920
3608
  const error = entry.error || entry.is_error;
2921
3609
  if (error) {
2922
3610
  const name = entry.tool_name || entry.name || "tool";
2923
- return { text: `> ${name} ✗ (${truncate(String(error), 100)})\n`, isUserMessage: false };
3611
+ return {
3612
+ text: `> ${name} ✗ (${truncate(String(error), 100)})\n`,
3613
+ isUserMessage: false,
3614
+ };
2924
3615
  }
2925
3616
  }
2926
3617
 
@@ -2957,7 +3648,8 @@ function extractToolCalls(content) {
2957
3648
  const name = c.name || c.tool || "tool";
2958
3649
  const input = c.input || c.arguments || {};
2959
3650
  // Extract a reasonable target from the input
2960
- const target = input.file_path || input.path || input.command?.slice(0, 30) || input.pattern || "";
3651
+ const target =
3652
+ input.file_path || input.path || input.command?.slice(0, 30) || input.pattern || "";
2961
3653
  const shortTarget = target.split("/").pop() || target.slice(0, 20);
2962
3654
  return { name, target: shortTarget, error: c.error };
2963
3655
  });
@@ -3002,8 +3694,14 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
3002
3694
 
3003
3695
  for (const entry of entries) {
3004
3696
  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" });
3697
+ const timeStr = ts.toLocaleTimeString("en-GB", {
3698
+ hour: "2-digit",
3699
+ minute: "2-digit",
3700
+ });
3701
+ const dateStr = ts.toLocaleDateString("en-GB", {
3702
+ month: "short",
3703
+ day: "numeric",
3704
+ });
3007
3705
  const p = entry.payload || {};
3008
3706
 
3009
3707
  console.log(`### [${p.agent || "unknown"}] ${dateStr} ${timeStr}\n`);
@@ -3032,7 +3730,7 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
3032
3730
  * @param {string} message
3033
3731
  * @param {{noWait?: boolean, yolo?: boolean, timeoutMs?: number}} [options]
3034
3732
  */
3035
- async function cmdAsk(agent, session, message, { noWait = false, yolo = false, timeoutMs } = {}) {
3733
+ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
3036
3734
  const sessionExists = session != null && tmuxHasSession(session);
3037
3735
  const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
3038
3736
 
@@ -3044,20 +3742,31 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3044
3742
  }
3045
3743
 
3046
3744
  /** @type {string} */
3047
- const activeSession = sessionExists ? /** @type {string} */ (session) : await cmdStart(agent, session, { yolo });
3745
+ const activeSession = sessionExists
3746
+ ? /** @type {string} */ (session)
3747
+ : await cmdStart(agent, session, { yolo });
3048
3748
 
3049
3749
  tmuxSendLiteral(activeSession, message);
3050
3750
  await sleep(50);
3051
3751
  tmuxSend(activeSession, "Enter");
3052
3752
 
3053
- if (noWait) return;
3753
+ if (noWait) {
3754
+ const parsed = parseSessionName(activeSession);
3755
+ const shortId = parsed?.uuid?.slice(0, 8) || activeSession;
3756
+ const cli = path.basename(process.argv[1], ".js");
3757
+ console.log(`Sent to: ${shortId}
3758
+
3759
+ e.g.
3760
+ ${cli} status --session=${shortId}
3761
+ ${cli} output --session=${shortId}`);
3762
+ return;
3763
+ }
3054
3764
 
3055
- // Yolo mode on a safe session: auto-approve until done
3056
3765
  const useAutoApprove = yolo && !nativeYolo;
3057
3766
 
3058
3767
  const { state, screen } = useAutoApprove
3059
- ? await autoApproveLoop(agent, activeSession, timeoutMs)
3060
- : await waitForResponse(agent, activeSession, timeoutMs);
3768
+ ? await autoApproveLoop(agent, activeSession, timeoutMs, streamResponse)
3769
+ : await streamResponse(agent, activeSession, timeoutMs);
3061
3770
 
3062
3771
  if (state === State.RATE_LIMITED) {
3063
3772
  console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
@@ -3065,14 +3774,9 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3065
3774
  }
3066
3775
 
3067
3776
  if (state === State.CONFIRMING) {
3068
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3777
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3069
3778
  process.exit(3);
3070
3779
  }
3071
-
3072
- const output = agent.getResponse(activeSession, screen);
3073
- if (output) {
3074
- console.log(output);
3075
- }
3076
3780
  }
3077
3781
 
3078
3782
  /**
@@ -3087,9 +3791,10 @@ async function cmdApprove(agent, session, { wait = false, timeoutMs } = {}) {
3087
3791
  }
3088
3792
 
3089
3793
  const before = tmuxCapture(session);
3090
- if (agent.getState(before) !== State.CONFIRMING) {
3091
- console.log("ERROR: not confirming");
3092
- process.exit(1);
3794
+ const beforeState = agent.getState(before);
3795
+ if (beforeState !== State.CONFIRMING) {
3796
+ console.log(`Already ${beforeState}`);
3797
+ return;
3093
3798
  }
3094
3799
 
3095
3800
  tmuxSend(session, agent.approveKey);
@@ -3104,7 +3809,7 @@ async function cmdApprove(agent, session, { wait = false, timeoutMs } = {}) {
3104
3809
  }
3105
3810
 
3106
3811
  if (state === State.CONFIRMING) {
3107
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3812
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3108
3813
  process.exit(3);
3109
3814
  }
3110
3815
 
@@ -3123,6 +3828,13 @@ async function cmdReject(agent, session, { wait = false, timeoutMs } = {}) {
3123
3828
  process.exit(1);
3124
3829
  }
3125
3830
 
3831
+ const before = tmuxCapture(session);
3832
+ const beforeState = agent.getState(before);
3833
+ if (beforeState !== State.CONFIRMING) {
3834
+ console.log(`Already ${beforeState}`);
3835
+ return;
3836
+ }
3837
+
3126
3838
  tmuxSend(session, agent.rejectKey);
3127
3839
 
3128
3840
  if (!wait) return;
@@ -3145,7 +3857,13 @@ async function cmdReject(agent, session, { wait = false, timeoutMs } = {}) {
3145
3857
  * @param {string | null | undefined} customInstructions
3146
3858
  * @param {{wait?: boolean, yolo?: boolean, fresh?: boolean, timeoutMs?: number}} [options]
3147
3859
  */
3148
- async function cmdReview(agent, session, option, customInstructions, { wait = true, yolo = true, fresh = false, timeoutMs = REVIEW_TIMEOUT_MS } = {}) {
3860
+ async function cmdReview(
3861
+ agent,
3862
+ session,
3863
+ option,
3864
+ customInstructions,
3865
+ { wait = true, yolo = false, fresh = false, timeoutMs = REVIEW_TIMEOUT_MS } = {},
3866
+ ) {
3149
3867
  const sessionExists = session != null && tmuxHasSession(session);
3150
3868
 
3151
3869
  // Reset conversation if --fresh and session exists
@@ -3171,7 +3889,11 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3171
3889
 
3172
3890
  // AX_REVIEW_MODE=exec: bypass /review command, send instructions directly
3173
3891
  if (process.env.AX_REVIEW_MODE === "exec" && option === "custom" && customInstructions) {
3174
- return cmdAsk(agent, session, customInstructions, { noWait: !wait, yolo, timeoutMs });
3892
+ return cmdAsk(agent, session, customInstructions, {
3893
+ noWait: !wait,
3894
+ yolo,
3895
+ timeoutMs,
3896
+ });
3175
3897
  }
3176
3898
  const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
3177
3899
 
@@ -3183,7 +3905,9 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3183
3905
  }
3184
3906
 
3185
3907
  /** @type {string} */
3186
- const activeSession = sessionExists ? /** @type {string} */ (session) : await cmdStart(agent, session, { yolo });
3908
+ const activeSession = sessionExists
3909
+ ? /** @type {string} */ (session)
3910
+ : await cmdStart(agent, session, { yolo });
3187
3911
 
3188
3912
  tmuxSendLiteral(activeSession, "/review");
3189
3913
  await sleep(50);
@@ -3205,12 +3929,11 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3205
3929
 
3206
3930
  if (!wait) return;
3207
3931
 
3208
- // Yolo mode on a safe session: auto-approve until done
3209
3932
  const useAutoApprove = yolo && !nativeYolo;
3210
3933
 
3211
3934
  const { state, screen } = useAutoApprove
3212
- ? await autoApproveLoop(agent, activeSession, timeoutMs)
3213
- : await waitForResponse(agent, activeSession, timeoutMs);
3935
+ ? await autoApproveLoop(agent, activeSession, timeoutMs, streamResponse)
3936
+ : await streamResponse(agent, activeSession, timeoutMs);
3214
3937
 
3215
3938
  if (state === State.RATE_LIMITED) {
3216
3939
  console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
@@ -3218,12 +3941,9 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3218
3941
  }
3219
3942
 
3220
3943
  if (state === State.CONFIRMING) {
3221
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3944
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3222
3945
  process.exit(3);
3223
3946
  }
3224
-
3225
- const response = agent.getResponse(activeSession, screen);
3226
- console.log(response || "");
3227
3947
  }
3228
3948
 
3229
3949
  /**
@@ -3254,7 +3974,7 @@ async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs }
3254
3974
  }
3255
3975
 
3256
3976
  if (state === State.CONFIRMING) {
3257
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3977
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3258
3978
  process.exit(3);
3259
3979
  }
3260
3980
 
@@ -3266,6 +3986,8 @@ async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs }
3266
3986
  const output = agent.getResponse(session, screen, index);
3267
3987
  if (output) {
3268
3988
  console.log(output);
3989
+ } else {
3990
+ console.log("READY_NO_CONTENT");
3269
3991
  }
3270
3992
  }
3271
3993
 
@@ -3288,7 +4010,7 @@ function cmdStatus(agent, session) {
3288
4010
  }
3289
4011
 
3290
4012
  if (state === State.CONFIRMING) {
3291
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
4013
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3292
4014
  process.exit(3);
3293
4015
  }
3294
4016
 
@@ -3296,6 +4018,10 @@ function cmdStatus(agent, session) {
3296
4018
  console.log("THINKING");
3297
4019
  process.exit(4);
3298
4020
  }
4021
+
4022
+ // READY (or STARTING/UPDATE_PROMPT which are transient)
4023
+ console.log("READY");
4024
+ process.exit(0);
3299
4025
  }
3300
4026
 
3301
4027
  /**
@@ -3409,7 +4135,7 @@ async function cmdSelect(agent, session, n, { wait = false, timeoutMs } = {}) {
3409
4135
  }
3410
4136
 
3411
4137
  if (state === State.CONFIRMING) {
3412
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
4138
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3413
4139
  process.exit(3);
3414
4140
  }
3415
4141
 
@@ -3422,20 +4148,43 @@ async function cmdSelect(agent, session, n, { wait = false, timeoutMs } = {}) {
3422
4148
  // =============================================================================
3423
4149
 
3424
4150
  /**
3425
- * @returns {Agent}
4151
+ * Resolve the agent to use based on (in priority order):
4152
+ * 1. Explicit --tool flag
4153
+ * 2. Session name (e.g., "claude-archangel-..." → ClaudeAgent)
4154
+ * 3. CLI invocation name (axclaude, axcodex)
4155
+ * 4. AX_DEFAULT_TOOL environment variable
4156
+ * 5. Default to CodexAgent
4157
+ *
4158
+ * @param {{toolFlag?: string, sessionName?: string | null}} options
4159
+ * @returns {{agent: Agent, error?: string}}
3426
4160
  */
3427
- function getAgentFromInvocation() {
4161
+ function resolveAgent({ toolFlag, sessionName } = {}) {
4162
+ // 1. Explicit --tool flag takes highest priority
4163
+ if (toolFlag) {
4164
+ if (toolFlag === "claude") return { agent: ClaudeAgent };
4165
+ if (toolFlag === "codex") return { agent: CodexAgent };
4166
+ return { agent: CodexAgent, error: `unknown tool '${toolFlag}'` };
4167
+ }
4168
+
4169
+ // 2. Infer from session name (e.g., "claude-archangel-..." or "codex-partner-...")
4170
+ if (sessionName) {
4171
+ const parsed = parseSessionName(sessionName);
4172
+ if (parsed?.tool === "claude") return { agent: ClaudeAgent };
4173
+ if (parsed?.tool === "codex") return { agent: CodexAgent };
4174
+ }
4175
+
4176
+ // 3. CLI invocation name
3428
4177
  const invoked = path.basename(process.argv[1], ".js");
3429
- if (invoked === "axclaude" || invoked === "claude") return ClaudeAgent;
3430
- if (invoked === "axcodex" || invoked === "codex") return CodexAgent;
4178
+ if (invoked === "axclaude" || invoked === "claude") return { agent: ClaudeAgent };
4179
+ if (invoked === "axcodex" || invoked === "codex") return { agent: CodexAgent };
3431
4180
 
3432
- // Default based on AX_DEFAULT_TOOL env var, or codex if not set
4181
+ // 4. AX_DEFAULT_TOOL environment variable
3433
4182
  const defaultTool = process.env.AX_DEFAULT_TOOL;
3434
- if (defaultTool === "claude") return ClaudeAgent;
3435
- if (defaultTool === "codex" || !defaultTool) return CodexAgent;
4183
+ if (defaultTool === "claude") return { agent: ClaudeAgent };
4184
+ if (defaultTool === "codex" || !defaultTool) return { agent: CodexAgent };
3436
4185
 
3437
4186
  console.error(`WARNING: invalid AX_DEFAULT_TOOL="${defaultTool}", using codex`);
3438
- return CodexAgent;
4187
+ return { agent: CodexAgent };
3439
4188
  }
3440
4189
 
3441
4190
  /**
@@ -3444,23 +4193,30 @@ function getAgentFromInvocation() {
3444
4193
  */
3445
4194
  function printHelp(agent, cliName) {
3446
4195
  const name = cliName;
3447
- const backendName = agent.name === "codex" ? "OpenAI Codex" : "Claude";
4196
+ const backendName = agent.displayName;
3448
4197
  const hasReview = !!agent.reviewOptions;
3449
4198
 
3450
- console.log(`${name}.js - agentic assistant CLI (${backendName})
4199
+ console.log(`${name} v${VERSION} - agentic assistant CLI (${backendName})
4200
+
4201
+ Usage: ${name} [OPTIONS] <command|message> [ARGS...]
3451
4202
 
3452
4203
  Commands:
3453
4204
  agents List all running agents with state and log paths
4205
+ target Show default target session for current tool
3454
4206
  attach [SESSION] Attach to agent session interactively
3455
4207
  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)
4208
+ mailbox View archangel observations (--limit=N, --branch=X, --all)
4209
+ summon [name] Summon archangels (all, or by name)
4210
+ recall [name] Recall archangels (all, or by name)
4211
+ kill Kill sessions (--all, --session=NAME, --orphans [--force])
3460
4212
  status Check state (exit: 0=ready, 2=rate_limited, 3=confirming, 4=thinking)
3461
4213
  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` : ""}
4214
+ debug Show raw screen output and detected state${
4215
+ hasReview
4216
+ ? `
4217
+ review [TYPE] Review code: pr, uncommitted, commit, custom`
4218
+ : ""
4219
+ }
3464
4220
  select N Select menu option N
3465
4221
  approve Approve pending action (send 'y')
3466
4222
  reject Reject pending action (send 'n')
@@ -3471,37 +4227,42 @@ Commands:
3471
4227
 
3472
4228
  Flags:
3473
4229
  --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)
4230
+ --session=NAME Target session by name, archangel name, or UUID prefix (self = current)
4231
+ --wait Wait for response (default for messages; required for approve/reject)
4232
+ --no-wait Fire-and-forget: send message, print session ID, exit immediately
4233
+ --timeout=N Set timeout in seconds (default: ${DEFAULT_TIMEOUT_MS / 1000}, reviews: ${REVIEW_TIMEOUT_MS / 1000})
3478
4234
  --yolo Skip all confirmations (dangerous)
3479
4235
  --fresh Reset conversation before review
4236
+ --orphans Kill orphaned claude/codex processes (PPID=1)
4237
+ --force Use SIGKILL instead of SIGTERM (with --orphans)
3480
4238
 
3481
4239
  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
4240
+ AX_DEFAULT_TOOL Default agent when using 'ax' (claude or codex, default: codex)
4241
+ ${agent.envVar} Override default session name
4242
+ AX_CLAUDE_CONFIG_DIR Override Claude config directory (default: ~/.claude)
4243
+ AX_CODEX_CONFIG_DIR Override Codex config directory (default: ~/.codex)
4244
+ AX_REVIEW_MODE=exec Bypass /review, send instructions directly (codex only)
4245
+ AX_DEBUG=1 Enable debug logging
3488
4246
 
3489
4247
  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)`);
4248
+ ${name} "explain this codebase"
4249
+ ${name} "review the error handling" # Auto custom review (${REVIEW_TIMEOUT_MS / 60000}min timeout)
4250
+ ${name} "FYI: auth was refactored" --no-wait # Send context to a working session (no response needed)
4251
+ ${name} review uncommitted --wait
4252
+ ${name} approve --wait
4253
+ ${name} kill # Kill agents in current project
4254
+ ${name} kill --all # Kill all agents across all projects
4255
+ ${name} kill --session=NAME # Kill specific session
4256
+ ${name} send "1[Enter]" # Recovery: select option 1 and press Enter
4257
+ ${name} send "[Escape][Escape]" # Recovery: escape out of a dialog
4258
+ ${name} summon # Summon all archangels from .ai/agents/*.md
4259
+ ${name} summon reviewer # Summon by name (creates config if new)
4260
+ ${name} recall # Recall all archangels
4261
+ ${name} recall reviewer # Recall one by name
4262
+ ${name} agents # List all agents (shows TYPE=archangel)
4263
+
4264
+ Note: Reviews and complex tasks may take several minutes.
4265
+ Use Bash run_in_background for long operations (not --no-wait).`);
3505
4266
  }
3506
4267
 
3507
4268
  async function main() {
@@ -3516,33 +4277,21 @@ async function main() {
3516
4277
  const args = process.argv.slice(2);
3517
4278
  const cliName = path.basename(process.argv[1], ".js");
3518
4279
 
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
- }
4280
+ // Parse all flags and positionals in one place
4281
+ const { flags, positionals } = parseCliArgs(args);
4282
+
4283
+ if (flags.version) {
4284
+ console.log(VERSION);
4285
+ process.exit(0);
3538
4286
  }
3539
4287
 
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") {
4288
+ // Extract flags into local variables for convenience
4289
+ const { wait, noWait, yolo, fresh, reasoning, follow, all, orphans, force } = flags;
4290
+
4291
+ // Session resolution (must happen before agent resolution so we can infer tool from session name)
4292
+ let session = null;
4293
+ if (flags.session) {
4294
+ if (flags.session === "self") {
3546
4295
  const current = tmuxCurrentSession();
3547
4296
  if (!current) {
3548
4297
  console.log("ERROR: --session=self requires running inside tmux");
@@ -3550,99 +4299,106 @@ async function main() {
3550
4299
  }
3551
4300
  session = current;
3552
4301
  } else {
3553
- // Resolve partial names, daemon names, and UUID prefixes
3554
- session = resolveSessionName(val);
4302
+ // Resolve partial names, archangel names, and UUID prefixes
4303
+ session = resolveSessionName(flags.session);
3555
4304
  }
3556
4305
  }
3557
4306
 
3558
- // Timeout
4307
+ // Agent resolution (considers --tool flag, session name, invocation, and env vars)
4308
+ const { agent, error: agentError } = resolveAgent({ toolFlag: flags.tool, sessionName: session });
4309
+ if (agentError) {
4310
+ console.log(`ERROR: ${agentError}`);
4311
+ process.exit(1);
4312
+ }
4313
+
4314
+ // If no explicit session, use agent's default
4315
+ if (!session) {
4316
+ session = agent.getDefaultSession();
4317
+ }
4318
+
4319
+ // Timeout (convert seconds to milliseconds)
3559
4320
  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) {
4321
+ if (flags.timeout !== undefined) {
4322
+ if (isNaN(flags.timeout) || flags.timeout <= 0) {
3564
4323
  console.log("ERROR: invalid timeout");
3565
4324
  process.exit(1);
3566
4325
  }
3567
- timeoutMs = val * 1000;
4326
+ timeoutMs = flags.timeout * 1000;
3568
4327
  }
3569
4328
 
3570
4329
  // 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
- }
4330
+ const tail = flags.tail ?? 50;
3576
4331
 
3577
4332
  // 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
- }
4333
+ const limit = flags.limit ?? 20;
3583
4334
 
3584
4335
  // 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];
4336
+ const branch = flags.branch ?? null;
4337
+
4338
+ // Command is first positional
4339
+ const cmd = positionals[0];
3606
4340
 
3607
4341
  // Dispatch commands
3608
4342
  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 });
4343
+ if (cmd === "target") {
4344
+ const defaultSession = agent.getDefaultSession();
4345
+ if (defaultSession) {
4346
+ console.log(defaultSession);
4347
+ } else {
4348
+ console.log("NO_TARGET");
4349
+ process.exit(1);
4350
+ }
4351
+ return;
4352
+ }
4353
+ if (cmd === "summon") return cmdSummon(positionals[1]);
4354
+ if (cmd === "recall") return cmdRecall(positionals[1]);
4355
+ if (cmd === "archangel") return cmdArchangel(positionals[1]);
4356
+ if (cmd === "kill") return cmdKill(session, { all, orphans, force });
4357
+ if (cmd === "attach") return cmdAttach(positionals[1] || session);
4358
+ if (cmd === "log") return cmdLog(positionals[1] || session, { tail, reasoning, follow });
3614
4359
  if (cmd === "mailbox") return cmdMailbox({ limit, branch, all });
3615
4360
  if (cmd === "approve") return cmdApprove(agent, session, { wait, timeoutMs });
3616
4361
  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 });
4362
+ if (cmd === "review")
4363
+ return cmdReview(agent, session, positionals[1], positionals[2], {
4364
+ wait,
4365
+ fresh,
4366
+ timeoutMs,
4367
+ });
3618
4368
  if (cmd === "status") return cmdStatus(agent, session);
3619
4369
  if (cmd === "debug") return cmdDebug(agent, session);
3620
4370
  if (cmd === "output") {
3621
- const indexArg = filteredArgs[1];
4371
+ const indexArg = positionals[1];
3622
4372
  const index = indexArg?.startsWith("-") ? parseInt(indexArg, 10) : 0;
3623
4373
  return cmdOutput(agent, session, index, { wait, timeoutMs });
3624
4374
  }
3625
- if (cmd === "send" && filteredArgs.length > 1) return cmdSend(session, filteredArgs.slice(1).join(" "));
4375
+ if (cmd === "send" && positionals.length > 1)
4376
+ return cmdSend(session, positionals.slice(1).join(" "));
3626
4377
  if (cmd === "compact") return cmdAsk(agent, session, "/compact", { noWait: true, timeoutMs });
3627
4378
  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 });
4379
+ if (cmd === "select" && positionals[1])
4380
+ return cmdSelect(agent, session, positionals[1], { wait, timeoutMs });
3629
4381
 
3630
4382
  // Default: send message
3631
- let message = filteredArgs.join(" ");
4383
+ let message = positionals.join(" ");
3632
4384
  if (!message && hasStdinData()) {
3633
4385
  message = await readStdin();
3634
4386
  }
3635
4387
 
3636
- if (!message || cmd === "--help" || cmd === "-h") {
4388
+ if (!message || flags.help) {
3637
4389
  printHelp(agent, cliName);
3638
4390
  process.exit(0);
3639
4391
  }
3640
4392
 
3641
- // Detect "please review" and route to custom review mode
3642
- const reviewMatch = message.match(/^please review\s*(.*)/i);
4393
+ // Detect "review ..." or "please review ..." and route to custom review mode
4394
+ const reviewMatch = message.match(/^(?:please )?review\s*(.*)/i);
3643
4395
  if (reviewMatch && agent.reviewOptions) {
3644
4396
  const customInstructions = reviewMatch[1].trim() || null;
3645
- return cmdReview(agent, session, "custom", customInstructions, { wait: !noWait, yolo, timeoutMs });
4397
+ return cmdReview(agent, session, "custom", customInstructions, {
4398
+ wait: !noWait,
4399
+ yolo,
4400
+ timeoutMs: flags.timeout !== undefined ? timeoutMs : REVIEW_TIMEOUT_MS,
4401
+ });
3646
4402
  }
3647
4403
 
3648
4404
  return cmdAsk(agent, session, message, { noWait, yolo, timeoutMs });
@@ -3650,17 +4406,21 @@ async function main() {
3650
4406
 
3651
4407
  // Run main() only when executed directly (not when imported for testing)
3652
4408
  // 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
- })();
4409
+ const isDirectRun =
4410
+ process.argv[1] &&
4411
+ (() => {
4412
+ try {
4413
+ return realpathSync(process.argv[1]) === __filename;
4414
+ } catch {
4415
+ return false;
4416
+ }
4417
+ })();
3661
4418
  if (isDirectRun) {
3662
4419
  main().catch((err) => {
3663
4420
  console.log(`ERROR: ${err.message}`);
4421
+ if (err instanceof TimeoutError && err.session) {
4422
+ console.log(`Hint: Use 'ax debug --session=${err.session}' to see current screen state`);
4423
+ }
3664
4424
  process.exit(1);
3665
4425
  });
3666
4426
  }
@@ -3670,6 +4430,7 @@ export {
3670
4430
  parseSessionName,
3671
4431
  parseAgentConfig,
3672
4432
  parseKeySequence,
4433
+ parseCliArgs,
3673
4434
  getClaudeProjectPath,
3674
4435
  matchesPattern,
3675
4436
  getBaseDir,
@@ -3681,4 +4442,3 @@ export {
3681
4442
  detectState,
3682
4443
  State,
3683
4444
  };
3684
-