ax-agents 0.0.1-alpha.6 → 0.0.1-alpha.8

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 (2) hide show
  1. package/ax.js +711 -168
  2. package/package.json +1 -1
package/ax.js CHANGED
@@ -23,11 +23,15 @@ import {
23
23
  renameSync,
24
24
  realpathSync,
25
25
  watch,
26
+ openSync,
27
+ readSync,
28
+ closeSync,
26
29
  } from "node:fs";
27
- import { randomUUID } from "node:crypto";
30
+ import { randomUUID, createHash } from "node:crypto";
28
31
  import { fileURLToPath } from "node:url";
29
32
  import path from "node:path";
30
33
  import os from "node:os";
34
+ import { parseArgs } from "node:util";
31
35
 
32
36
  const __filename = fileURLToPath(import.meta.url);
33
37
  const __dirname = path.dirname(__filename);
@@ -301,6 +305,33 @@ const TRUNCATE_THINKING_LEN = 300;
301
305
  const ARCHANGEL_GIT_CONTEXT_HOURS = 4;
302
306
  const ARCHANGEL_GIT_CONTEXT_MAX_LINES = 200;
303
307
  const ARCHANGEL_PARENT_CONTEXT_ENTRIES = 10;
308
+ const ARCHANGEL_PREAMBLE = `## Guidelines
309
+
310
+ - Investigate before speaking. If uncertain, read more code and trace the logic until you're confident.
311
+ - Explain WHY something is an issue, not just that it is.
312
+ - Focus on your area of expertise.
313
+ - Calibrate to the task or plan. Don't suggest refactors during a bug fix.
314
+ - Be clear. Brief is fine, but never sacrifice clarity.
315
+ - For critical issues, request for them to be added to the todo list.
316
+ - Don't repeat observations you've already made unless you have more to say or better clarity.
317
+ - Make judgment calls - don't ask questions.
318
+
319
+ "No issues found." is a valid response when there's nothing significant to report.`;
320
+
321
+ /**
322
+ * @param {string} session
323
+ * @param {(screen: string) => boolean} predicate
324
+ * @param {number} [timeoutMs]
325
+ * @returns {Promise<string>}
326
+ */
327
+ class TimeoutError extends Error {
328
+ /** @param {string} [session] */
329
+ constructor(session) {
330
+ super("timeout");
331
+ this.name = "TimeoutError";
332
+ this.session = session;
333
+ }
334
+ }
304
335
 
305
336
  /**
306
337
  * @param {string} session
@@ -315,7 +346,7 @@ async function waitFor(session, predicate, timeoutMs = STARTUP_TIMEOUT_MS) {
315
346
  if (predicate(screen)) return screen;
316
347
  await sleep(POLL_MS);
317
348
  }
318
- throw new Error("timeout");
349
+ throw new TimeoutError(session);
319
350
  }
320
351
 
321
352
  // =============================================================================
@@ -343,6 +374,38 @@ function findCallerPid() {
343
374
  return null;
344
375
  }
345
376
 
377
+ /**
378
+ * Find orphaned claude/codex processes (PPID=1, reparented to init/launchd)
379
+ * @returns {{pid: string, command: string}[]}
380
+ */
381
+ function findOrphanedProcesses() {
382
+ const result = spawnSync("ps", ["-eo", "pid=,ppid=,args="], { encoding: "utf-8" });
383
+
384
+ if (result.status !== 0 || !result.stdout.trim()) {
385
+ return [];
386
+ }
387
+
388
+ const orphans = [];
389
+ for (const line of result.stdout.trim().split("\n")) {
390
+ // Parse: " PID PPID command args..."
391
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/);
392
+ if (!match) continue;
393
+
394
+ const [, pid, ppid, args] = match;
395
+
396
+ // Must have PPID=1 (orphaned/reparented to init)
397
+ if (ppid !== "1") continue;
398
+
399
+ // Command must START with claude or codex (excludes tmux which also has PPID=1)
400
+ const cmd = args.split(/\s+/)[0];
401
+ if (cmd !== "claude" && cmd !== "codex") continue;
402
+
403
+ orphans.push({ pid, command: args.slice(0, 60) });
404
+ }
405
+
406
+ return orphans;
407
+ }
408
+
346
409
  // =============================================================================
347
410
  // Helpers - stdin
348
411
  // =============================================================================
@@ -371,6 +434,86 @@ async function readStdin() {
371
434
  }
372
435
 
373
436
  // =============================================================================
437
+ // =============================================================================
438
+ // Helpers - CLI argument parsing
439
+ // =============================================================================
440
+
441
+ /**
442
+ * Parse CLI arguments using Node.js built-in parseArgs.
443
+ * @param {string[]} args - Command line arguments (without node and script path)
444
+ * @returns {{ flags: ParsedFlags, positionals: string[] }}
445
+ *
446
+ * @typedef {Object} ParsedFlags
447
+ * @property {boolean} wait
448
+ * @property {boolean} noWait
449
+ * @property {boolean} yolo
450
+ * @property {boolean} fresh
451
+ * @property {boolean} reasoning
452
+ * @property {boolean} follow
453
+ * @property {boolean} all
454
+ * @property {boolean} orphans
455
+ * @property {boolean} force
456
+ * @property {boolean} version
457
+ * @property {boolean} help
458
+ * @property {string} [tool]
459
+ * @property {string} [session]
460
+ * @property {number} [timeout]
461
+ * @property {number} [tail]
462
+ * @property {number} [limit]
463
+ * @property {string} [branch]
464
+ */
465
+ function parseCliArgs(args) {
466
+ const { values, positionals } = parseArgs({
467
+ args,
468
+ options: {
469
+ // Boolean flags
470
+ wait: { type: "boolean", default: false },
471
+ "no-wait": { type: "boolean", default: false },
472
+ yolo: { type: "boolean", default: false },
473
+ fresh: { type: "boolean", default: false },
474
+ reasoning: { type: "boolean", default: false },
475
+ follow: { type: "boolean", short: "f", default: false },
476
+ all: { type: "boolean", default: false },
477
+ orphans: { type: "boolean", default: false },
478
+ force: { type: "boolean", default: false },
479
+ version: { type: "boolean", short: "V", default: false },
480
+ help: { type: "boolean", short: "h", default: false },
481
+ // Value flags
482
+ tool: { type: "string" },
483
+ session: { type: "string" },
484
+ timeout: { type: "string" },
485
+ tail: { type: "string" },
486
+ limit: { type: "string" },
487
+ branch: { type: "string" },
488
+ },
489
+ allowPositionals: true,
490
+ strict: false, // Don't error on unknown flags
491
+ });
492
+
493
+ return {
494
+ flags: {
495
+ wait: Boolean(values.wait),
496
+ noWait: Boolean(values["no-wait"]),
497
+ yolo: Boolean(values.yolo),
498
+ fresh: Boolean(values.fresh),
499
+ reasoning: Boolean(values.reasoning),
500
+ follow: Boolean(values.follow),
501
+ all: Boolean(values.all),
502
+ orphans: Boolean(values.orphans),
503
+ force: Boolean(values.force),
504
+ version: Boolean(values.version),
505
+ help: Boolean(values.help),
506
+ tool: /** @type {string | undefined} */ (values.tool),
507
+ session: /** @type {string | undefined} */ (values.session),
508
+ timeout: values.timeout !== undefined ? Number(values.timeout) : undefined,
509
+ tail: values.tail !== undefined ? Number(values.tail) : undefined,
510
+ limit: values.limit !== undefined ? Number(values.limit) : undefined,
511
+ branch: /** @type {string | undefined} */ (values.branch),
512
+ },
513
+ positionals,
514
+ };
515
+ }
516
+
374
517
  // Helpers - session tracking
375
518
  // =============================================================================
376
519
 
@@ -413,6 +556,16 @@ function generateSessionName(tool) {
413
556
  return `${tool}-partner-${randomUUID()}`;
414
557
  }
415
558
 
