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

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 +1335 -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,161 @@ 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
+ const watchingLabel = parentSession
2707
+ ? parentSession.session || parentSession.uuid?.slice(0, 8)
2708
+ : null;
2709
+ console.log(
2710
+ `Summoning: ${config.name} (pid ${child.pid})${watchingLabel ? ` [watching: ${watchingLabel}]` : ""}`,
2711
+ );
2116
2712
  }
2117
2713
 
2118
2714
  // =============================================================================
2119
- // Command: daemon (runs as the daemon process itself)
2715
+ // Command: archangel (runs as the archangel process itself)
2120
2716
  // =============================================================================
2121
2717
 
2122
2718
  /**
2123
2719
  * @param {string | undefined} agentName
2124
2720
  */
2125
- async function cmdDaemon(agentName) {
2721
+ async function cmdArchangel(agentName) {
2126
2722
  if (!agentName) {
2127
- console.error("Usage: ./ax.js daemon <name>");
2723
+ console.error("Usage: ./ax.js archangel <name>");
2128
2724
  process.exit(1);
2129
2725
  }
2130
2726
  // Load agent config
2131
2727
  const configPath = path.join(AGENTS_DIR, `${agentName}.md`);
2132
2728
  if (!existsSync(configPath)) {
2133
- console.error(`[daemon:${agentName}] Config not found: ${configPath}`);
2729
+ console.error(`[archangel:${agentName}] Config not found: ${configPath}`);
2134
2730
  process.exit(1);
2135
2731
  }
2136
2732
 
2137
2733
  const content = readFileSync(configPath, "utf-8");
2138
2734
  const configResult = parseAgentConfig(`${agentName}.md`, content);
2139
2735
  if (!configResult || "error" in configResult) {
2140
- console.error(`[daemon:${agentName}] Invalid config`);
2736
+ console.error(`[archangel:${agentName}] Invalid config`);
2141
2737
  process.exit(1);
2142
2738
  }
2143
2739
  const config = configResult;
2144
2740
 
2145
2741
  const agent = config.tool === "claude" ? ClaudeAgent : CodexAgent;
2146
- const sessionName = generateDaemonSessionName(config);
2742
+ const sessionName = generateArchangelSessionName(config);
2147
2743
 
2148
2744
  // Check agent CLI is installed before trying to start
2149
2745
  const cliCheck = spawnSync("which", [agent.name], { encoding: "utf-8" });
2150
2746
  if (cliCheck.status !== 0) {
2151
- console.error(`[daemon:${agentName}] ERROR: ${agent.name} CLI is not installed or not in PATH`);
2747
+ console.error(
2748
+ `[archangel:${agentName}] ERROR: ${agent.name} CLI is not installed or not in PATH`,
2749
+ );
2152
2750
  process.exit(1);
2153
2751
  }
2154
2752
 
@@ -2158,7 +2756,7 @@ async function cmdDaemon(agentName) {
2158
2756
 
2159
2757
  // Wait for agent to be ready
2160
2758
  const start = Date.now();
2161
- while (Date.now() - start < DAEMON_STARTUP_TIMEOUT_MS) {
2759
+ while (Date.now() - start < ARCHANGEL_STARTUP_TIMEOUT_MS) {
2162
2760
  const screen = tmuxCapture(sessionName);
2163
2761
  const state = agent.getState(screen);
2164
2762
 
@@ -2169,7 +2767,7 @@ async function cmdDaemon(agentName) {
2169
2767
 
2170
2768
  // Handle bypass permissions confirmation dialog (Claude Code shows this for --dangerously-skip-permissions)
2171
2769
  if (screen.includes("Bypass Permissions mode") && screen.includes("Yes, I accept")) {
2172
- console.log(`[daemon:${agentName}] Accepting bypass permissions dialog`);
2770
+ console.log(`[archangel:${agentName}] Accepting bypass permissions dialog`);
2173
2771
  tmuxSend(sessionName, "2"); // Select "Yes, I accept"
2174
2772
  await sleep(300);
2175
2773
  tmuxSend(sessionName, "Enter");
@@ -2178,7 +2776,7 @@ async function cmdDaemon(agentName) {
2178
2776
  }
2179
2777
 
2180
2778
  if (state === State.READY) {
2181
- console.log(`[daemon:${agentName}] Started session: ${sessionName}`);
2779
+ console.log(`[archangel:${agentName}] Started session: ${sessionName}`);
2182
2780
  break;
2183
2781
  }
2184
2782
 
@@ -2200,6 +2798,13 @@ async function cmdDaemon(agentName) {
2200
2798
  let isProcessing = false;
2201
2799
  const intervalMs = config.interval * 1000;
2202
2800
 
2801
+ // Hash tracking for incremental context updates
2802
+ /** @type {string | null} */
2803
+ let lastPlanHash = null;
2804
+ /** @type {string | null} */
2805
+ let lastTodosHash = null;
2806
+ let isFirstTrigger = true;
2807
+
2203
2808
  async function processChanges() {
2204
2809
  clearTimeout(debounceTimer);
2205
2810
  clearTimeout(maxWaitTimer);
@@ -2210,16 +2815,32 @@ async function cmdDaemon(agentName) {
2210
2815
  isProcessing = true;
2211
2816
 
2212
2817
  const files = [...changedFiles];
2213
- changedFiles = new Set(); // atomic swap to avoid losing changes during processing
2818
+ changedFiles = new Set(); // atomic swap to avoid losing changes during processing
2214
2819
 
2215
2820
  try {
2216
2821
  // Get parent session log path for JSONL extraction
2217
2822
  const parent = findParentSession();
2218
2823
  const logPath = parent ? findClaudeLogPath(parent.uuid, parent.session) : null;
2219
2824
 
2825
+ // Get orientation context (plan and todos) from parent session
2826
+ const meta = parent?.session ? getSessionMeta(parent.session) : null;
2827
+ const planContent = meta?.slug ? readPlanFile(meta.slug) : null;
2828
+ const todosContent = meta?.todos?.length ? formatTodos(meta.todos) : null;
2829
+
2830
+ // Check if plan/todos have changed since last trigger
2831
+ const planHash = quickHash(planContent);
2832
+ const todosHash = quickHash(todosContent);
2833
+ const includePlan = planHash !== lastPlanHash;
2834
+ const includeTodos = todosHash !== lastTodosHash;
2835
+
2836
+ // Update tracking for next trigger
2837
+ lastPlanHash = planHash;
2838
+ lastTodosHash = todosHash;
2839
+
2220
2840
  // Build file-specific context from JSONL
2221
2841
  const fileContexts = [];
2222
- for (const file of files.slice(0, 5)) { // Limit to 5 files
2842
+ for (const file of files.slice(0, 5)) {
2843
+ // Limit to 5 files
2223
2844
  const ctx = extractFileEditContext(logPath, file);
2224
2845
  if (ctx) {
2225
2846
  fileContexts.push({ file, ...ctx });
@@ -2227,7 +2848,18 @@ async function cmdDaemon(agentName) {
2227
2848
  }
2228
2849
 
2229
2850
  // Build the prompt
2230
- let prompt = basePrompt;
2851
+ // First trigger: include intro, guidelines, and focus (archangel has memory)
2852
+ let prompt = isFirstTrigger
2853
+ ? `You are the archangel of ${agentName}.\n\n${ARCHANGEL_PREAMBLE}\n\n## Focus\n\n${basePrompt}\n\n---`
2854
+ : "";
2855
+
2856
+ // Add orientation context (plan and todos) only if changed since last trigger
2857
+ if (includePlan && planContent) {
2858
+ prompt += (prompt ? "\n\n" : "") + "## Current Plan\n\n" + planContent;
2859
+ }
2860
+ if (includeTodos && todosContent) {
2861
+ prompt += (prompt ? "\n\n" : "") + "## Current Todos\n\n" + todosContent;
2862
+ }
2231
2863
 
2232
2864
  if (fileContexts.length > 0) {
2233
2865
  prompt += "\n\n## Recent Edits (from parent session)\n";
@@ -2246,26 +2878,33 @@ async function cmdDaemon(agentName) {
2246
2878
  }
2247
2879
 
2248
2880
  if (ctx.readsBefore.length > 0) {
2249
- const reads = ctx.readsBefore.map(f => f.split("/").pop()).join(", ");
2881
+ const reads = ctx.readsBefore.map((f) => f.split("/").pop()).join(", ");
2250
2882
  prompt += `**Files read before:** ${reads}\n`;
2251
2883
  }
2252
2884
  }
2253
2885
 
2254
2886
  prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
2255
2887
 
2256
- const gitContext = buildGitContext(DAEMON_GIT_CONTEXT_HOURS, DAEMON_GIT_CONTEXT_MAX_LINES);
2888
+ const gitContext = buildGitContext(
2889
+ ARCHANGEL_GIT_CONTEXT_HOURS,
2890
+ ARCHANGEL_GIT_CONTEXT_MAX_LINES,
2891
+ );
2257
2892
  if (gitContext) {
2258
2893
  prompt += "\n\n## Git Context\n\n" + gitContext;
2259
2894
  }
2260
2895
 
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."';
2896
+ prompt += "\n\nReview these changes.";
2262
2897
  } else {
2263
2898
  // 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);
2899
+ const parentContext = getParentSessionContext(ARCHANGEL_PARENT_CONTEXT_ENTRIES);
2900
+ const gitContext = buildGitContext(
2901
+ ARCHANGEL_GIT_CONTEXT_HOURS,
2902
+ ARCHANGEL_GIT_CONTEXT_MAX_LINES,
2903
+ );
2266
2904
 
2267
2905
  if (parentContext) {
2268
- prompt += "\n\n## Main Session Context\n\nThe user is currently working on:\n\n" + parentContext;
2906
+ prompt +=
2907
+ "\n\n## Main Session Context\n\nThe user is currently working on:\n\n" + parentContext;
2269
2908
  }
2270
2909
 
2271
2910
  prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
@@ -2274,13 +2913,12 @@ async function cmdDaemon(agentName) {
2274
2913
  prompt += "\n\n## Git Context\n\n" + gitContext;
2275
2914
  }
2276
2915
 
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."';
2916
+ prompt += "\n\nReview these changes.";
2278
2917
  }
2279
2918
 
2280
-
2281
2919
  // Check session still exists
2282
2920
  if (!tmuxHasSession(sessionName)) {
2283
- console.log(`[daemon:${agentName}] Session gone, exiting`);
2921
+ console.log(`[archangel:${agentName}] Session gone, exiting`);
2284
2922
  process.exit(0);
2285
2923
  }
2286
2924
 
@@ -2289,12 +2927,12 @@ async function cmdDaemon(agentName) {
2289
2927
  const state = agent.getState(screen);
2290
2928
 
2291
2929
  if (state === State.RATE_LIMITED) {
2292
- console.error(`[daemon:${agentName}] Rate limited - stopping`);
2930
+ console.error(`[archangel:${agentName}] Rate limited - stopping`);
2293
2931
  process.exit(2);
2294
2932
  }
2295
2933
 
2296
2934
  if (state !== State.READY) {
2297
- console.log(`[daemon:${agentName}] Agent not ready (${state}), skipping`);
2935
+ console.log(`[archangel:${agentName}] Agent not ready (${state}), skipping`);
2298
2936
  isProcessing = false;
2299
2937
  return;
2300
2938
  }
@@ -2304,38 +2942,37 @@ async function cmdDaemon(agentName) {
2304
2942
  await sleep(200); // Allow time for large prompts to be processed
2305
2943
  tmuxSend(sessionName, "Enter");
2306
2944
  await sleep(100); // Ensure Enter is processed
2945
+ isFirstTrigger = false;
2307
2946
 
2308
2947
  // Wait for response
2309
- const { state: endState, screen: afterScreen } = await waitForResponse(agent, sessionName, DAEMON_RESPONSE_TIMEOUT_MS);
2948
+ const { state: endState, screen: afterScreen } = await waitForResponse(
2949
+ agent,
2950
+ sessionName,
2951
+ ARCHANGEL_RESPONSE_TIMEOUT_MS,
2952
+ );
2310
2953
 
2311
2954
  if (endState === State.RATE_LIMITED) {
2312
- console.error(`[daemon:${agentName}] Rate limited - stopping`);
2955
+ console.error(`[archangel:${agentName}] Rate limited - stopping`);
2313
2956
  process.exit(2);
2314
2957
  }
2315
2958
 
2316
-
2317
2959
  const cleanedResponse = agent.getResponse(sessionName, afterScreen) || "";
2318
2960
 
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;
2961
+ const isSkippable = !cleanedResponse || cleanedResponse.trim() === "EMPTY_RESPONSE";
2323
2962
 
2324
- if (cleanedResponse && !isGarbage && !cleanedResponse.toLowerCase().includes("no issues found")) {
2963
+ if (!isSkippable) {
2325
2964
  writeToMailbox({
2326
2965
  agent: /** @type {string} */ (agentName),
2327
2966
  session: sessionName,
2328
2967
  branch: getCurrentBranch(),
2329
2968
  commit: getCurrentCommit(),
2330
2969
  files,
2331
- message: cleanedResponse.slice(0, 1000),
2970
+ message: cleanedResponse,
2332
2971
  });
2333
- console.log(`[daemon:${agentName}] Wrote observation for ${files.length} file(s)`);
2334
- } else if (isGarbage) {
2335
- console.log(`[daemon:${agentName}] Skipped garbage response`);
2972
+ console.log(`[archangel:${agentName}] Wrote observation for ${files.length} file(s)`);
2336
2973
  }
2337
2974
  } catch (err) {
2338
- console.error(`[daemon:${agentName}] Error:`, err instanceof Error ? err.message : err);
2975
+ console.error(`[archangel:${agentName}] Error:`, err instanceof Error ? err.message : err);
2339
2976
  }
2340
2977
 
2341
2978
  isProcessing = false;
@@ -2343,7 +2980,10 @@ async function cmdDaemon(agentName) {
2343
2980
 
2344
2981
  function scheduleProcessChanges() {
2345
2982
  processChanges().catch((err) => {
2346
- console.error(`[daemon:${agentName}] Unhandled error:`, err instanceof Error ? err.message : err);
2983
+ console.error(
2984
+ `[archangel:${agentName}] Unhandled error:`,
2985
+ err instanceof Error ? err.message : err,
2986
+ );
2347
2987
  });
2348
2988
  }
2349
2989
 
@@ -2364,16 +3004,16 @@ async function cmdDaemon(agentName) {
2364
3004
  // Check if session still exists periodically
2365
3005
  const sessionCheck = setInterval(() => {
2366
3006
  if (!tmuxHasSession(sessionName)) {
2367
- console.log(`[daemon:${agentName}] Session gone, exiting`);
3007
+ console.log(`[archangel:${agentName}] Session gone, exiting`);
2368
3008
  stopWatching();
2369
3009
  clearInterval(sessionCheck);
2370
3010
  process.exit(0);
2371
3011
  }
2372
- }, DAEMON_HEALTH_CHECK_MS);
3012
+ }, ARCHANGEL_HEALTH_CHECK_MS);
2373
3013
 
2374
3014
  // Handle graceful shutdown
2375
3015
  process.on("SIGTERM", () => {
2376
- console.log(`[daemon:${agentName}] Received SIGTERM, shutting down`);
3016
+ console.log(`[archangel:${agentName}] Received SIGTERM, shutting down`);
2377
3017
  stopWatching();
2378
3018
  clearInterval(sessionCheck);
2379
3019
  tmuxSend(sessionName, "C-c");
@@ -2384,7 +3024,7 @@ async function cmdDaemon(agentName) {
2384
3024
  });
2385
3025
 
2386
3026
  process.on("SIGINT", () => {
2387
- console.log(`[daemon:${agentName}] Received SIGINT, shutting down`);
3027
+ console.log(`[archangel:${agentName}] Received SIGINT, shutting down`);
2388
3028
  stopWatching();
2389
3029
  clearInterval(sessionCheck);
2390
3030
  tmuxSend(sessionName, "C-c");
@@ -2394,48 +3034,33 @@ async function cmdDaemon(agentName) {
2394
3034
  }, 500);
2395
3035
  });
2396
3036
 
2397
- console.log(`[daemon:${agentName}] Watching: ${config.watch.join(", ")}`);
3037
+ console.log(`[archangel:${agentName}] Watching: ${config.watch.join(", ")}`);
2398
3038
 
2399
3039
  // Keep the process alive
2400
3040
  await new Promise(() => {});
2401
3041
  }
2402
3042
 
2403
3043
  /**
2404
- * @param {string} action
2405
- * @param {string | null} [daemonName]
3044
+ * @param {string | null} [name]
2406
3045
  */
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
- }
3046
+ async function cmdSummon(name = null) {
3047
+ const configs = loadAgentConfigs();
2426
3048
 
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
- }
3049
+ // If name provided but doesn't exist, create it
3050
+ if (name) {
3051
+ const exists = configs.some((c) => c.name === name);
3052
+ if (!exists) {
3053
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
3054
+ console.log("ERROR: Name must contain only letters, numbers, dashes, and underscores");
3055
+ process.exit(1);
3056
+ }
2432
3057
 
2433
- // Create agents directory if needed
2434
- if (!existsSync(AGENTS_DIR)) {
2435
- mkdirSync(AGENTS_DIR, { recursive: true });
2436
- }
3058
+ if (!existsSync(AGENTS_DIR)) {
3059
+ mkdirSync(AGENTS_DIR, { recursive: true });
3060
+ }
2437
3061
 
2438
- const template = `---
3062
+ const agentPath = path.join(AGENTS_DIR, `${name}.md`);
3063
+ const template = `---
2439
3064
  tool: claude
2440
3065
  watch: ["**/*.{ts,tsx,js,jsx,mjs,mts}"]
2441
3066
  interval: 30
@@ -2443,75 +3068,76 @@ interval: 30
2443
3068
 
2444
3069
  Review changed files for bugs, type errors, and edge cases.
2445
3070
  `;
3071
+ writeFileSync(agentPath, template);
3072
+ console.log(`Created: ${agentPath}`);
3073
+ console.log(`Edit the file to customize, then run: ax summon ${name}`);
3074
+ return;
3075
+ }
3076
+ }
2446
3077
 
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}`);
3078
+ if (configs.length === 0) {
3079
+ console.log(`No archangels found in ${AGENTS_DIR}/`);
2450
3080
  return;
2451
3081
  }
2452
3082
 
3083
+ const targetConfigs = name ? configs.filter((c) => c.name === name) : configs;
3084
+
3085
+ ensureMailboxHookScript();
3086
+
3087
+ const parentSession = findCurrentClaudeSession();
3088
+ if (parentSession) {
3089
+ console.log(`Parent session: ${parentSession.session || "(non-tmux)"} [${parentSession.uuid}]`);
3090
+ }
3091
+
3092
+ for (const config of targetConfigs) {
3093
+ const sessionPattern = getArchangelSessionPattern(config);
3094
+ const existing = findArchangelSession(sessionPattern);
3095
+
3096
+ if (!existing) {
3097
+ startArchangel(config, parentSession);
3098
+ } else {
3099
+ console.log(`Already running: ${config.name} (${existing})`);
3100
+ }
3101
+ }
3102
+
3103
+ gcMailbox(24);
3104
+ }
3105
+
3106
+ /**
3107
+ * @param {string | null} [name]
3108
+ */
3109
+ async function cmdRecall(name = null) {
2453
3110
  const configs = loadAgentConfigs();
2454
3111
 
2455
3112
  if (configs.length === 0) {
2456
- console.log(`No agent configs found in ${AGENTS_DIR}/`);
3113
+ console.log(`No archangels found in ${AGENTS_DIR}/`);
2457
3114
  return;
2458
3115
  }
2459
3116
 
2460
- // Filter to specific daemon if name provided
2461
- const targetConfigs = daemonName
2462
- ? configs.filter((c) => c.name === daemonName)
2463
- : configs;
3117
+ const targetConfigs = name ? configs.filter((c) => c.name === name) : configs;
2464
3118
 
2465
- if (daemonName && targetConfigs.length === 0) {
2466
- console.log(`ERROR: daemon '${daemonName}' not found in ${AGENTS_DIR}/`);
3119
+ if (name && targetConfigs.length === 0) {
3120
+ console.log(`ERROR: archangel '${name}' not found in ${AGENTS_DIR}/`);
2467
3121
  process.exit(1);
2468
3122
  }
2469
3123
 
2470
- // Ensure hook script exists on start
2471
- if (action === "start") {
2472
- ensureMailboxHookScript();
2473
- }
3124
+ for (const config of targetConfigs) {
3125
+ const sessionPattern = getArchangelSessionPattern(config);
3126
+ const existing = findArchangelSession(sessionPattern);
2474
3127
 
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}]`);
3128
+ if (existing) {
3129
+ tmuxSend(existing, "C-c");
3130
+ await sleep(300);
3131
+ tmuxKill(existing);
3132
+ console.log(`Recalled: ${config.name} (${existing})`);
2480
3133
  } else {
2481
- console.log("Parent session: null (not running from Claude or no active sessions)");
3134
+ console.log(`Not running: ${config.name}`);
2482
3135
  }
2483
3136
  }
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
3137
  }
2512
3138
 
2513
3139
  // Version of the hook script template - bump when making changes
2514
- const HOOK_SCRIPT_VERSION = "2";
3140
+ const HOOK_SCRIPT_VERSION = "4";
2515
3141
 
2516
3142
  function ensureMailboxHookScript() {
2517
3143
  const hooksDir = HOOKS_DIR;
@@ -2529,34 +3155,54 @@ function ensureMailboxHookScript() {
2529
3155
  mkdirSync(hooksDir, { recursive: true });
2530
3156
  }
2531
3157
 
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
3158
  const hookCode = `#!/usr/bin/env node
2537
3159
  ${versionMarker}
2538
- // Auto-generated hook script - do not edit manually
2539
-
2540
3160
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
3161
+ import { dirname, join } from "node:path";
3162
+ import { fileURLToPath } from "node:url";
3163
+ import { createHash } from "node:crypto";
2541
3164
 
3165
+ const __dirname = dirname(fileURLToPath(import.meta.url));
3166
+ const AI_DIR = join(__dirname, "..");
2542
3167
  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)
3168
+ const MAILBOX = join(AI_DIR, "mailbox.jsonl");
3169
+ const MAX_AGE_MS = 60 * 60 * 1000;
2546
3170
 
2547
- if (!existsSync(MAILBOX)) process.exit(0);
3171
+ // Read hook input from stdin
3172
+ let hookInput = {};
3173
+ try {
3174
+ const stdinData = readFileSync(0, "utf-8").trim();
3175
+ if (stdinData) hookInput = JSON.parse(stdinData);
3176
+ } catch (err) {
3177
+ if (DEBUG) console.error("[hook] stdin parse:", err.message);
3178
+ }
3179
+
3180
+ const sessionId = hookInput.session_id || "";
3181
+ const hookEvent = hookInput.hook_event_name || "";
2548
3182
 
2549
- // Note: commit filtering removed - age + lastSeen is sufficient
3183
+ if (DEBUG) console.error("[hook] session:", sessionId, "event:", hookEvent);
2550
3184
 
2551
- // Read last seen timestamp
2552
- let lastSeen = 0;
3185
+ // NO-OP for archangel or partner sessions
3186
+ if (sessionId.includes("-archangel-") || sessionId.includes("-partner-")) {
3187
+ if (DEBUG) console.error("[hook] skipping non-parent session");
3188
+ process.exit(0);
3189
+ }
3190
+
3191
+ // Per-session last-seen tracking (single JSON file, self-cleaning)
3192
+ const sessionHash = sessionId ? createHash("md5").update(sessionId).digest("hex").slice(0, 8) : "default";
3193
+ const LAST_SEEN_FILE = join(AI_DIR, "mailbox-last-seen.json");
3194
+
3195
+ if (!existsSync(MAILBOX)) process.exit(0);
3196
+
3197
+ let lastSeenMap = {};
2553
3198
  try {
2554
- if (existsSync(LAST_SEEN)) {
2555
- lastSeen = parseInt(readFileSync(LAST_SEEN, "utf-8").trim(), 10) || 0;
3199
+ if (existsSync(LAST_SEEN_FILE)) {
3200
+ lastSeenMap = JSON.parse(readFileSync(LAST_SEEN_FILE, "utf-8"));
2556
3201
  }
2557
3202
  } catch (err) {
2558
3203
  if (DEBUG) console.error("[hook] readLastSeen:", err.message);
2559
3204
  }
3205
+ const lastSeen = lastSeenMap[sessionHash] || 0;
2560
3206
 
2561
3207
  const now = Date.now();
2562
3208
  const lines = readFileSync(MAILBOX, "utf-8").trim().split("\\n").filter(Boolean);
@@ -2567,11 +3213,7 @@ for (const line of lines) {
2567
3213
  const entry = JSON.parse(line);
2568
3214
  const ts = new Date(entry.timestamp).getTime();
2569
3215
  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
3216
  if (age < MAX_AGE_MS && ts > lastSeen) {
2574
- // Extract session prefix (without UUID) for shorter log command
2575
3217
  const session = entry.payload.session || "";
2576
3218
  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
3219
  relevant.push({ agent: entry.payload.agent, sessionPrefix, message: entry.payload.message });
@@ -2582,22 +3224,39 @@ for (const line of lines) {
2582
3224
  }
2583
3225
 
2584
3226
  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
3227
  const sessionPrefixes = new Set();
3228
+ let messageLines = [];
3229
+ messageLines.push("## Background Agents");
3230
+ messageLines.push("");
3231
+ messageLines.push("Background agents watching your files found:");
3232
+ messageLines.push("");
2590
3233
  for (const { agent, sessionPrefix, message } of relevant) {
2591
3234
  if (sessionPrefix) sessionPrefixes.add(sessionPrefix);
2592
- console.log("**[" + agent + "]**");
2593
- console.log("");
2594
- console.log(message);
2595
- console.log("");
3235
+ messageLines.push("**[" + agent + "]**");
3236
+ messageLines.push("");
3237
+ messageLines.push(message);
3238
+ messageLines.push("");
2596
3239
  }
2597
3240
  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());
3241
+ messageLines.push("> For more context: \\\`./ax.js mailbox\\\`" + (sessionList ? " or " + sessionList : ""));
3242
+
3243
+ const formattedMessage = messageLines.join("\\n");
3244
+
3245
+ // For Stop hook, return blocking JSON to force acknowledgment
3246
+ if (hookEvent === "Stop") {
3247
+ console.log(JSON.stringify({ decision: "block", reason: formattedMessage }));
3248
+ } else {
3249
+ // For other hooks, just output the context
3250
+ console.log(formattedMessage);
3251
+ }
3252
+
3253
+ // Update last-seen and prune entries older than 24 hours
3254
+ const PRUNE_AGE_MS = 24 * 60 * 60 * 1000;
3255
+ lastSeenMap[sessionHash] = now;
3256
+ for (const key of Object.keys(lastSeenMap)) {
3257
+ if (now - lastSeenMap[key] > PRUNE_AGE_MS) delete lastSeenMap[key];
3258
+ }
3259
+ writeFileSync(LAST_SEEN_FILE, JSON.stringify(lastSeenMap));
2601
3260
  }
2602
3261
 
2603
3262
  process.exit(0);
@@ -2609,22 +3268,12 @@ process.exit(0);
2609
3268
  // Configure the hook in .claude/settings.json at the same time
2610
3269
  const configuredHook = ensureClaudeHookConfig();
2611
3270
  if (!configuredHook) {
2612
- const hookScriptPath = path.join(HOOKS_DIR, "mailbox-inject.js");
2613
3271
  console.log(`\nTo enable manually, add to .claude/settings.json:\n`);
2614
3272
  console.log(`{
2615
3273
  "hooks": {
2616
- "UserPromptSubmit": [
2617
- {
2618
- "matcher": "",
2619
- "hooks": [
2620
- {
2621
- "type": "command",
2622
- "command": "node ${hookScriptPath}",
2623
- "timeout": 5
2624
- }
2625
- ]
2626
- }
2627
- ]
3274
+ "UserPromptSubmit": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }],
3275
+ "PostToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }],
3276
+ "Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }]
2628
3277
  }
2629
3278
  }`);
2630
3279
  }
@@ -2633,8 +3282,8 @@ process.exit(0);
2633
3282
  function ensureClaudeHookConfig() {
2634
3283
  const settingsDir = ".claude";
2635
3284
  const settingsPath = path.join(settingsDir, "settings.json");
2636
- const hookScriptPath = path.join(HOOKS_DIR, "mailbox-inject.js");
2637
- const hookCommand = `node ${hookScriptPath}`;
3285
+ const hookCommand = "node .ai/hooks/mailbox-inject.js";
3286
+ const hookEvents = ["UserPromptSubmit", "PostToolUse", "Stop"];
2638
3287
 
2639
3288
  try {
2640
3289
  /** @type {ClaudeSettings} */
@@ -2653,33 +3302,41 @@ function ensureClaudeHookConfig() {
2653
3302
 
2654
3303
  // Ensure hooks structure exists
2655
3304
  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
3305
 
2664
- if (hookExists) {
2665
- return true; // Already configured
3306
+ let anyAdded = false;
3307
+
3308
+ for (const eventName of hookEvents) {
3309
+ if (!settings.hooks[eventName]) settings.hooks[eventName] = [];
3310
+
3311
+ // Check if our hook is already configured for this event
3312
+ const hookExists = settings.hooks[eventName].some(
3313
+ /** @param {{hooks?: Array<{command: string}>}} entry */
3314
+ (entry) =>
3315
+ entry.hooks?.some(/** @param {{command: string}} h */ (h) => h.command === hookCommand),
3316
+ );
3317
+
3318
+ if (!hookExists) {
3319
+ // Add the hook for this event
3320
+ settings.hooks[eventName].push({
3321
+ matcher: "",
3322
+ hooks: [
3323
+ {
3324
+ type: "command",
3325
+ command: hookCommand,
3326
+ timeout: 5,
3327
+ },
3328
+ ],
3329
+ });
3330
+ anyAdded = true;
3331
+ }
2666
3332
  }
2667
3333
 
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
- });
3334
+ if (anyAdded) {
3335
+ // Write settings
3336
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
3337
+ console.log(`Configured hooks in: ${settingsPath}`);
3338
+ }
2679
3339
 
2680
- // Write settings
2681
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2682
- console.log(`Configured hook in: ${settingsPath}`);
2683
3340
  return true;
2684
3341
  } catch {
2685
3342
  // If we can't configure automatically, return false so manual instructions are shown
@@ -2689,9 +3346,31 @@ function ensureClaudeHookConfig() {
2689
3346
 
2690
3347
  /**
2691
3348
  * @param {string | null | undefined} session
2692
- * @param {{all?: boolean}} [options]
3349
+ * @param {{all?: boolean, orphans?: boolean, force?: boolean}} [options]
2693
3350
  */
2694
- function cmdKill(session, { all = false } = {}) {
3351
+ function cmdKill(session, { all = false, orphans = false, force = false } = {}) {
3352
+ // Handle orphaned processes
3353
+ if (orphans) {
3354
+ const orphanedProcesses = findOrphanedProcesses();
3355
+
3356
+ if (orphanedProcesses.length === 0) {
3357
+ console.log("No orphaned processes found");
3358
+ return;
3359
+ }
3360
+
3361
+ const signal = force ? "-9" : "-15"; // SIGKILL vs SIGTERM
3362
+ let killed = 0;
3363
+ for (const { pid, command } of orphanedProcesses) {
3364
+ const result = spawnSync("kill", [signal, pid]);
3365
+ if (result.status === 0) {
3366
+ console.log(`Killed: PID ${pid} (${command.slice(0, 40)})`);
3367
+ killed++;
3368
+ }
3369
+ }
3370
+ console.log(`Killed ${killed} orphaned process(es)${force ? " (forced)" : ""}`);
3371
+ return;
3372
+ }
3373
+
2695
3374
  // If specific session provided, kill just that one
2696
3375
  if (session) {
2697
3376
  if (!tmuxHasSession(session)) {
@@ -2751,7 +3430,9 @@ function cmdAttach(session) {
2751
3430
  }
2752
3431
 
2753
3432
  // Hand over to tmux attach
2754
- const result = spawnSync("tmux", ["attach", "-t", resolved], { stdio: "inherit" });
3433
+ const result = spawnSync("tmux", ["attach", "-t", resolved], {
3434
+ stdio: "inherit",
3435
+ });
2755
3436
  process.exit(result.status || 0);
2756
3437
  }
2757
3438
 
@@ -2810,13 +3491,15 @@ function cmdLog(sessionName, { tail = 50, reasoning = false, follow = false } =
2810
3491
 
2811
3492
  if (newLines.length === 0) return;
2812
3493
 
2813
- const entries = newLines.map((line) => {
2814
- try {
2815
- return JSON.parse(line);
2816
- } catch {
2817
- return null;
2818
- }
2819
- }).filter(Boolean);
3494
+ const entries = newLines
3495
+ .map((line) => {
3496
+ try {
3497
+ return JSON.parse(line);
3498
+ } catch {
3499
+ return null;
3500
+ }
3501
+ })
3502
+ .filter(Boolean);
2820
3503
 
2821
3504
  const output = [];
2822
3505
  if (isInitial) {
@@ -2829,7 +3512,10 @@ function cmdLog(sessionName, { tail = 50, reasoning = false, follow = false } =
2829
3512
  const ts = entry.timestamp || entry.ts || entry.createdAt;
2830
3513
  if (ts && ts !== lastTimestamp) {
2831
3514
  const date = new Date(ts);
2832
- const timeStr = date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
3515
+ const timeStr = date.toLocaleTimeString("en-GB", {
3516
+ hour: "2-digit",
3517
+ minute: "2-digit",
3518
+ });
2833
3519
  if (formatted.isUserMessage) {
2834
3520
  output.push(`\n### ${timeStr}\n`);
2835
3521
  }
@@ -2879,7 +3565,10 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2879
3565
  if (type === "user" || type === "human") {
2880
3566
  const text = extractTextContent(content);
2881
3567
  if (text) {
2882
- return { text: `**User**: ${truncate(text, TRUNCATE_USER_LEN)}\n`, isUserMessage: true };
3568
+ return {
3569
+ text: `**User**: ${truncate(text, TRUNCATE_USER_LEN)}\n`,
3570
+ isUserMessage: true,
3571
+ };
2883
3572
  }
2884
3573
  }
2885
3574
 
@@ -2895,10 +3584,12 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2895
3584
  // Extract tool calls (compressed)
2896
3585
  const tools = extractToolCalls(content);
2897
3586
  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(", ");
3587
+ const toolSummary = tools
3588
+ .map((t) => {
3589
+ if (t.error) return `${t.name}(${t.target}) ✗`;
3590
+ return `${t.name}(${t.target})`;
3591
+ })
3592
+ .join(", ");
2902
3593
  parts.push(`> ${toolSummary}\n`);
2903
3594
  }
2904
3595
 
@@ -2920,7 +3611,10 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2920
3611
  const error = entry.error || entry.is_error;
2921
3612
  if (error) {
2922
3613
  const name = entry.tool_name || entry.name || "tool";
2923
- return { text: `> ${name} ✗ (${truncate(String(error), 100)})\n`, isUserMessage: false };
3614
+ return {
3615
+ text: `> ${name} ✗ (${truncate(String(error), 100)})\n`,
3616
+ isUserMessage: false,
3617
+ };
2924
3618
  }
2925
3619
  }
2926
3620
 
@@ -2957,7 +3651,8 @@ function extractToolCalls(content) {
2957
3651
  const name = c.name || c.tool || "tool";
2958
3652
  const input = c.input || c.arguments || {};
2959
3653
  // Extract a reasonable target from the input
2960
- const target = input.file_path || input.path || input.command?.slice(0, 30) || input.pattern || "";
3654
+ const target =
3655
+ input.file_path || input.path || input.command?.slice(0, 30) || input.pattern || "";
2961
3656
  const shortTarget = target.split("/").pop() || target.slice(0, 20);
2962
3657
  return { name, target: shortTarget, error: c.error };
2963
3658
  });
@@ -3002,8 +3697,14 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
3002
3697
 
3003
3698
  for (const entry of entries) {
3004
3699
  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" });
3700
+ const timeStr = ts.toLocaleTimeString("en-GB", {
3701
+ hour: "2-digit",
3702
+ minute: "2-digit",
3703
+ });
3704
+ const dateStr = ts.toLocaleDateString("en-GB", {
3705
+ month: "short",
3706
+ day: "numeric",
3707
+ });
3007
3708
  const p = entry.payload || {};
3008
3709
 
3009
3710
  console.log(`### [${p.agent || "unknown"}] ${dateStr} ${timeStr}\n`);
@@ -3032,7 +3733,7 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
3032
3733
  * @param {string} message
3033
3734
  * @param {{noWait?: boolean, yolo?: boolean, timeoutMs?: number}} [options]
3034
3735
  */
3035
- async function cmdAsk(agent, session, message, { noWait = false, yolo = false, timeoutMs } = {}) {
3736
+ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
3036
3737
  const sessionExists = session != null && tmuxHasSession(session);
3037
3738
  const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
3038
3739
 
@@ -3044,20 +3745,31 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3044
3745
  }
3045
3746
 
3046
3747
  /** @type {string} */
3047
- const activeSession = sessionExists ? /** @type {string} */ (session) : await cmdStart(agent, session, { yolo });
3748
+ const activeSession = sessionExists
3749
+ ? /** @type {string} */ (session)
3750
+ : await cmdStart(agent, session, { yolo });
3048
3751
 
3049
3752
  tmuxSendLiteral(activeSession, message);
3050
3753
  await sleep(50);
3051
3754
  tmuxSend(activeSession, "Enter");
3052
3755
 
3053
- if (noWait) return;
3756
+ if (noWait) {
3757
+ const parsed = parseSessionName(activeSession);
3758
+ const shortId = parsed?.uuid?.slice(0, 8) || activeSession;
3759
+ const cli = path.basename(process.argv[1], ".js");
3760
+ console.log(`Sent to: ${shortId}
3761
+
3762
+ e.g.
3763
+ ${cli} status --session=${shortId}
3764
+ ${cli} output --session=${shortId}`);
3765
+ return;
3766
+ }
3054
3767
 
3055
- // Yolo mode on a safe session: auto-approve until done
3056
3768
  const useAutoApprove = yolo && !nativeYolo;
3057
3769
 
3058
3770
  const { state, screen } = useAutoApprove
3059
- ? await autoApproveLoop(agent, activeSession, timeoutMs)
3060
- : await waitForResponse(agent, activeSession, timeoutMs);
3771
+ ? await autoApproveLoop(agent, activeSession, timeoutMs, streamResponse)
3772
+ : await streamResponse(agent, activeSession, timeoutMs);
3061
3773
 
3062
3774
  if (state === State.RATE_LIMITED) {
3063
3775
  console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
@@ -3065,14 +3777,9 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3065
3777
  }
3066
3778
 
3067
3779
  if (state === State.CONFIRMING) {
3068
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3780
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3069
3781
  process.exit(3);
3070
3782
  }
3071
-
3072
- const output = agent.getResponse(activeSession, screen);
3073
- if (output) {
3074
- console.log(output);
3075
- }
3076
3783
  }
3077
3784
 
3078
3785
  /**
@@ -3087,9 +3794,10 @@ async function cmdApprove(agent, session, { wait = false, timeoutMs } = {}) {
3087
3794
  }
3088
3795
 
3089
3796
  const before = tmuxCapture(session);
3090
- if (agent.getState(before) !== State.CONFIRMING) {
3091
- console.log("ERROR: not confirming");
3092
- process.exit(1);
3797
+ const beforeState = agent.getState(before);
3798
+ if (beforeState !== State.CONFIRMING) {
3799
+ console.log(`Already ${beforeState}`);
3800
+ return;
3093
3801
  }
3094
3802
 
3095
3803
  tmuxSend(session, agent.approveKey);
@@ -3104,7 +3812,7 @@ async function cmdApprove(agent, session, { wait = false, timeoutMs } = {}) {
3104
3812
  }
3105
3813
 
3106
3814
  if (state === State.CONFIRMING) {
3107
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3815
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3108
3816
  process.exit(3);
3109
3817
  }
3110
3818
 
@@ -3123,6 +3831,13 @@ async function cmdReject(agent, session, { wait = false, timeoutMs } = {}) {
3123
3831
  process.exit(1);
3124
3832
  }
3125
3833
 
3834
+ const before = tmuxCapture(session);
3835
+ const beforeState = agent.getState(before);
3836
+ if (beforeState !== State.CONFIRMING) {
3837
+ console.log(`Already ${beforeState}`);
3838
+ return;
3839
+ }
3840
+
3126
3841
  tmuxSend(session, agent.rejectKey);
3127
3842
 
3128
3843
  if (!wait) return;
@@ -3145,7 +3860,13 @@ async function cmdReject(agent, session, { wait = false, timeoutMs } = {}) {
3145
3860
  * @param {string | null | undefined} customInstructions
3146
3861
  * @param {{wait?: boolean, yolo?: boolean, fresh?: boolean, timeoutMs?: number}} [options]
3147
3862
  */
3148
- async function cmdReview(agent, session, option, customInstructions, { wait = true, yolo = true, fresh = false, timeoutMs = REVIEW_TIMEOUT_MS } = {}) {
3863
+ async function cmdReview(
3864
+ agent,
3865
+ session,
3866
+ option,
3867
+ customInstructions,
3868
+ { wait = true, yolo = false, fresh = false, timeoutMs = REVIEW_TIMEOUT_MS } = {},
3869
+ ) {
3149
3870
  const sessionExists = session != null && tmuxHasSession(session);
3150
3871
 
3151
3872
  // Reset conversation if --fresh and session exists
@@ -3171,7 +3892,11 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3171
3892
 
3172
3893
  // AX_REVIEW_MODE=exec: bypass /review command, send instructions directly
3173
3894
  if (process.env.AX_REVIEW_MODE === "exec" && option === "custom" && customInstructions) {
3174
- return cmdAsk(agent, session, customInstructions, { noWait: !wait, yolo, timeoutMs });
3895
+ return cmdAsk(agent, session, customInstructions, {
3896
+ noWait: !wait,
3897
+ yolo,
3898
+ timeoutMs,
3899
+ });
3175
3900
  }
3176
3901
  const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
3177
3902
 
@@ -3183,7 +3908,9 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3183
3908
  }
3184
3909
 
3185
3910
  /** @type {string} */
3186
- const activeSession = sessionExists ? /** @type {string} */ (session) : await cmdStart(agent, session, { yolo });
3911
+ const activeSession = sessionExists
3912
+ ? /** @type {string} */ (session)
3913
+ : await cmdStart(agent, session, { yolo });
3187
3914
 
3188
3915
  tmuxSendLiteral(activeSession, "/review");
3189
3916
  await sleep(50);
@@ -3205,12 +3932,11 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3205
3932
 
3206
3933
  if (!wait) return;
3207
3934
 
3208
- // Yolo mode on a safe session: auto-approve until done
3209
3935
  const useAutoApprove = yolo && !nativeYolo;
3210
3936
 
3211
3937
  const { state, screen } = useAutoApprove
3212
- ? await autoApproveLoop(agent, activeSession, timeoutMs)
3213
- : await waitForResponse(agent, activeSession, timeoutMs);
3938
+ ? await autoApproveLoop(agent, activeSession, timeoutMs, streamResponse)
3939
+ : await streamResponse(agent, activeSession, timeoutMs);
3214
3940
 
3215
3941
  if (state === State.RATE_LIMITED) {
3216
3942
  console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
@@ -3218,12 +3944,9 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3218
3944
  }
3219
3945
 
3220
3946
  if (state === State.CONFIRMING) {
3221
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3947
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3222
3948
  process.exit(3);
3223
3949
  }
3224
-
3225
- const response = agent.getResponse(activeSession, screen);
3226
- console.log(response || "");
3227
3950
  }
3228
3951
 
3229
3952
  /**
@@ -3254,7 +3977,7 @@ async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs }
3254
3977
  }
3255
3978
 
3256
3979
  if (state === State.CONFIRMING) {
3257
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3980
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3258
3981
  process.exit(3);
3259
3982
  }
3260
3983
 
@@ -3266,6 +3989,8 @@ async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs }
3266
3989
  const output = agent.getResponse(session, screen, index);
3267
3990
  if (output) {
3268
3991
  console.log(output);
3992
+ } else {
3993
+ console.log("READY_NO_CONTENT");
3269
3994
  }
3270
3995
  }
3271
3996
 
@@ -3288,7 +4013,7 @@ function cmdStatus(agent, session) {
3288
4013
  }
3289
4014
 
3290
4015
  if (state === State.CONFIRMING) {
3291
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
4016
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3292
4017
  process.exit(3);
3293
4018
  }
3294
4019
 
@@ -3296,6 +4021,10 @@ function cmdStatus(agent, session) {
3296
4021
  console.log("THINKING");
3297
4022
  process.exit(4);
3298
4023
  }
4024
+
4025
+ // READY (or STARTING/UPDATE_PROMPT which are transient)
4026
+ console.log("READY");
4027
+ process.exit(0);
3299
4028
  }
3300
4029
 
3301
4030
  /**
@@ -3409,7 +4138,7 @@ async function cmdSelect(agent, session, n, { wait = false, timeoutMs } = {}) {
3409
4138
  }
3410
4139
 
3411
4140
  if (state === State.CONFIRMING) {
3412
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
4141
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3413
4142
  process.exit(3);
3414
4143
  }
3415
4144
 
@@ -3422,20 +4151,43 @@ async function cmdSelect(agent, session, n, { wait = false, timeoutMs } = {}) {
3422
4151
  // =============================================================================
3423
4152
 
3424
4153
  /**
3425
- * @returns {Agent}
4154
+ * Resolve the agent to use based on (in priority order):
4155
+ * 1. Explicit --tool flag
4156
+ * 2. Session name (e.g., "claude-archangel-..." → ClaudeAgent)
4157
+ * 3. CLI invocation name (axclaude, axcodex)
4158
+ * 4. AX_DEFAULT_TOOL environment variable
4159
+ * 5. Default to CodexAgent
4160
+ *
4161
+ * @param {{toolFlag?: string, sessionName?: string | null}} options
4162
+ * @returns {{agent: Agent, error?: string}}
3426
4163
  */
3427
- function getAgentFromInvocation() {
4164
+ function resolveAgent({ toolFlag, sessionName } = {}) {
4165
+ // 1. Explicit --tool flag takes highest priority
4166
+ if (toolFlag) {
4167
+ if (toolFlag === "claude") return { agent: ClaudeAgent };
4168
+ if (toolFlag === "codex") return { agent: CodexAgent };
4169
+ return { agent: CodexAgent, error: `unknown tool '${toolFlag}'` };
4170
+ }
4171
+
4172
+ // 2. Infer from session name (e.g., "claude-archangel-..." or "codex-partner-...")
4173
+ if (sessionName) {
4174
+ const parsed = parseSessionName(sessionName);
4175
+ if (parsed?.tool === "claude") return { agent: ClaudeAgent };
4176
+ if (parsed?.tool === "codex") return { agent: CodexAgent };
4177
+ }
4178
+
4179
+ // 3. CLI invocation name
3428
4180
  const invoked = path.basename(process.argv[1], ".js");
3429
- if (invoked === "axclaude" || invoked === "claude") return ClaudeAgent;
3430
- if (invoked === "axcodex" || invoked === "codex") return CodexAgent;
4181
+ if (invoked === "axclaude" || invoked === "claude") return { agent: ClaudeAgent };
4182
+ if (invoked === "axcodex" || invoked === "codex") return { agent: CodexAgent };
3431
4183
 
3432
- // Default based on AX_DEFAULT_TOOL env var, or codex if not set
4184
+ // 4. AX_DEFAULT_TOOL environment variable
3433
4185
  const defaultTool = process.env.AX_DEFAULT_TOOL;
3434
- if (defaultTool === "claude") return ClaudeAgent;
3435
- if (defaultTool === "codex" || !defaultTool) return CodexAgent;
4186
+ if (defaultTool === "claude") return { agent: ClaudeAgent };
4187
+ if (defaultTool === "codex" || !defaultTool) return { agent: CodexAgent };
3436
4188
 
3437
4189
  console.error(`WARNING: invalid AX_DEFAULT_TOOL="${defaultTool}", using codex`);
3438
- return CodexAgent;
4190
+ return { agent: CodexAgent };
3439
4191
  }
3440
4192
 
3441
4193
  /**
@@ -3444,23 +4196,30 @@ function getAgentFromInvocation() {
3444
4196
  */
3445
4197
  function printHelp(agent, cliName) {
3446
4198
  const name = cliName;
3447
- const backendName = agent.name === "codex" ? "OpenAI Codex" : "Claude";
4199
+ const backendName = agent.displayName;
3448
4200
  const hasReview = !!agent.reviewOptions;
3449
4201
 
3450
- console.log(`${name}.js - agentic assistant CLI (${backendName})
4202
+ console.log(`${name} v${VERSION} - agentic assistant CLI (${backendName})
4203
+
4204
+ Usage: ${name} [OPTIONS] <command|message> [ARGS...]
3451
4205
 
3452
4206
  Commands:
3453
4207
  agents List all running agents with state and log paths
4208
+ target Show default target session for current tool
3454
4209
  attach [SESSION] Attach to agent session interactively
3455
4210
  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)
4211
+ mailbox View archangel observations (--limit=N, --branch=X, --all)
4212
+ summon [name] Summon archangels (all, or by name)
4213
+ recall [name] Recall archangels (all, or by name)
4214
+ kill Kill sessions (--all, --session=NAME, --orphans [--force])
3460
4215
  status Check state (exit: 0=ready, 2=rate_limited, 3=confirming, 4=thinking)
3461
4216
  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` : ""}
4217
+ debug Show raw screen output and detected state${
4218
+ hasReview
4219
+ ? `
4220
+ review [TYPE] Review code: pr, uncommitted, commit, custom`
4221
+ : ""
4222
+ }
3464
4223
  select N Select menu option N
3465
4224
  approve Approve pending action (send 'y')
3466
4225
  reject Reject pending action (send 'n')
@@ -3471,37 +4230,42 @@ Commands:
3471
4230
 
3472
4231
  Flags:
3473
4232
  --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)
4233
+ --session=NAME Target session by name, archangel name, or UUID prefix (self = current)
4234
+ --wait Wait for response (default for messages; required for approve/reject)
4235
+ --no-wait Fire-and-forget: send message, print session ID, exit immediately
4236
+ --timeout=N Set timeout in seconds (default: ${DEFAULT_TIMEOUT_MS / 1000}, reviews: ${REVIEW_TIMEOUT_MS / 1000})
3478
4237
  --yolo Skip all confirmations (dangerous)
3479
4238
  --fresh Reset conversation before review
4239
+ --orphans Kill orphaned claude/codex processes (PPID=1)
4240
+ --force Use SIGKILL instead of SIGTERM (with --orphans)
3480
4241
 
3481
4242
  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
4243
+ AX_DEFAULT_TOOL Default agent when using 'ax' (claude or codex, default: codex)
4244
+ ${agent.envVar} Override default session name
4245
+ AX_CLAUDE_CONFIG_DIR Override Claude config directory (default: ~/.claude)
4246
+ AX_CODEX_CONFIG_DIR Override Codex config directory (default: ~/.codex)
4247
+ AX_REVIEW_MODE=exec Bypass /review, send instructions directly (codex only)
4248
+ AX_DEBUG=1 Enable debug logging
3488
4249
 
3489
4250
  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)`);
4251
+ ${name} "explain this codebase"
4252
+ ${name} "review the error handling" # Auto custom review (${REVIEW_TIMEOUT_MS / 60000}min timeout)
4253
+ ${name} "FYI: auth was refactored" --no-wait # Send context to a working session (no response needed)
4254
+ ${name} review uncommitted --wait
4255
+ ${name} approve --wait
4256
+ ${name} kill # Kill agents in current project
4257
+ ${name} kill --all # Kill all agents across all projects
4258
+ ${name} kill --session=NAME # Kill specific session
4259
+ ${name} send "1[Enter]" # Recovery: select option 1 and press Enter
4260
+ ${name} send "[Escape][Escape]" # Recovery: escape out of a dialog
4261
+ ${name} summon # Summon all archangels from .ai/agents/*.md
4262
+ ${name} summon reviewer # Summon by name (creates config if new)
4263
+ ${name} recall # Recall all archangels
4264
+ ${name} recall reviewer # Recall one by name
4265
+ ${name} agents # List all agents (shows TYPE=archangel)
4266
+
4267
+ Note: Reviews and complex tasks may take several minutes.
4268
+ Use Bash run_in_background for long operations (not --no-wait).`);
3505
4269
  }
3506
4270
 
3507
4271
  async function main() {
@@ -3516,33 +4280,21 @@ async function main() {
3516
4280
  const args = process.argv.slice(2);
3517
4281
  const cliName = path.basename(process.argv[1], ".js");
3518
4282
 
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
- }
4283
+ // Parse all flags and positionals in one place
4284
+ const { flags, positionals } = parseCliArgs(args);
4285
+
4286
+ if (flags.version) {
4287
+ console.log(VERSION);
4288
+ process.exit(0);
3538
4289
  }
3539
4290
 
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") {
4291
+ // Extract flags into local variables for convenience
4292
+ const { wait, noWait, yolo, fresh, reasoning, follow, all, orphans, force } = flags;
4293
+
4294
+ // Session resolution (must happen before agent resolution so we can infer tool from session name)
4295
+ let session = null;
4296
+ if (flags.session) {
4297
+ if (flags.session === "self") {
3546
4298
  const current = tmuxCurrentSession();
3547
4299
  if (!current) {
3548
4300
  console.log("ERROR: --session=self requires running inside tmux");
@@ -3550,99 +4302,106 @@ async function main() {
3550
4302
  }
3551
4303
  session = current;
3552
4304
  } else {
3553
- // Resolve partial names, daemon names, and UUID prefixes
3554
- session = resolveSessionName(val);
4305
+ // Resolve partial names, archangel names, and UUID prefixes
4306
+ session = resolveSessionName(flags.session);
3555
4307
  }
3556
4308
  }
3557
4309
 
3558
- // Timeout
4310
+ // Agent resolution (considers --tool flag, session name, invocation, and env vars)
4311
+ const { agent, error: agentError } = resolveAgent({ toolFlag: flags.tool, sessionName: session });
4312
+ if (agentError) {
4313
+ console.log(`ERROR: ${agentError}`);
4314
+ process.exit(1);
4315
+ }
4316
+
4317
+ // If no explicit session, use agent's default
4318
+ if (!session) {
4319
+ session = agent.getDefaultSession();
4320
+ }
4321
+
4322
+ // Timeout (convert seconds to milliseconds)
3559
4323
  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) {
4324
+ if (flags.timeout !== undefined) {
4325
+ if (isNaN(flags.timeout) || flags.timeout <= 0) {
3564
4326
  console.log("ERROR: invalid timeout");
3565
4327
  process.exit(1);
3566
4328
  }
3567
- timeoutMs = val * 1000;
4329
+ timeoutMs = flags.timeout * 1000;
3568
4330
  }
3569
4331
 
3570
4332
  // 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
- }
4333
+ const tail = flags.tail ?? 50;
3576
4334
 
3577
4335
  // 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
- }
4336
+ const limit = flags.limit ?? 20;
3583
4337
 
3584
4338
  // 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];
4339
+ const branch = flags.branch ?? null;
4340
+
4341
+ // Command is first positional
4342
+ const cmd = positionals[0];
3606
4343
 
3607
4344
  // Dispatch commands
3608
4345
  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 });
4346
+ if (cmd === "target") {
4347
+ const defaultSession = agent.getDefaultSession();
4348
+ if (defaultSession) {
4349
+ console.log(defaultSession);
4350
+ } else {
4351
+ console.log("NO_TARGET");
4352
+ process.exit(1);
4353
+ }
4354
+ return;
4355
+ }
4356
+ if (cmd === "summon") return cmdSummon(positionals[1]);
4357
+ if (cmd === "recall") return cmdRecall(positionals[1]);
4358
+ if (cmd === "archangel") return cmdArchangel(positionals[1]);
4359
+ if (cmd === "kill") return cmdKill(session, { all, orphans, force });
4360
+ if (cmd === "attach") return cmdAttach(positionals[1] || session);
4361
+ if (cmd === "log") return cmdLog(positionals[1] || session, { tail, reasoning, follow });
3614
4362
  if (cmd === "mailbox") return cmdMailbox({ limit, branch, all });
3615
4363
  if (cmd === "approve") return cmdApprove(agent, session, { wait, timeoutMs });
3616
4364
  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 });
4365
+ if (cmd === "review")
4366
+ return cmdReview(agent, session, positionals[1], positionals[2], {
4367
+ wait,
4368
+ fresh,
4369
+ timeoutMs,
4370
+ });
3618
4371
  if (cmd === "status") return cmdStatus(agent, session);
3619
4372
  if (cmd === "debug") return cmdDebug(agent, session);
3620
4373
  if (cmd === "output") {
3621
- const indexArg = filteredArgs[1];
4374
+ const indexArg = positionals[1];
3622
4375
  const index = indexArg?.startsWith("-") ? parseInt(indexArg, 10) : 0;
3623
4376
  return cmdOutput(agent, session, index, { wait, timeoutMs });
3624
4377
  }
3625
- if (cmd === "send" && filteredArgs.length > 1) return cmdSend(session, filteredArgs.slice(1).join(" "));
4378
+ if (cmd === "send" && positionals.length > 1)
4379
+ return cmdSend(session, positionals.slice(1).join(" "));
3626
4380
  if (cmd === "compact") return cmdAsk(agent, session, "/compact", { noWait: true, timeoutMs });
3627
4381
  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 });
4382
+ if (cmd === "select" && positionals[1])
4383
+ return cmdSelect(agent, session, positionals[1], { wait, timeoutMs });
3629
4384
 
3630
4385
  // Default: send message
3631
- let message = filteredArgs.join(" ");
4386
+ let message = positionals.join(" ");
3632
4387
  if (!message && hasStdinData()) {
3633
4388
  message = await readStdin();
3634
4389
  }
3635
4390
 
3636
- if (!message || cmd === "--help" || cmd === "-h") {
4391
+ if (!message || flags.help) {
3637
4392
  printHelp(agent, cliName);
3638
4393
  process.exit(0);
3639
4394
  }
3640
4395
 
3641
- // Detect "please review" and route to custom review mode
3642
- const reviewMatch = message.match(/^please review\s*(.*)/i);
4396
+ // Detect "review ..." or "please review ..." and route to custom review mode
4397
+ const reviewMatch = message.match(/^(?:please )?review\s*(.*)/i);
3643
4398
  if (reviewMatch && agent.reviewOptions) {
3644
4399
  const customInstructions = reviewMatch[1].trim() || null;
3645
- return cmdReview(agent, session, "custom", customInstructions, { wait: !noWait, yolo, timeoutMs });
4400
+ return cmdReview(agent, session, "custom", customInstructions, {
4401
+ wait: !noWait,
4402
+ yolo,
4403
+ timeoutMs: flags.timeout !== undefined ? timeoutMs : REVIEW_TIMEOUT_MS,
4404
+ });
3646
4405
  }
3647
4406
 
3648
4407
  return cmdAsk(agent, session, message, { noWait, yolo, timeoutMs });
@@ -3650,17 +4409,21 @@ async function main() {
3650
4409
 
3651
4410
  // Run main() only when executed directly (not when imported for testing)
3652
4411
  // 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
- })();
4412
+ const isDirectRun =
4413
+ process.argv[1] &&
4414
+ (() => {
4415
+ try {
4416
+ return realpathSync(process.argv[1]) === __filename;
4417
+ } catch {
4418
+ return false;
4419
+ }
4420
+ })();
3661
4421
  if (isDirectRun) {
3662
4422
  main().catch((err) => {
3663
4423
  console.log(`ERROR: ${err.message}`);
4424
+ if (err instanceof TimeoutError && err.session) {
4425
+ console.log(`Hint: Use 'ax debug --session=${err.session}' to see current screen state`);
4426
+ }
3664
4427
  process.exit(1);
3665
4428
  });
3666
4429
  }
@@ -3670,6 +4433,7 @@ export {
3670
4433
  parseSessionName,
3671
4434
  parseAgentConfig,
3672
4435
  parseKeySequence,
4436
+ parseCliArgs,
3673
4437
  getClaudeProjectPath,
3674
4438
  matchesPattern,
3675
4439
  getBaseDir,
@@ -3681,4 +4445,3 @@ export {
3681
4445
  detectState,
3682
4446
  State,
3683
4447
  };
3684
-