559
+ /**
560
+ * Quick hash for change detection (not cryptographic).
561
+ * @param {string | null | undefined} str
562
+ * @returns {string | null}
563
+ */
564
+ function quickHash(str) {
565
+ if (!str) return null;
566
+ return createHash("md5").update(str).digest("hex").slice(0, 8);
567
+ }
568
+
416
569
  /**
417
570
  * @param {string} cwd
418
571
  * @returns {string}
@@ -540,6 +693,93 @@ function findCodexLogPath(sessionName) {
540
693
  }
541
694
  }
542
695
 
696
+ /**
697
+ * @typedef {Object} SessionMeta
698
+ * @property {string | null} slug - Plan identifier (if plan is active)
699
+ * @property {Array<{content: string, status: string, id?: string}> | null} todos - Current todos
700
+ * @property {string | null} permissionMode - "default", "acceptEdits", "plan"
701
+ * @property {string | null} gitBranch - Current git branch
702
+ * @property {string | null} cwd - Working directory
703
+ */
704
+
705
+ /**
706
+ * Get metadata from a Claude session's JSONL file.
707
+ * Returns null for Codex sessions (different format, no equivalent metadata).
708
+ * @param {string} sessionName - The tmux session name
709
+ * @returns {SessionMeta | null}
710
+ */
711
+ function getSessionMeta(sessionName) {
712
+ const parsed = parseSessionName(sessionName);
713
+ if (!parsed) return null;
714
+
715
+ // Only Claude sessions have this metadata
716
+ if (parsed.tool !== "claude") return null;
717
+ if (!parsed.uuid) return null;
718
+
719
+ const logPath = findClaudeLogPath(parsed.uuid, sessionName);
720
+ if (!logPath || !existsSync(logPath)) return null;
721
+
722
+ try {
723
+ const content = readFileSync(logPath, "utf-8");
724
+ const lines = content.trim().split("\n").filter(Boolean);
725
+
726
+ // Read from end to find most recent entry with metadata
727
+ for (let i = lines.length - 1; i >= 0; i--) {
728
+ try {
729
+ const entry = JSON.parse(lines[i]);
730
+ // User entries typically have the metadata fields
731
+ if (entry.type === "user" || entry.slug || entry.gitBranch) {
732
+ return {
733
+ slug: entry.slug || null,
734
+ todos: entry.todos || null,
735
+ permissionMode: entry.permissionMode || null,
736
+ gitBranch: entry.gitBranch || null,
737
+ cwd: entry.cwd || null,
738
+ };
739
+ }
740
+ } catch {
741
+ // Skip malformed lines
742
+ }
743
+ }
744
+ return null;
745
+ } catch (err) {
746
+ debugError("getSessionMeta", err);
747
+ return null;
748
+ }
749
+ }
750
+
751
+ /**
752
+ * Read a plan file by its slug.
753
+ * @param {string} slug - The plan slug (e.g., "curious-roaming-pascal")
754
+ * @returns {string | null} The plan content or null if not found
755
+ */
756
+ function readPlanFile(slug) {
757
+ const planPath = path.join(CLAUDE_CONFIG_DIR, "plans", `${slug}.md`);
758
+ try {
759
+ if (existsSync(planPath)) {
760
+ return readFileSync(planPath, "utf-8");
761
+ }
762
+ } catch (err) {
763
+ debugError("readPlanFile", err);
764
+ }
765
+ return null;
766
+ }
767
+
768
+ /**
769
+ * Format todos for display in a prompt.
770
+ * @param {Array<{content: string, status: string, id?: string}>} todos
771
+ * @returns {string}
772
+ */
773
+ function formatTodos(todos) {
774
+ if (!todos || todos.length === 0) return "";
775
+ return todos
776
+ .map((t) => {
777
+ const status = t.status === "completed" ? "[x]" : t.status === "in_progress" ? "[>]" : "[ ]";
778
+ return `${status} ${t.content || "(no content)"}`;
779
+ })
780
+ .join("\n");
781
+ }
782
+
543
783
  /**
544
784
  * Extract assistant text responses from a JSONL log file.
545
785
  * This provides clean text without screen-scraped artifacts.
@@ -585,6 +825,130 @@ function getAssistantText(logPath, index = 0) {
585
825
  }
586
826
  }
587
827
 
828
+ /**
829
+ * Read new complete JSON lines from a log file since the given offset.
830
+ * @param {string | null} logPath
831
+ * @param {number} fromOffset
832
+ * @returns {{ entries: object[], newOffset: number }}
833
+ */
834
+ function tailJsonl(logPath, fromOffset) {
835
+ if (!logPath || !existsSync(logPath)) {
836
+ return { entries: [], newOffset: fromOffset };
837
+ }
838
+
839
+ const stats = statSync(logPath);
840
+ if (stats.size <= fromOffset) {
841
+ return { entries: [], newOffset: fromOffset };
842
+ }
843
+
844
+ const fd = openSync(logPath, "r");
845
+ const buffer = Buffer.alloc(stats.size - fromOffset);
846
+ readSync(fd, buffer, 0, buffer.length, fromOffset);
847
+ closeSync(fd);
848
+
849
+ const text = buffer.toString("utf-8");
850
+ const lines = text.split("\n");
851
+
852
+ // Last line may be incomplete - don't parse it yet
853
+ const complete = lines.slice(0, -1).filter(Boolean);
854
+ const incomplete = lines[lines.length - 1];
855
+
856
+ const entries = [];
857
+ for (const line of complete) {
858
+ try {
859
+ entries.push(JSON.parse(line));
860
+ } catch {
861
+ // Skip malformed lines
862
+ }
863
+ }
864
+
865
+ // Offset advances by complete lines only
866
+ const newOffset = fromOffset + text.length - incomplete.length;
867
+ return { entries, newOffset };
868
+ }
869
+
870
+ /**
871
+ * @typedef {{command?: string, file_path?: string, path?: string, pattern?: string}} ToolInput
872
+ */
873
+
874
+ /**
875
+ * Format a JSONL entry for streaming display.
876
+ * @param {{type?: string, message?: {content?: Array<{type?: string, text?: string, name?: string, input?: ToolInput, tool?: string, arguments?: ToolInput}>}}} entry
877
+ * @returns {string | null}
878
+ */
879
+ function formatEntry(entry) {
880
+ // Skip tool_result entries (they can be very verbose)
881
+ if (entry.type === "tool_result") return null;
882
+
883
+ // Only process assistant entries
884
+ if (entry.type !== "assistant") return null;
885
+
886
+ const parts = entry.message?.content || [];
887
+ const output = [];
888
+
889
+ for (const part of parts) {
890
+ if (part.type === "text" && part.text) {
891
+ output.push(part.text);
892
+ } else if (part.type === "tool_use" || part.type === "tool_call") {
893
+ const name = part.name || part.tool || "tool";
894
+ const input = part.input || part.arguments || {};
895
+ let summary;
896
+ if (name === "Bash" && input.command) {
897
+ summary = input.command.slice(0, 50);
898
+ } else {
899
+ const target = input.file_path || input.path || input.pattern || "";
900
+ summary = target.split("/").pop() || target.slice(0, 30);
901
+ }
902
+ output.push(`> ${name}(${summary})`);
903
+ }
904
+ // Skip thinking blocks - internal reasoning
905
+ }
906
+
907
+ return output.length > 0 ? output.join("\n") : null;
908
+ }
909
+
910
+ /**
911
+ * Extract pending tool from confirmation screen.
912
+ * @param {string} screen
913
+ * @returns {string | null}
914
+ */
915
+ function extractPendingToolFromScreen(screen) {
916
+ const lines = screen.split("\n");
917
+
918
+ // Check recent lines for tool confirmation patterns
919
+ for (let i = lines.length - 1; i >= Math.max(0, lines.length - 15); i--) {
920
+ const line = lines[i];
921
+ // Match tool confirmation patterns like "Bash: command" or "Write: /path/file"
922
+ const match = line.match(
923
+ /^\s*(Bash|Write|Edit|Read|Glob|Grep|Task|WebFetch|WebSearch|NotebookEdit|Skill|TodoWrite|TodoRead):\s*(.{1,40})/,
924
+ );
925
+ if (match) {
926
+ return `${match[1]}: ${match[2].trim()}`;
927
+ }
928
+ }
929
+
930
+ return null;
931
+ }
932
+
933
+ /**
934
+ * Format confirmation output with helpful commands
935
+ * @param {string} screen
936
+ * @param {Agent} _agent
937
+ * @returns {string}
938
+ */
939
+ function formatConfirmationOutput(screen, _agent) {
940
+ const pendingTool = extractPendingToolFromScreen(screen);
941
+ const cli = path.basename(process.argv[1], ".js");
942
+
943
+ let output = pendingTool || "Confirmation required";
944
+ output += "\n\ne.g.";
945
+ output += `\n ${cli} approve # for y/n prompts`;
946
+ output += `\n ${cli} reject`;
947
+ output += `\n ${cli} select N # for numbered menus`;
948
+
949
+ return output;
950
+ }
951
+
588
952
  /**
589
953
  * @returns {string[]}
590
954
  */
@@ -1457,7 +1821,7 @@ const State = {
1457
1821
  * @param {string} config.promptSymbol - Symbol indicating ready state
1458
1822
  * @param {string[]} [config.spinners] - Spinner characters indicating thinking
1459
1823
  * @param {RegExp} [config.rateLimitPattern] - Pattern for rate limit detection
1460
- * @param {string[]} [config.thinkingPatterns] - Text patterns indicating thinking
1824
+ * @param {(string | RegExp | ((lines: string) => boolean))[]} [config.thinkingPatterns] - Text patterns indicating thinking
1461
1825
  * @param {(string | ((lines: string) => boolean))[]} [config.confirmPatterns] - Patterns for confirmation dialogs
1462
1826
  * @param {{screen: string[], lastLines: string[]} | null} [config.updatePromptPatterns] - Patterns for update prompts
1463
1827
  * @returns {string} The detected state
@@ -1470,19 +1834,38 @@ function detectState(screen, config) {
1470
1834
  // Larger range for confirmation detection (catches dialogs that scrolled slightly)
1471
1835
  const recentLines = lines.slice(-15).join("\n");
1472
1836
 
1473
- // Rate limited - check full screen (rate limit messages can appear anywhere)
1474
- if (config.rateLimitPattern && config.rateLimitPattern.test(screen)) {
1837
+ // Rate limited - check recent lines (not full screen to avoid matching historical output)
1838
+ if (config.rateLimitPattern && config.rateLimitPattern.test(recentLines)) {
1475
1839
  return State.RATE_LIMITED;
1476
1840
  }
1477
1841
 
1478
- // Thinking - spinners (full screen, they're unique UI elements)
1842
+ // Confirming - check before THINKING because "Running…" in tool output matches thinking patterns
1843
+ const confirmPatterns = config.confirmPatterns || [];
1844
+ for (const pattern of confirmPatterns) {
1845
+ if (typeof pattern === "function") {
1846
+ // Functions check lastLines first (most specific), then recentLines
1847
+ if (pattern(lastLines)) return State.CONFIRMING;
1848
+ if (pattern(recentLines)) return State.CONFIRMING;
1849
+ } else {
1850
+ // String patterns check recentLines (bounded range)
1851
+ if (recentLines.includes(pattern)) return State.CONFIRMING;
1852
+ }
1853
+ }
1854
+
1855
+ // Thinking - spinners (check last lines only to avoid false positives from timing messages like "✻ Crunched for 32s")
1479
1856
  const spinners = config.spinners || [];
1480
- if (spinners.some((s) => screen.includes(s))) {
1857
+ if (spinners.some((s) => lastLines.includes(s))) {
1481
1858
  return State.THINKING;
1482
1859
  }
1483
- // Thinking - text patterns (last lines)
1860
+ // Thinking - text patterns (last lines) - supports strings, regexes, and functions
1484
1861
  const thinkingPatterns = config.thinkingPatterns || [];
1485
- if (thinkingPatterns.some((p) => lastLines.includes(p))) {
1862
+ if (
1863
+ thinkingPatterns.some((p) => {
1864
+ if (typeof p === "function") return p(lastLines);
1865
+ if (p instanceof RegExp) return p.test(lastLines);
1866
+ return lastLines.includes(p);
1867
+ })
1868
+ ) {
1486
1869
  return State.THINKING;
1487
1870
  }
1488
1871
 
@@ -1494,19 +1877,6 @@ function detectState(screen, config) {
1494
1877
  }
1495
1878
  }
1496
1879
 
1497
- // Confirming - check recent lines (not full screen to avoid history false positives)
1498
- const confirmPatterns = config.confirmPatterns || [];
1499
- for (const pattern of confirmPatterns) {
1500
- if (typeof pattern === "function") {
1501
- // Functions check lastLines first (most specific), then recentLines
1502
- if (pattern(lastLines)) return State.CONFIRMING;
1503
- if (pattern(recentLines)) return State.CONFIRMING;
1504
- } else {
1505
- // String patterns check recentLines (bounded range)
1506
- if (recentLines.includes(pattern)) return State.CONFIRMING;
1507
- }
1508
- }
1509
-
1510
1880
  // Ready - only if prompt symbol is visible AND not followed by pasted content
1511
1881
  // "[Pasted text" indicates user has pasted content and Claude is still processing
1512
1882
  if (lastLines.includes(config.promptSymbol)) {
@@ -1541,12 +1911,13 @@ function detectState(screen, config) {
1541
1911
  /**
1542
1912
  * @typedef {Object} AgentConfigInput
1543
1913
  * @property {string} name
1914
+ * @property {string} displayName
1544
1915
  * @property {string} startCommand
1545
1916
  * @property {string} yoloCommand
1546
1917
  * @property {string} promptSymbol
1547
1918
  * @property {string[]} [spinners]
1548
1919
  * @property {RegExp} [rateLimitPattern]
1549
- * @property {string[]} [thinkingPatterns]
1920
+ * @property {(string | RegExp | ((lines: string) => boolean))[]} [thinkingPatterns]
1550
1921
  * @property {ConfirmPattern[]} [confirmPatterns]
1551
1922
  * @property {UpdatePromptPatterns | null} [updatePromptPatterns]
1552
1923
  * @property {string[]} [responseMarkers]
@@ -1556,6 +1927,8 @@ function detectState(screen, config) {
1556
1927
  * @property {string} [approveKey]
1557
1928
  * @property {string} [rejectKey]
1558
1929
  * @property {string} [safeAllowedTools]
1930
+ * @property {string | null} [sessionIdFlag]
1931
+ * @property {((sessionName: string) => string | null) | null} [logPathFinder]
1559
1932
  */
1560
1933
 
1561
1934
  class Agent {
@@ -1566,6 +1939,8 @@ class Agent {
1566
1939
  /** @type {string} */
1567
1940
  this.name = config.name;
1568
1941
  /** @type {string} */
1942
+ this.displayName = config.displayName;
1943
+ /** @type {string} */
1569
1944
  this.startCommand = config.startCommand;
1570
1945
  /** @type {string} */
1571
1946
  this.yoloCommand = config.yoloCommand;
@@ -1575,7 +1950,7 @@ class Agent {
1575
1950
  this.spinners = config.spinners || [];
1576
1951
  /** @type {RegExp | undefined} */
1577
1952
  this.rateLimitPattern = config.rateLimitPattern;
1578
- /** @type {string[]} */
1953
+ /** @type {(string | RegExp | ((lines: string) => boolean))[]} */
1579
1954
  this.thinkingPatterns = config.thinkingPatterns || [];
1580
1955
  /** @type {ConfirmPattern[]} */
1581
1956
  this.confirmPatterns = config.confirmPatterns || [];
@@ -1595,6 +1970,10 @@ class Agent {
1595
1970
  this.rejectKey = config.rejectKey || "n";
1596
1971
  /** @type {string | undefined} */
1597
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;
1598
1977
  }
1599
1978
 
1600
1979
  /**
@@ -1612,11 +1991,11 @@ class Agent {
1612
1991
  } else {
1613
1992
  base = this.startCommand;
1614
1993
  }
1615
- // Claude supports --session-id for deterministic session tracking
1616
- if (this.name === "claude" && sessionName) {
1994
+ // Some agents support session ID flags for deterministic session tracking
1995
+ if (this.sessionIdFlag && sessionName) {
1617
1996
  const parsed = parseSessionName(sessionName);
1618
1997
  if (parsed?.uuid) {
1619
- return `${base} --session-id ${parsed.uuid}`;
1998
+ return `${base} ${this.sessionIdFlag} ${parsed.uuid}`;
1620
1999
  }
1621
2000
  }
1622
2001
  return base;
@@ -1674,13 +2053,8 @@ class Agent {
1674
2053
  * @returns {string | null}
1675
2054
  */
1676
2055
  findLogPath(sessionName) {
1677
- const parsed = parseSessionName(sessionName);
1678
- if (this.name === "claude") {
1679
- const uuid = parsed?.uuid;
1680
- if (uuid) return findClaudeLogPath(uuid, sessionName);
1681
- }
1682
- if (this.name === "codex") {
1683
- return findCodexLogPath(sessionName);
2056
+ if (this.logPathFinder) {
2057
+ return this.logPathFinder(sessionName);
1684
2058
  }
1685
2059
  return null;
1686
2060
  }
@@ -1908,6 +2282,7 @@ class Agent {
1908
2282
 
1909
2283
  const CodexAgent = new Agent({
1910
2284
  name: "codex",
2285
+ displayName: "Codex",
1911
2286
  startCommand: "codex --sandbox read-only",
1912
2287
  yoloCommand: "codex --dangerously-bypass-approvals-and-sandbox",
1913
2288
  promptSymbol: "›",
@@ -1927,6 +2302,7 @@ const CodexAgent = new Agent({
1927
2302
  chromePatterns: ["context left", "for shortcuts"],
1928
2303
  reviewOptions: { pr: "1", uncommitted: "2", commit: "3", custom: "4" },
1929
2304
  envVar: "AX_SESSION",
2305
+ logPathFinder: findCodexLogPath,
1930
2306
  });
1931
2307
 
1932
2308
  // =============================================================================
@@ -1935,12 +2311,15 @@ const CodexAgent = new Agent({
1935
2311
 
1936
2312
  const ClaudeAgent = new Agent({
1937
2313
  name: "claude",
2314
+ displayName: "Claude",
1938
2315
  startCommand: "claude",
1939
2316
  yoloCommand: "claude --dangerously-skip-permissions",
1940
2317
  promptSymbol: "❯",
1941
- spinners: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
2318
+ // Claude Code spinners: ·✢✳✶✻✽ (from cli.js source)
2319
+ spinners: ["·", "✢", "✳", "✶", "✻", "✽"],
1942
2320
  rateLimitPattern: /rate.?limit/i,
1943
- thinkingPatterns: ["Thinking"],
2321
+ // Claude uses whimsical verbs like "Wibbling…", "Dancing…", etc. Match any capitalized -ing word + ellipsis (… or ...)
2322
+ thinkingPatterns: ["Thinking", /[A-Z][a-z]+ing(…|\.\.\.)/],
1944
2323
  confirmPatterns: [
1945
2324
  "Do you want to make this edit",
1946
2325
  "Do you want to run this command",
@@ -1965,6 +2344,13 @@ const ClaudeAgent = new Agent({
1965
2344
  envVar: "AX_SESSION",
1966
2345
  approveKey: "1",
1967
2346
  rejectKey: "Escape",
2347
+ sessionIdFlag: "--session-id",
2348
+ logPathFinder: (sessionName) => {
2349
+ const parsed = parseSessionName(sessionName);
2350
+ const uuid = parsed?.uuid;
2351
+ if (uuid) return findClaudeLogPath(uuid, sessionName);
2352
+ return null;
2353
+ },
1968
2354
  });
1969
2355
 
1970
2356
  // =============================================================================
@@ -2002,30 +2388,38 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2002
2388
  return { state, screen };
2003
2389
  }
2004
2390
  }
2005
- throw new Error("timeout");
2391
+ throw new TimeoutError(session);
2006
2392
  }
2007
2393
 
2008
2394
  /**
2009
- * Wait for agent to process a new message and respond.
2010
- * Waits for screen activity before considering the response complete.
2395
+ * Core polling loop for waiting on agent responses.
2011
2396
  * @param {Agent} agent
2012
2397
  * @param {string} session
2013
- * @param {number} [timeoutMs]
2398
+ * @param {number} timeoutMs
2399
+ * @param {{onPoll?: (screen: string, state: string) => void, onStateChange?: (state: string, lastState: string | null, screen: string) => void, onReady?: (screen: string) => void}} [hooks]
2014
2400
  * @returns {Promise<{state: string, screen: string}>}
2015
2401
  */
2016
- async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2402
+ async function pollForResponse(agent, session, timeoutMs, hooks = {}) {
2403
+ const { onPoll, onStateChange, onReady } = hooks;
2017
2404
  const start = Date.now();
2018
2405
  const initialScreen = tmuxCapture(session);
2019
2406
 
2020
2407
  let lastScreen = initialScreen;
2408
+ let lastState = null;
2021
2409
  let stableAt = null;
2022
2410
  let sawActivity = false;
2023
2411
 
2024
2412
  while (Date.now() - start < timeoutMs) {
2025
- await sleep(POLL_MS);
2026
2413
  const screen = tmuxCapture(session);
2027
2414
  const state = agent.getState(screen);
2028
2415
 
2416
+ if (onPoll) onPoll(screen, state);
2417
+
2418
+ if (state !== lastState) {
2419
+ if (onStateChange) onStateChange(state, lastState, screen);
2420
+ lastState = state;
2421
+ }
2422
+
2029
2423
  if (state === State.RATE_LIMITED || state === State.CONFIRMING) {
2030
2424
  return { state, screen };
2031
2425
  }
@@ -2040,6 +2434,7 @@ async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2040
2434
 
2041
2435
  if (sawActivity && stableAt && Date.now() - stableAt >= STABLE_MS) {
2042
2436
  if (state === State.READY) {
2437
+ if (onReady) onReady(screen);
2043
2438
  return { state, screen };
2044
2439
  }
2045
2440
  }
@@ -2047,26 +2442,86 @@ async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2047
2442
  if (state === State.THINKING) {
2048
2443
  sawActivity = true;
2049
2444
  }
2445
+
2446
+ await sleep(POLL_MS);
2050
2447
  }
2051
- throw new Error("timeout");
2448
+ throw new TimeoutError(session);
2052
2449
  }
2053
2450
 
2054
2451
  /**
2055
- * Auto-approve loop that keeps approving confirmations until the agent is ready or rate limited.
2056
- * Used by callers to implement yolo mode on sessions not started with native --yolo.
2452
+ * Wait for agent response without streaming output.
2057
2453
  * @param {Agent} agent
2058
2454
  * @param {string} session
2059
2455
  * @param {number} [timeoutMs]
2060
2456
  * @returns {Promise<{state: string, screen: string}>}
2061
2457
  */
2062
- async function autoApproveLoop(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2458
+ async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2459
+ return pollForResponse(agent, session, timeoutMs);
2460
+ }
2461
+
2462
+ /**
2463
+ * Wait for agent response with streaming output to console.
2464
+ * @param {Agent} agent
2465
+ * @param {string} session
2466
+ * @param {number} [timeoutMs]
2467
+ * @returns {Promise<{state: string, screen: string}>}
2468
+ */
2469
+ async function streamResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2470
+ let logPath = agent.findLogPath(session);
2471
+ let logOffset = logPath && existsSync(logPath) ? statSync(logPath).size : 0;
2472
+ let printedThinking = false;
2473
+
2474
+ const streamNewEntries = () => {
2475
+ if (!logPath) {
2476
+ logPath = agent.findLogPath(session);
2477
+ if (logPath && existsSync(logPath)) {
2478
+ logOffset = statSync(logPath).size;
2479
+ }
2480
+ }
2481
+ if (logPath) {
2482
+ const { entries, newOffset } = tailJsonl(logPath, logOffset);
2483
+ logOffset = newOffset;
2484
+ for (const entry of entries) {
2485
+ const formatted = formatEntry(entry);
2486
+ if (formatted) console.log(formatted);
2487
+ }
2488
+ }
2489
+ };
2490
+
2491
+ return pollForResponse(agent, session, timeoutMs, {
2492
+ onPoll: () => streamNewEntries(),
2493
+ onStateChange: (state, lastState, screen) => {
2494
+ if (state === State.THINKING && !printedThinking) {
2495
+ console.log("[THINKING]");
2496
+ printedThinking = true;
2497
+ } else if (state === State.CONFIRMING) {
2498
+ const pendingTool = extractPendingToolFromScreen(screen);
2499
+ console.log(pendingTool ? `[CONFIRMING] ${pendingTool}` : "[CONFIRMING]");
2500
+ }
2501
+ if (lastState === State.THINKING && state !== State.THINKING) {
2502
+ printedThinking = false;
2503
+ }
2504
+ },
2505
+ onReady: () => streamNewEntries(),
2506
+ });
2507
+ }
2508
+
2509
+ /**
2510
+ * Auto-approve loop that keeps approving confirmations until the agent is ready or rate limited.
2511
+ * @param {Agent} agent
2512
+ * @param {string} session
2513
+ * @param {number} timeoutMs
2514
+ * @param {Function} waitFn - waitForResponse or streamResponse
2515
+ * @returns {Promise<{state: string, screen: string}>}
2516
+ */
2517
+ async function autoApproveLoop(agent, session, timeoutMs, waitFn) {
2063
2518
  const deadline = Date.now() + timeoutMs;
2064
2519
 
2065
2520
  while (Date.now() < deadline) {
2066
2521
  const remaining = deadline - Date.now();
2067
2522
  if (remaining <= 0) break;
2068
2523
 
2069
- const { state, screen } = await waitForResponse(agent, session, remaining);
2524
+ const { state, screen } = await waitFn(agent, session, remaining);
2070
2525
 
2071
2526
  if (state === State.RATE_LIMITED || state === State.READY) {
2072
2527
  return { state, screen };
@@ -2078,11 +2533,10 @@ async function autoApproveLoop(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2078
2533
  continue;
2079
2534
  }
2080
2535
 
2081
- // Unexpected state - log and continue polling
2082
2536
  debugError("autoApproveLoop", new Error(`unexpected state: ${state}`));
2083
2537
  }
2084
2538
 
2085
- throw new Error("timeout");
2539
+ throw new TimeoutError(session);
2086
2540
  }
2087
2541
 
2088
2542
  /**
@@ -2141,41 +2595,73 @@ function cmdAgents() {
2141
2595
 
2142
2596
  if (agentSessions.length === 0) {
2143
2597
  console.log("No agents running");
2598
+ // Still check for orphans
2599
+ const orphans = findOrphanedProcesses();
2600
+ if (orphans.length > 0) {
2601
+ console.log(`\nOrphaned (${orphans.length}):`);
2602
+ for (const { pid, command } of orphans) {
2603
+ console.log(` PID ${pid}: ${command}`);
2604
+ }
2605
+ console.log(`\n Run 'ax kill --orphans' to clean up`);
2606
+ }
2144
2607
  return;
2145
2608
  }
2146
2609
 
2610
+ // Get default session for each agent type
2611
+ const claudeDefault = ClaudeAgent.getDefaultSession();
2612
+ const codexDefault = CodexAgent.getDefaultSession();
2613
+
2147
2614
  // Get info for each agent
2148
2615
  const agents = agentSessions.map((session) => {
2149
2616
  const parsed = /** @type {ParsedSession} */ (parseSessionName(session));
2150
2617
  const agent = parsed.tool === "claude" ? ClaudeAgent : CodexAgent;
2151
2618
  const screen = tmuxCapture(session);
2152
2619
  const state = agent.getState(screen);
2153
- const logPath = agent.findLogPath(session);
2154
2620
  const type = parsed.archangelName ? "archangel" : "-";
2621
+ const isDefault =
2622
+ (parsed.tool === "claude" && session === claudeDefault) ||
2623
+ (parsed.tool === "codex" && session === codexDefault);
2624
+
2625
+ // Get session metadata (Claude only)
2626
+ const meta = getSessionMeta(session);
2155
2627
 
2156
2628
  return {
2157
2629
  session,
2158
2630
  tool: parsed.tool,
2159
2631
  state: state || "unknown",
2632
+ target: isDefault ? "*" : "",
2160
2633
  type,
2161
- log: logPath || "-",
2634
+ plan: meta?.slug || "-",
2635
+ branch: meta?.gitBranch || "-",
2162
2636
  };
2163
2637
  });
2164
2638
 
2165
- // Print table
2639
+ // Print sessions table
2166
2640
  const maxSession = Math.max(7, ...agents.map((a) => a.session.length));
2167
2641
  const maxTool = Math.max(4, ...agents.map((a) => a.tool.length));
2168
2642
  const maxState = Math.max(5, ...agents.map((a) => a.state.length));
2643
+ const maxTarget = Math.max(6, ...agents.map((a) => a.target.length));
2169
2644
  const maxType = Math.max(4, ...agents.map((a) => a.type.length));
2645
+ const maxPlan = Math.max(4, ...agents.map((a) => a.plan.length));
2170
2646
 
2171
2647
  console.log(
2172
- `${"SESSION".padEnd(maxSession)} ${"TOOL".padEnd(maxTool)} ${"STATE".padEnd(maxState)} ${"TYPE".padEnd(maxType)} LOG`,
2648
+ `${"SESSION".padEnd(maxSession)} ${"TOOL".padEnd(maxTool)} ${"STATE".padEnd(maxState)} ${"TARGET".padEnd(maxTarget)} ${"TYPE".padEnd(maxType)} ${"PLAN".padEnd(maxPlan)} BRANCH`,
2173
2649
  );
2174
2650
  for (const a of agents) {
2175
2651
  console.log(
2176
- `${a.session.padEnd(maxSession)} ${a.tool.padEnd(maxTool)} ${a.state.padEnd(maxState)} ${a.type.padEnd(maxType)} ${a.log}`,
2652
+ `${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}`,
2177
2653
  );
2178
2654
  }
2655
+
2656
+ // Print orphaned processes if any
2657
+ const orphans = findOrphanedProcesses();
2658
+ if (orphans.length > 0) {
2659
+ console.log(`\nOrphaned (${orphans.length}):`);
2660
+ for (const { pid, command } of orphans) {
2661
+ console.log(` PID ${pid}: ${command}`);
2662
+ }
2663
+ console.log(`\n Run 'ax kill --orphans' to clean up`);
2664
+ }
2179
2665
  }
2180
2666
 
2181
2667
  // =============================================================================
@@ -2312,6 +2798,13 @@ async function cmdArchangel(agentName) {
2312
2798
  let isProcessing = false;
2313
2799
  const intervalMs = config.interval * 1000;
2314
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
+
2315
2808
  async function processChanges() {
2316
2809
  clearTimeout(debounceTimer);
2317
2810
  clearTimeout(maxWaitTimer);
@@ -2329,6 +2822,21 @@ async function cmdArchangel(agentName) {
2329
2822
  const parent = findParentSession();
2330
2823
  const logPath = parent ? findClaudeLogPath(parent.uuid, parent.session) : null;
2331
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
+
2332
2840
  // Build file-specific context from JSONL
2333
2841
  const fileContexts = [];
2334
2842
  for (const file of files.slice(0, 5)) {
@@ -2340,7 +2848,18 @@ async function cmdArchangel(agentName) {
2340
2848
  }
2341
2849
 
2342
2850
  // Build the prompt
2343
- 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
+ }
2344
2863
 
2345
2864
  if (fileContexts.length > 0) {
2346
2865
  prompt += "\n\n## Recent Edits (from parent session)\n";
@@ -2374,8 +2893,7 @@ async function cmdArchangel(agentName) {
2374
2893
  prompt += "\n\n## Git Context\n\n" + gitContext;
2375
2894
  }
2376
2895
 
2377
- prompt +=
2378
- '\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.";
2379
2897
  } else {
2380
2898
  // Fallback: no JSONL context available, use conversation + git context
2381
2899
  const parentContext = getParentSessionContext(ARCHANGEL_PARENT_CONTEXT_ENTRIES);
@@ -2395,8 +2913,7 @@ async function cmdArchangel(agentName) {
2395
2913
  prompt += "\n\n## Git Context\n\n" + gitContext;
2396
2914
  }
2397
2915
 
2398
- prompt +=
2399
- '\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.";
2400
2917
  }
2401
2918
 
2402
2919
  // Check session still exists
@@ -2425,6 +2942,7 @@ async function cmdArchangel(agentName) {
2425
2942
  await sleep(200); // Allow time for large prompts to be processed
2426
2943
  tmuxSend(sessionName, "Enter");
2427
2944
  await sleep(100); // Ensure Enter is processed
2945
+ isFirstTrigger = false;
2428
2946
 
2429
2947
  // Wait for response
2430
2948
  const { state: endState, screen: afterScreen } = await waitForResponse(
@@ -2838,9 +3356,31 @@ function ensureClaudeHookConfig() {
2838
3356
 
2839
3357
  /**
2840
3358
  * @param {string | null | undefined} session
2841
- * @param {{all?: boolean}} [options]
3359
+ * @param {{all?: boolean, orphans?: boolean, force?: boolean}} [options]
2842
3360
  */
2843
- function cmdKill(session, { all = false } = {}) {
3361
+ function cmdKill(session, { all = false, orphans = false, force = false } = {}) {
3362
+ // Handle orphaned processes
3363
+ if (orphans) {
3364
+ const orphanedProcesses = findOrphanedProcesses();
3365
+
3366
+ if (orphanedProcesses.length === 0) {
3367
+ console.log("No orphaned processes found");
3368
+ return;
3369
+ }
3370
+
3371
+ const signal = force ? "-9" : "-15"; // SIGKILL vs SIGTERM
3372
+ let killed = 0;
3373
+ for (const { pid, command } of orphanedProcesses) {
3374
+ const result = spawnSync("kill", [signal, pid]);
3375
+ if (result.status === 0) {
3376
+ console.log(`Killed: PID ${pid} (${command.slice(0, 40)})`);
3377
+ killed++;
3378
+ }
3379
+ }
3380
+ console.log(`Killed ${killed} orphaned process(es)${force ? " (forced)" : ""}`);
3381
+ return;
3382
+ }
3383
+
2844
3384
  // If specific session provided, kill just that one
2845
3385
  if (session) {
2846
3386
  if (!tmuxHasSession(session)) {
@@ -3203,7 +3743,7 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
3203
3743
  * @param {string} message
3204
3744
  * @param {{noWait?: boolean, yolo?: boolean, timeoutMs?: number}} [options]
3205
3745
  */
3206
- async function cmdAsk(agent, session, message, { noWait = false, yolo = false, timeoutMs } = {}) {
3746
+ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
3207
3747
  const sessionExists = session != null && tmuxHasSession(session);
3208
3748
  const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
3209
3749
 
@@ -3223,14 +3763,23 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3223
3763
  await sleep(50);
3224
3764
  tmuxSend(activeSession, "Enter");
3225
3765
 
3226
- if (noWait) return;
3766
+ if (noWait) {
3767
+ const parsed = parseSessionName(activeSession);
3768
+ const shortId = parsed?.uuid?.slice(0, 8) || activeSession;
3769
+ const cli = path.basename(process.argv[1], ".js");
3770
+ console.log(`Sent to: ${shortId}
3771
+
3772
+ e.g.
3773
+ ${cli} status --session=${shortId}
3774
+ ${cli} output --session=${shortId}`);
3775
+ return;
3776
+ }
3227
3777
 
3228
- // Yolo mode on a safe session: auto-approve until done
3229
3778
  const useAutoApprove = yolo && !nativeYolo;
3230
3779
 
3231
3780
  const { state, screen } = useAutoApprove
3232
- ? await autoApproveLoop(agent, activeSession, timeoutMs)
3233
- : await waitForResponse(agent, activeSession, timeoutMs);
3781
+ ? await autoApproveLoop(agent, activeSession, timeoutMs, streamResponse)
3782
+ : await streamResponse(agent, activeSession, timeoutMs);
3234
3783
 
3235
3784
  if (state === State.RATE_LIMITED) {
3236
3785
  console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
@@ -3238,14 +3787,9 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3238
3787
  }
3239
3788
 
3240
3789
  if (state === State.CONFIRMING) {
3241
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3790
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3242
3791
  process.exit(3);
3243
3792
  }
3244
-
3245
- const output = agent.getResponse(activeSession, screen);
3246
- if (output) {
3247
- console.log(output);
3248
- }
3249
3793
  }
3250
3794
 
3251
3795
  /**
@@ -3260,9 +3804,10 @@ async function cmdApprove(agent, session, { wait = false, timeoutMs } = {}) {
3260
3804
  }
3261
3805
 
3262
3806
  const before = tmuxCapture(session);
3263
- if (agent.getState(before) !== State.CONFIRMING) {
3264
- console.log("ERROR: not confirming");
3265
- process.exit(1);
3807
+ const beforeState = agent.getState(before);
3808
+ if (beforeState !== State.CONFIRMING) {
3809
+ console.log(`Already ${beforeState}`);
3810
+ return;
3266
3811
  }
3267
3812
 
3268
3813
  tmuxSend(session, agent.approveKey);
@@ -3277,7 +3822,7 @@ async function cmdApprove(agent, session, { wait = false, timeoutMs } = {}) {
3277
3822
  }
3278
3823
 
3279
3824
  if (state === State.CONFIRMING) {
3280
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3825
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3281
3826
  process.exit(3);
3282
3827
  }
3283
3828
 
@@ -3296,6 +3841,13 @@ async function cmdReject(agent, session, { wait = false, timeoutMs } = {}) {
3296
3841
  process.exit(1);
3297
3842
  }
3298
3843
 
3844
+ const before = tmuxCapture(session);
3845
+ const beforeState = agent.getState(before);
3846
+ if (beforeState !== State.CONFIRMING) {
3847
+ console.log(`Already ${beforeState}`);
3848
+ return;
3849
+ }
3850
+
3299
3851
  tmuxSend(session, agent.rejectKey);
3300
3852
 
3301
3853
  if (!wait) return;
@@ -3323,7 +3875,7 @@ async function cmdReview(
3323
3875
  session,
3324
3876
  option,
3325
3877
  customInstructions,
3326
- { wait = true, yolo = true, fresh = false, timeoutMs = REVIEW_TIMEOUT_MS } = {},
3878
+ { wait = true, yolo = false, fresh = false, timeoutMs = REVIEW_TIMEOUT_MS } = {},
3327
3879
  ) {
3328
3880
  const sessionExists = session != null && tmuxHasSession(session);
3329
3881
 
@@ -3390,12 +3942,11 @@ async function cmdReview(
3390
3942
 
3391
3943
  if (!wait) return;
3392
3944
 
3393
- // Yolo mode on a safe session: auto-approve until done
3394
3945
  const useAutoApprove = yolo && !nativeYolo;
3395
3946
 
3396
3947
  const { state, screen } = useAutoApprove
3397
- ? await autoApproveLoop(agent, activeSession, timeoutMs)
3398
- : await waitForResponse(agent, activeSession, timeoutMs);
3948
+ ? await autoApproveLoop(agent, activeSession, timeoutMs, streamResponse)
3949
+ : await streamResponse(agent, activeSession, timeoutMs);
3399
3950
 
3400
3951
  if (state === State.RATE_LIMITED) {
3401
3952
  console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
@@ -3403,12 +3954,9 @@ async function cmdReview(
3403
3954
  }
3404
3955
 
3405
3956
  if (state === State.CONFIRMING) {
3406
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3957
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3407
3958
  process.exit(3);
3408
3959
  }
3409
-
3410
- const response = agent.getResponse(activeSession, screen);
3411
- console.log(response || "");
3412
3960
  }
3413
3961
 
3414
3962
  /**
@@ -3439,7 +3987,7 @@ async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs }
3439
3987
  }
3440
3988
 
3441
3989
  if (state === State.CONFIRMING) {
3442
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3990
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3443
3991
  process.exit(3);
3444
3992
  }
3445
3993
 
@@ -3451,6 +3999,8 @@ async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs }
3451
3999
  const output = agent.getResponse(session, screen, index);
3452
4000
  if (output) {
3453
4001
  console.log(output);
4002
+ } else {
4003
+ console.log("READY_NO_CONTENT");
3454
4004
  }
3455
4005
  }
3456
4006
 
@@ -3473,7 +4023,7 @@ function cmdStatus(agent, session) {
3473
4023
  }
3474
4024
 
3475
4025
  if (state === State.CONFIRMING) {
3476
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
4026
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3477
4027
  process.exit(3);
3478
4028
  }
3479
4029
 
@@ -3481,6 +4031,10 @@ function cmdStatus(agent, session) {
3481
4031
  console.log("THINKING");
3482
4032
  process.exit(4);
3483
4033
  }
4034
+
4035
+ // READY (or STARTING/UPDATE_PROMPT which are transient)
4036
+ console.log("READY");
4037
+ process.exit(0);
3484
4038
  }
3485
4039
 
3486
4040
  /**
@@ -3594,7 +4148,7 @@ async function cmdSelect(agent, session, n, { wait = false, timeoutMs } = {}) {
3594
4148
  }
3595
4149
 
3596
4150
  if (state === State.CONFIRMING) {
3597
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
4151
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3598
4152
  process.exit(3);
3599
4153
  }
3600
4154
 
@@ -3629,19 +4183,22 @@ function getAgentFromInvocation() {
3629
4183
  */
3630
4184
  function printHelp(agent, cliName) {
3631
4185
  const name = cliName;
3632
- const backendName = agent.name === "codex" ? "Codex" : "Claude";
4186
+ const backendName = agent.displayName;
3633
4187
  const hasReview = !!agent.reviewOptions;
3634
4188
 
3635
4189
  console.log(`${name} v${VERSION} - agentic assistant CLI (${backendName})
3636
4190
 
4191
+ Usage: ${name} [OPTIONS] <command|message> [ARGS...]
4192
+
3637
4193
  Commands:
3638
4194
  agents List all running agents with state and log paths
4195
+ target Show default target session for current tool
3639
4196
  attach [SESSION] Attach to agent session interactively
3640
4197
  log SESSION View conversation log (--tail=N, --follow, --reasoning)
3641
4198
  mailbox View archangel observations (--limit=N, --branch=X, --all)
3642
4199
  summon [name] Summon archangels (all, or by name)
3643
4200
  recall [name] Recall archangels (all, or by name)
3644
- kill Kill sessions in current project (--all for all, --session=NAME for one)
4201
+ kill Kill sessions (--all, --session=NAME, --orphans [--force])
3645
4202
  status Check state (exit: 0=ready, 2=rate_limited, 3=confirming, 4=thinking)
3646
4203
  output [-N] Show response (0=last, -1=prev, -2=older)
3647
4204
  debug Show raw screen output and detected state${
@@ -3661,11 +4218,13 @@ Commands:
3661
4218
  Flags:
3662
4219
  --tool=NAME Use specific agent (codex, claude)
3663
4220
  --session=NAME Target session by name, archangel name, or UUID prefix (self = current)
3664
- --wait Wait for response (for review, approve, etc)
3665
- --no-wait Don't wait (for messages, which wait by default)
3666
- --timeout=N Set timeout in seconds (default: 120)
4221
+ --wait Wait for response (default for messages; required for approve/reject)
4222
+ --no-wait Fire-and-forget: send message, print session ID, exit immediately
4223
+ --timeout=N Set timeout in seconds (default: ${DEFAULT_TIMEOUT_MS / 1000}, reviews: ${REVIEW_TIMEOUT_MS / 1000})
3667
4224
  --yolo Skip all confirmations (dangerous)
3668
4225
  --fresh Reset conversation before review
4226
+ --orphans Kill orphaned claude/codex processes (PPID=1)
4227
+ --force Use SIGKILL instead of SIGTERM (with --orphans)
3669
4228
 
3670
4229
  Environment:
3671
4230
  AX_DEFAULT_TOOL Default agent when using 'ax' (claude or codex, default: codex)
@@ -3677,7 +4236,8 @@ Environment:
3677
4236
 
3678
4237
  Examples:
3679
4238
  ${name} "explain this codebase"
3680
- ${name} "please review the error handling" # Auto custom review
4239
+ ${name} "review the error handling" # Auto custom review (${REVIEW_TIMEOUT_MS / 60000}min timeout)
4240
+ ${name} "FYI: auth was refactored" --no-wait # Send context to a working session (no response needed)
3681
4241
  ${name} review uncommitted --wait
3682
4242
  ${name} approve --wait
3683
4243
  ${name} kill # Kill agents in current project
@@ -3689,7 +4249,10 @@ Examples:
3689
4249
  ${name} summon reviewer # Summon by name (creates config if new)
3690
4250
  ${name} recall # Recall all archangels
3691
4251
  ${name} recall reviewer # Recall one by name
3692
- ${name} agents # List all agents (shows TYPE=archangel)`);
4252
+ ${name} agents # List all agents (shows TYPE=archangel)
4253
+
4254
+ Note: Reviews and complex tasks may take several minutes.
4255
+ Use Bash run_in_background for long operations (not --no-wait).`);
3693
4256
  }
3694
4257
 
3695
4258
  async function main() {
@@ -3704,38 +4267,32 @@ async function main() {
3704
4267
  const args = process.argv.slice(2);
3705
4268
  const cliName = path.basename(process.argv[1], ".js");
3706
4269
 
3707
- if (args.includes("--version") || args.includes("-V")) {
4270
+ // Parse all flags and positionals in one place
4271
+ const { flags, positionals } = parseCliArgs(args);
4272
+
4273
+ if (flags.version) {
3708
4274
  console.log(VERSION);
3709
4275
  process.exit(0);
3710
4276
  }
3711
4277
 
3712
- // Parse flags
3713
- const wait = args.includes("--wait");
3714
- const noWait = args.includes("--no-wait");
3715
- const yolo = args.includes("--yolo");
3716
- const fresh = args.includes("--fresh");
3717
- const reasoning = args.includes("--reasoning");
3718
- const follow = args.includes("--follow") || args.includes("-f");
4278
+ // Extract flags into local variables for convenience
4279
+ const { wait, noWait, yolo, fresh, reasoning, follow, all, orphans, force } = flags;
3719
4280
 
3720
4281
  // Agent selection
3721
4282
  let agent = getAgentFromInvocation();
3722
- const toolArg = args.find((a) => a.startsWith("--tool="));
3723
- if (toolArg) {
3724
- const tool = toolArg.split("=")[1];
3725
- if (tool === "claude") agent = ClaudeAgent;
3726
- else if (tool === "codex") agent = CodexAgent;
4283
+ if (flags.tool) {
4284
+ if (flags.tool === "claude") agent = ClaudeAgent;
4285
+ else if (flags.tool === "codex") agent = CodexAgent;
3727
4286
  else {
3728
- console.log(`ERROR: unknown tool '${tool}'`);
4287
+ console.log(`ERROR: unknown tool '${flags.tool}'`);
3729
4288
  process.exit(1);
3730
4289
  }
3731
4290
  }
3732
4291
 
3733
4292
  // Session resolution
3734
4293
  let session = agent.getDefaultSession();
3735
- const sessionArg = args.find((a) => a.startsWith("--session="));
3736
- if (sessionArg) {
3737
- const val = sessionArg.split("=")[1];
3738
- if (val === "self") {
4294
+ if (flags.session) {
4295
+ if (flags.session === "self") {
3739
4296
  const current = tmuxCurrentSession();
3740
4297
  if (!current) {
3741
4298
  console.log("ERROR: --session=self requires running inside tmux");
@@ -3744,110 +4301,92 @@ async function main() {
3744
4301
  session = current;
3745
4302
  } else {
3746
4303
  // Resolve partial names, archangel names, and UUID prefixes
3747
- session = resolveSessionName(val);
4304
+ session = resolveSessionName(flags.session);
3748
4305
  }
3749
4306
  }
3750
4307
 
3751
- // Timeout
4308
+ // Timeout (convert seconds to milliseconds)
3752
4309
  let timeoutMs = DEFAULT_TIMEOUT_MS;
3753
- const timeoutArg = args.find((a) => a.startsWith("--timeout="));
3754
- if (timeoutArg) {
3755
- const val = parseInt(timeoutArg.split("=")[1], 10);
3756
- if (isNaN(val) || val <= 0) {
4310
+ if (flags.timeout !== undefined) {
4311
+ if (isNaN(flags.timeout) || flags.timeout <= 0) {
3757
4312
  console.log("ERROR: invalid timeout");
3758
4313
  process.exit(1);
3759
4314
  }
3760
- timeoutMs = val * 1000;
4315
+ timeoutMs = flags.timeout * 1000;
3761
4316
  }
3762
4317
 
3763
4318
  // Tail (for log command)
3764
- let tail = 50;
3765
- const tailArg = args.find((a) => a.startsWith("--tail="));
3766
- if (tailArg) {
3767
- tail = parseInt(tailArg.split("=")[1], 10) || 50;
3768
- }
4319
+ const tail = flags.tail ?? 50;
3769
4320
 
3770
4321
  // Limit (for mailbox command)
3771
- let limit = 20;
3772
- const limitArg = args.find((a) => a.startsWith("--limit="));
3773
- if (limitArg) {
3774
- limit = parseInt(limitArg.split("=")[1], 10) || 20;
3775
- }
4322
+ const limit = flags.limit ?? 20;
3776
4323
 
3777
4324
  // Branch filter (for mailbox command)
3778
- let branch = null;
3779
- const branchArg = args.find((a) => a.startsWith("--branch="));
3780
- if (branchArg) {
3781
- branch = branchArg.split("=")[1] || null;
3782
- }
3783
-
3784
- // All flag (for mailbox command - show all regardless of age)
3785
- const all = args.includes("--all");
3786
-
3787
- // Filter out flags
3788
- const filteredArgs = args.filter(
3789
- (a) =>
3790
- !["--wait", "--no-wait", "--yolo", "--reasoning", "--follow", "-f", "--all"].includes(a) &&
3791
- !a.startsWith("--timeout") &&
3792
- !a.startsWith("--session") &&
3793
- !a.startsWith("--tool") &&
3794
- !a.startsWith("--tail") &&
3795
- !a.startsWith("--limit") &&
3796
- !a.startsWith("--branch"),
3797
- );
3798
- const cmd = filteredArgs[0];
4325
+ const branch = flags.branch ?? null;
4326
+
4327
+ // Command is first positional
4328
+ const cmd = positionals[0];
3799
4329
 
3800
4330
  // Dispatch commands
3801
4331
  if (cmd === "agents") return cmdAgents();
3802
- if (cmd === "summon") return cmdSummon(filteredArgs[1]);
3803
- if (cmd === "recall") return cmdRecall(filteredArgs[1]);
3804
- if (cmd === "archangel") return cmdArchangel(filteredArgs[1]);
3805
- if (cmd === "kill") return cmdKill(session, { all });
3806
- if (cmd === "attach") return cmdAttach(filteredArgs[1] || session);
3807
- if (cmd === "log") return cmdLog(filteredArgs[1] || session, { tail, reasoning, follow });
4332
+ if (cmd === "target") {
4333
+ const defaultSession = agent.getDefaultSession();
4334
+ if (defaultSession) {
4335
+ console.log(defaultSession);
4336
+ } else {
4337
+ console.log("NO_TARGET");
4338
+ process.exit(1);
4339
+ }
4340
+ return;
4341
+ }
4342
+ if (cmd === "summon") return cmdSummon(positionals[1]);
4343
+ if (cmd === "recall") return cmdRecall(positionals[1]);
4344
+ if (cmd === "archangel") return cmdArchangel(positionals[1]);
4345
+ if (cmd === "kill") return cmdKill(session, { all, orphans, force });
4346
+ if (cmd === "attach") return cmdAttach(positionals[1] || session);
4347
+ if (cmd === "log") return cmdLog(positionals[1] || session, { tail, reasoning, follow });
3808
4348
  if (cmd === "mailbox") return cmdMailbox({ limit, branch, all });
3809
4349
  if (cmd === "approve") return cmdApprove(agent, session, { wait, timeoutMs });
3810
4350
  if (cmd === "reject") return cmdReject(agent, session, { wait, timeoutMs });
3811
4351
  if (cmd === "review")
3812
- return cmdReview(agent, session, filteredArgs[1], filteredArgs[2], {
4352
+ return cmdReview(agent, session, positionals[1], positionals[2], {
3813
4353
  wait,
3814
- yolo,
3815
4354
  fresh,
3816
4355
  timeoutMs,
3817
4356
  });
3818
4357
  if (cmd === "status") return cmdStatus(agent, session);
3819
4358
  if (cmd === "debug") return cmdDebug(agent, session);
3820
4359
  if (cmd === "output") {
3821
- const indexArg = filteredArgs[1];
4360
+ const indexArg = positionals[1];
3822
4361
  const index = indexArg?.startsWith("-") ? parseInt(indexArg, 10) : 0;
3823
4362
  return cmdOutput(agent, session, index, { wait, timeoutMs });
3824
4363
  }
3825
- if (cmd === "send" && filteredArgs.length > 1)
3826
- return cmdSend(session, filteredArgs.slice(1).join(" "));
4364
+ if (cmd === "send" && positionals.length > 1)
4365
+ return cmdSend(session, positionals.slice(1).join(" "));
3827
4366
  if (cmd === "compact") return cmdAsk(agent, session, "/compact", { noWait: true, timeoutMs });
3828
4367
  if (cmd === "reset") return cmdAsk(agent, session, "/new", { noWait: true, timeoutMs });
3829
- if (cmd === "select" && filteredArgs[1])
3830
- return cmdSelect(agent, session, filteredArgs[1], { wait, timeoutMs });
4368
+ if (cmd === "select" && positionals[1])
4369
+ return cmdSelect(agent, session, positionals[1], { wait, timeoutMs });
3831
4370
 
3832
4371
  // Default: send message
3833
- let message = filteredArgs.join(" ");
4372
+ let message = positionals.join(" ");
3834
4373
  if (!message && hasStdinData()) {
3835
4374
  message = await readStdin();
3836
4375
  }
3837
4376
 
3838
- if (!message || cmd === "--help" || cmd === "-h") {
4377
+ if (!message || flags.help) {
3839
4378
  printHelp(agent, cliName);
3840
4379
  process.exit(0);
3841
4380
  }
3842
4381
 
3843
- // Detect "please review" and route to custom review mode
3844
- const reviewMatch = message.match(/^please review\s*(.*)/i);
4382
+ // Detect "review ..." or "please review ..." and route to custom review mode
4383
+ const reviewMatch = message.match(/^(?:please )?review\s*(.*)/i);
3845
4384
  if (reviewMatch && agent.reviewOptions) {
3846
4385
  const customInstructions = reviewMatch[1].trim() || null;
3847
4386
  return cmdReview(agent, session, "custom", customInstructions, {
3848
4387
  wait: !noWait,
3849
4388
  yolo,
3850
- timeoutMs,
4389
+ timeoutMs: flags.timeout !== undefined ? timeoutMs : REVIEW_TIMEOUT_MS,
3851
4390
  });
3852
4391
  }
3853
4392
 
@@ -3868,6 +4407,9 @@ const isDirectRun =
3868
4407
  if (isDirectRun) {
3869
4408
  main().catch((err) => {
3870
4409
  console.log(`ERROR: ${err.message}`);
4410
+ if (err instanceof TimeoutError && err.session) {
4411
+ console.log(`Hint: Use 'ax debug --session=${err.session}' to see current screen state`);
4412
+ }
3871
4413
  process.exit(1);
3872
4414
  });
3873
4415
  }
@@ -3877,6 +4419,7 @@ export {
3877
4419
  parseSessionName,
3878
4420
  parseAgentConfig,
3879
4421
  parseKeySequence,
4422
+ parseCliArgs,
3880
4423
  getClaudeProjectPath,
3881
4424
  matchesPattern,
3882
4425
  getBaseDir,