ax-agents 0.0.1-alpha.5 → 0.0.1-alpha.7

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 +6 -2
  2. package/ax.js +990 -368
  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,29 @@
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";
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";
18
30
  import { randomUUID } 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";
22
35
 
23
36
  const __filename = fileURLToPath(import.meta.url);
24
37
  const __dirname = path.dirname(__filename);
@@ -110,8 +123,9 @@ const VERSION = packageJson.version;
110
123
  */
111
124
 
112
125
  /**
126
+ * @typedef {{matcher: string, hooks: Array<{type: string, command: string, timeout?: number}>}} ClaudeHookEntry
113
127
  * @typedef {Object} ClaudeSettings
114
- * @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]
115
129
  */
116
130
 
117
131
  const DEBUG = process.env.AX_DEBUG === "1";
@@ -221,7 +235,9 @@ function tmuxKill(session) {
221
235
  */
222
236
  function tmuxNewSession(session, command) {
223
237
  // Use spawnSync to avoid command injection via session/command
224
- 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
+ });
225
241
  if (result.status !== 0) throw new Error(result.stderr || "tmux new-session failed");
226
242
  }
227
243
 
@@ -230,7 +246,9 @@ function tmuxNewSession(session, command) {
230
246
  */
231
247
  function tmuxCurrentSession() {
232
248
  if (!process.env.TMUX) return null;
233
- 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
+ });
234
252
  if (result.status !== 0) return null;
235
253
  return result.stdout.trim();
236
254
  }
@@ -242,9 +260,13 @@ function tmuxCurrentSession() {
242
260
  */
243
261
  function isYoloSession(session) {
244
262
  try {
245
- const result = spawnSync("tmux", ["display-message", "-t", session, "-p", "#{pane_start_command}"], {
246
- encoding: "utf-8",
247
- });
263
+ const result = spawnSync(
264
+ "tmux",
265
+ ["display-message", "-t", session, "-p", "#{pane_start_command}"],
266
+ {
267
+ encoding: "utf-8",
268
+ },
269
+ );
248
270
  if (result.status !== 0) return false;
249
271
  const cmd = result.stdout.trim();
250
272
  return cmd.includes("--dangerously-");
@@ -264,8 +286,14 @@ const POLL_MS = parseInt(process.env.AX_POLL_MS || "200", 10);
264
286
  const DEFAULT_TIMEOUT_MS = parseInt(process.env.AX_TIMEOUT_MS || "120000", 10);
265
287
  const REVIEW_TIMEOUT_MS = parseInt(process.env.AX_REVIEW_TIMEOUT_MS || "900000", 10); // 15 minutes
266
288
  const STARTUP_TIMEOUT_MS = parseInt(process.env.AX_STARTUP_TIMEOUT_MS || "30000", 10);
267
- const ARCHANGEL_STARTUP_TIMEOUT_MS = parseInt(process.env.AX_ARCHANGEL_STARTUP_TIMEOUT_MS || "60000", 10);
268
- const ARCHANGEL_RESPONSE_TIMEOUT_MS = parseInt(process.env.AX_ARCHANGEL_RESPONSE_TIMEOUT_MS || "300000", 10); // 5 minutes
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
269
297
  const ARCHANGEL_HEALTH_CHECK_MS = parseInt(process.env.AX_ARCHANGEL_HEALTH_CHECK_MS || "30000", 10);
270
298
  const STABLE_MS = parseInt(process.env.AX_STABLE_MS || "1000", 10);
271
299
  const APPROVE_DELAY_MS = parseInt(process.env.AX_APPROVE_DELAY_MS || "100", 10);
@@ -278,6 +306,21 @@ const ARCHANGEL_GIT_CONTEXT_HOURS = 4;
278
306
  const ARCHANGEL_GIT_CONTEXT_MAX_LINES = 200;
279
307
  const ARCHANGEL_PARENT_CONTEXT_ENTRIES = 10;
280
308
 
309
+ /**
310
+ * @param {string} session
311
+ * @param {(screen: string) => boolean} predicate
312
+ * @param {number} [timeoutMs]
313
+ * @returns {Promise<string>}
314
+ */
315
+ class TimeoutError extends Error {
316
+ /** @param {string} [session] */
317
+ constructor(session) {
318
+ super("timeout");
319
+ this.name = "TimeoutError";
320
+ this.session = session;
321
+ }
322
+ }
323
+
281
324
  /**
282
325
  * @param {string} session
283
326
  * @param {(screen: string) => boolean} predicate
@@ -291,7 +334,7 @@ async function waitFor(session, predicate, timeoutMs = STARTUP_TIMEOUT_MS) {
291
334
  if (predicate(screen)) return screen;
292
335
  await sleep(POLL_MS);
293
336
  }
294
- throw new Error("timeout");
337
+ throw new TimeoutError(session);
295
338
  }
296
339
 
297
340
  // =============================================================================
@@ -304,7 +347,9 @@ async function waitFor(session, predicate, timeoutMs = STARTUP_TIMEOUT_MS) {
304
347
  function findCallerPid() {
305
348
  let pid = process.ppid;
306
349
  while (pid > 1) {
307
- const result = spawnSync("ps", ["-p", pid.toString(), "-o", "ppid=,comm="], { encoding: "utf-8" });
350
+ const result = spawnSync("ps", ["-p", pid.toString(), "-o", "ppid=,comm="], {
351
+ encoding: "utf-8",
352
+ });
308
353
  if (result.status !== 0) break;
309
354
  const parts = result.stdout.trim().split(/\s+/);
310
355
  const ppid = parseInt(parts[0], 10);
@@ -317,6 +362,38 @@ function findCallerPid() {
317
362
  return null;
318
363
  }
319
364
 
365
+ /**
366
+ * Find orphaned claude/codex processes (PPID=1, reparented to init/launchd)
367
+ * @returns {{pid: string, command: string}[]}
368
+ */
369
+ function findOrphanedProcesses() {
370
+ const result = spawnSync("ps", ["-eo", "pid=,ppid=,args="], { encoding: "utf-8" });
371
+
372
+ if (result.status !== 0 || !result.stdout.trim()) {
373
+ return [];
374
+ }
375
+
376
+ const orphans = [];
377
+ for (const line of result.stdout.trim().split("\n")) {
378
+ // Parse: " PID PPID command args..."
379
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/);
380
+ if (!match) continue;
381
+
382
+ const [, pid, ppid, args] = match;
383
+
384
+ // Must have PPID=1 (orphaned/reparented to init)
385
+ if (ppid !== "1") continue;
386
+
387
+ // Command must START with claude or codex (excludes tmux which also has PPID=1)
388
+ const cmd = args.split(/\s+/)[0];
389
+ if (cmd !== "claude" && cmd !== "codex") continue;
390
+
391
+ orphans.push({ pid, command: args.slice(0, 60) });
392
+ }
393
+
394
+ return orphans;
395
+ }
396
+
320
397
  // =============================================================================
321
398
  // Helpers - stdin
322
399
  // =============================================================================
@@ -345,6 +422,86 @@ async function readStdin() {
345
422
  }
346
423
 
347
424
  // =============================================================================
425
+ // =============================================================================
426
+ // Helpers - CLI argument parsing
427
+ // =============================================================================
428
+
429
+ /**
430
+ * Parse CLI arguments using Node.js built-in parseArgs.
431
+ * @param {string[]} args - Command line arguments (without node and script path)
432
+ * @returns {{ flags: ParsedFlags, positionals: string[] }}
433
+ *
434
+ * @typedef {Object} ParsedFlags
435
+ * @property {boolean} wait
436
+ * @property {boolean} noWait
437
+ * @property {boolean} yolo
438
+ * @property {boolean} fresh
439
+ * @property {boolean} reasoning
440
+ * @property {boolean} follow
441
+ * @property {boolean} all
442
+ * @property {boolean} orphans
443
+ * @property {boolean} force
444
+ * @property {boolean} version
445
+ * @property {boolean} help
446
+ * @property {string} [tool]
447
+ * @property {string} [session]
448
+ * @property {number} [timeout]
449
+ * @property {number} [tail]
450
+ * @property {number} [limit]
451
+ * @property {string} [branch]
452
+ */
453
+ function parseCliArgs(args) {
454
+ const { values, positionals } = parseArgs({
455
+ args,
456
+ options: {
457
+ // Boolean flags
458
+ wait: { type: "boolean", default: false },
459
+ "no-wait": { type: "boolean", default: false },
460
+ yolo: { type: "boolean", default: false },
461
+ fresh: { type: "boolean", default: false },
462
+ reasoning: { type: "boolean", default: false },
463
+ follow: { type: "boolean", short: "f", default: false },
464
+ all: { type: "boolean", default: false },
465
+ orphans: { type: "boolean", default: false },
466
+ force: { type: "boolean", default: false },
467
+ version: { type: "boolean", short: "V", default: false },
468
+ help: { type: "boolean", short: "h", default: false },
469
+ // Value flags
470
+ tool: { type: "string" },
471
+ session: { type: "string" },
472
+ timeout: { type: "string" },
473
+ tail: { type: "string" },
474
+ limit: { type: "string" },
475
+ branch: { type: "string" },
476
+ },
477
+ allowPositionals: true,
478
+ strict: false, // Don't error on unknown flags
479
+ });
480
+
481
+ return {
482
+ flags: {
483
+ wait: Boolean(values.wait),
484
+ noWait: Boolean(values["no-wait"]),
485
+ yolo: Boolean(values.yolo),
486
+ fresh: Boolean(values.fresh),
487
+ reasoning: Boolean(values.reasoning),
488
+ follow: Boolean(values.follow),
489
+ all: Boolean(values.all),
490
+ orphans: Boolean(values.orphans),
491
+ force: Boolean(values.force),
492
+ version: Boolean(values.version),
493
+ help: Boolean(values.help),
494
+ tool: /** @type {string | undefined} */ (values.tool),
495
+ session: /** @type {string | undefined} */ (values.session),
496
+ timeout: values.timeout !== undefined ? Number(values.timeout) : undefined,
497
+ tail: values.tail !== undefined ? Number(values.tail) : undefined,
498
+ limit: values.limit !== undefined ? Number(values.limit) : undefined,
499
+ branch: /** @type {string | undefined} */ (values.branch),
500
+ },
501
+ positionals,
502
+ };
503
+ }
504
+
348
505
  // Helpers - session tracking
349
506
  // =============================================================================
350
507
 
@@ -360,13 +517,17 @@ function parseSessionName(session) {
360
517
  const rest = match[2];
361
518
 
362
519
  // Archangel: {tool}-archangel-{name}-{uuid}
363
- const archangelMatch = rest.match(/^archangel-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
520
+ const archangelMatch = rest.match(
521
+ /^archangel-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i,
522
+ );
364
523
  if (archangelMatch) {
365
524
  return { tool, archangelName: archangelMatch[1], uuid: archangelMatch[2] };
366
525
  }
367
526
 
368
527
  // Partner: {tool}-partner-{uuid}
369
- 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);
528
+ const partnerMatch = rest.match(
529
+ /^partner-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i,
530
+ );
370
531
  if (partnerMatch) {
371
532
  return { tool, uuid: partnerMatch[1] };
372
533
  }
@@ -399,9 +560,13 @@ function getClaudeProjectPath(cwd) {
399
560
  */
400
561
  function getTmuxSessionCwd(sessionName) {
401
562
  try {
402
- const result = spawnSync("tmux", ["display-message", "-t", sessionName, "-p", "#{pane_current_path}"], {
403
- encoding: "utf-8",
404
- });
563
+ const result = spawnSync(
564
+ "tmux",
565
+ ["display-message", "-t", sessionName, "-p", "#{pane_current_path}"],
566
+ {
567
+ encoding: "utf-8",
568
+ },
569
+ );
405
570
  if (result.status === 0) return result.stdout.trim();
406
571
  } catch (err) {
407
572
  debugError("getTmuxSessionCwd", err);
@@ -425,7 +590,9 @@ function findClaudeLogPath(sessionId, sessionName) {
425
590
  if (existsSync(indexPath)) {
426
591
  try {
427
592
  const index = JSON.parse(readFileSync(indexPath, "utf-8"));
428
- const entry = index.entries?.find(/** @param {{sessionId: string, fullPath?: string}} e */ (e) => e.sessionId === sessionId);
593
+ const entry = index.entries?.find(
594
+ /** @param {{sessionId: string, fullPath?: string}} e */ (e) => e.sessionId === sessionId,
595
+ );
429
596
  if (entry?.fullPath) return entry.fullPath;
430
597
  } catch (err) {
431
598
  debugError("findClaudeLogPath", err);
@@ -447,9 +614,13 @@ function findCodexLogPath(sessionName) {
447
614
  // For Codex, we need to match by timing since we can't control the session ID
448
615
  // Get tmux session creation time
449
616
  try {
450
- const result = spawnSync("tmux", ["display-message", "-t", sessionName, "-p", "#{session_created}"], {
451
- encoding: "utf-8",
452
- });
617
+ const result = spawnSync(
618
+ "tmux",
619
+ ["display-message", "-t", sessionName, "-p", "#{session_created}"],
620
+ {
621
+ encoding: "utf-8",
622
+ },
623
+ );
453
624
  if (result.status !== 0) return null;
454
625
  const createdTs = parseInt(result.stdout.trim(), 10) * 1000; // tmux gives seconds, we need ms
455
626
  if (isNaN(createdTs)) return null;
@@ -483,7 +654,11 @@ function findCodexLogPath(sessionName) {
483
654
  // Log file should be created shortly after session start
484
655
  // Allow small negative diff (-2s) for clock skew, up to 60s for slow starts
485
656
  if (diff >= -2000 && diff < 60000) {
486
- candidates.push({ file, diff: Math.abs(diff), path: path.join(dayDir, file) });
657
+ candidates.push({
658
+ file,
659
+ diff: Math.abs(diff),
660
+ path: path.join(dayDir, file),
661
+ });
487
662
  }
488
663
  }
489
664
 
@@ -496,7 +671,6 @@ function findCodexLogPath(sessionName) {
496
671
  }
497
672
  }
498
673
 
499
-
500
674
  /**
501
675
  * Extract assistant text responses from a JSONL log file.
502
676
  * This provides clean text without screen-scraped artifacts.
@@ -542,6 +716,130 @@ function getAssistantText(logPath, index = 0) {
542
716
  }
543
717
  }
544
718
 
719
+ /**
720
+ * Read new complete JSON lines from a log file since the given offset.
721
+ * @param {string | null} logPath
722
+ * @param {number} fromOffset
723
+ * @returns {{ entries: object[], newOffset: number }}
724
+ */
725
+ function tailJsonl(logPath, fromOffset) {
726
+ if (!logPath || !existsSync(logPath)) {
727
+ return { entries: [], newOffset: fromOffset };
728
+ }
729
+
730
+ const stats = statSync(logPath);
731
+ if (stats.size <= fromOffset) {
732
+ return { entries: [], newOffset: fromOffset };
733
+ }
734
+
735
+ const fd = openSync(logPath, "r");
736
+ const buffer = Buffer.alloc(stats.size - fromOffset);
737
+ readSync(fd, buffer, 0, buffer.length, fromOffset);
738
+ closeSync(fd);
739
+
740
+ const text = buffer.toString("utf-8");
741
+ const lines = text.split("\n");
742
+
743
+ // Last line may be incomplete - don't parse it yet
744
+ const complete = lines.slice(0, -1).filter(Boolean);
745
+ const incomplete = lines[lines.length - 1];
746
+
747
+ const entries = [];
748
+ for (const line of complete) {
749
+ try {
750
+ entries.push(JSON.parse(line));
751
+ } catch {
752
+ // Skip malformed lines
753
+ }
754
+ }
755
+
756
+ // Offset advances by complete lines only
757
+ const newOffset = fromOffset + text.length - incomplete.length;
758
+ return { entries, newOffset };
759
+ }
760
+
761
+ /**
762
+ * @typedef {{command?: string, file_path?: string, path?: string, pattern?: string}} ToolInput
763
+ */
764
+
765
+ /**
766
+ * Format a JSONL entry for streaming display.
767
+ * @param {{type?: string, message?: {content?: Array<{type?: string, text?: string, name?: string, input?: ToolInput, tool?: string, arguments?: ToolInput}>}}} entry
768
+ * @returns {string | null}
769
+ */
770
+ function formatEntry(entry) {
771
+ // Skip tool_result entries (they can be very verbose)
772
+ if (entry.type === "tool_result") return null;
773
+
774
+ // Only process assistant entries
775
+ if (entry.type !== "assistant") return null;
776
+
777
+ const parts = entry.message?.content || [];
778
+ const output = [];
779
+
780
+ for (const part of parts) {
781
+ if (part.type === "text" && part.text) {
782
+ output.push(part.text);
783
+ } else if (part.type === "tool_use" || part.type === "tool_call") {
784
+ const name = part.name || part.tool || "tool";
785
+ const input = part.input || part.arguments || {};
786
+ let summary;
787
+ if (name === "Bash" && input.command) {
788
+ summary = input.command.slice(0, 50);
789
+ } else {
790
+ const target = input.file_path || input.path || input.pattern || "";
791
+ summary = target.split("/").pop() || target.slice(0, 30);
792
+ }
793
+ output.push(`> ${name}(${summary})`);
794
+ }
795
+ // Skip thinking blocks - internal reasoning
796
+ }
797
+
798
+ return output.length > 0 ? output.join("\n") : null;
799
+ }
800
+
801
+ /**
802
+ * Extract pending tool from confirmation screen.
803
+ * @param {string} screen
804
+ * @returns {string | null}
805
+ */
806
+ function extractPendingToolFromScreen(screen) {
807
+ const lines = screen.split("\n");
808
+
809
+ // Check recent lines for tool confirmation patterns
810
+ for (let i = lines.length - 1; i >= Math.max(0, lines.length - 15); i--) {
811
+ const line = lines[i];
812
+ // Match tool confirmation patterns like "Bash: command" or "Write: /path/file"
813
+ const match = line.match(
814
+ /^\s*(Bash|Write|Edit|Read|Glob|Grep|Task|WebFetch|WebSearch|NotebookEdit|Skill|TodoWrite|TodoRead):\s*(.{1,40})/,
815
+ );
816
+ if (match) {
817
+ return `${match[1]}: ${match[2].trim()}`;
818
+ }
819
+ }
820
+
821
+ return null;
822
+ }
823
+
824
+ /**
825
+ * Format confirmation output with helpful commands
826
+ * @param {string} screen
827
+ * @param {Agent} _agent
828
+ * @returns {string}
829
+ */
830
+ function formatConfirmationOutput(screen, _agent) {
831
+ const pendingTool = extractPendingToolFromScreen(screen);
832
+ const cli = path.basename(process.argv[1], ".js");
833
+
834
+ let output = pendingTool || "Confirmation required";
835
+ output += "\n\ne.g.";
836
+ output += `\n ${cli} approve # for y/n prompts`;
837
+ output += `\n ${cli} reject`;
838
+ output += `\n ${cli} select N # for numbered menus`;
839
+
840
+ return output;
841
+ }
842
+
545
843
  /**
546
844
  * @returns {string[]}
547
845
  */
@@ -622,7 +920,7 @@ function loadAgentConfigs() {
622
920
  try {
623
921
  const content = readFileSync(path.join(agentsDir, file), "utf-8");
624
922
  const config = parseAgentConfig(file, content);
625
- if (config && 'error' in config) {
923
+ if (config && "error" in config) {
626
924
  console.error(`ERROR: ${file}: ${config.error}`);
627
925
  continue;
628
926
  }
@@ -653,7 +951,9 @@ function parseAgentConfig(filename, content) {
653
951
  return { error: `Missing frontmatter. File must start with '---'` };
654
952
  }
655
953
  if (!normalized.includes("\n---\n")) {
656
- return { error: `Frontmatter not closed. Add '---' on its own line after the YAML block` };
954
+ return {
955
+ error: `Frontmatter not closed. Add '---' on its own line after the YAML block`,
956
+ };
657
957
  }
658
958
  return { error: `Invalid frontmatter format` };
659
959
  }
@@ -674,9 +974,13 @@ function parseAgentConfig(filename, content) {
674
974
  const fieldName = line.trim().match(/^(\w+):/)?.[1];
675
975
  if (fieldName && !knownFields.includes(fieldName)) {
676
976
  // Suggest closest match
677
- const suggestions = knownFields.filter((f) => f[0] === fieldName[0] || fieldName.includes(f.slice(0, 3)));
977
+ const suggestions = knownFields.filter(
978
+ (f) => f[0] === fieldName[0] || fieldName.includes(f.slice(0, 3)),
979
+ );
678
980
  const hint = suggestions.length > 0 ? ` Did you mean '${suggestions[0]}'?` : "";
679
- return { error: `Unknown field '${fieldName}'.${hint} Valid fields: ${knownFields.join(", ")}` };
981
+ return {
982
+ error: `Unknown field '${fieldName}'.${hint} Valid fields: ${knownFields.join(", ")}`,
983
+ };
680
984
  }
681
985
  }
682
986
 
@@ -694,7 +998,9 @@ function parseAgentConfig(filename, content) {
694
998
  const rawValue = intervalMatch[1].trim();
695
999
  const parsed = parseInt(rawValue, 10);
696
1000
  if (isNaN(parsed)) {
697
- return { error: `Invalid interval '${rawValue}'. Must be a number (seconds)` };
1001
+ return {
1002
+ error: `Invalid interval '${rawValue}'. Must be a number (seconds)`,
1003
+ };
698
1004
  }
699
1005
  interval = Math.max(10, Math.min(3600, parsed)); // Clamp to 10s - 1hr
700
1006
  }
@@ -706,16 +1012,22 @@ function parseAgentConfig(filename, content) {
706
1012
  const rawWatch = watchLine[1].trim();
707
1013
  // Must be array format
708
1014
  if (!rawWatch.startsWith("[") || !rawWatch.endsWith("]")) {
709
- return { error: `Invalid watch format. Must be an array: watch: ["src/**/*.ts"]` };
1015
+ return {
1016
+ error: `Invalid watch format. Must be an array: watch: ["src/**/*.ts"]`,
1017
+ };
710
1018
  }
711
1019
  const inner = rawWatch.slice(1, -1).trim();
712
1020
  if (!inner) {
713
- return { error: `Empty watch array. Add at least one pattern: watch: ["**/*"]` };
1021
+ return {
1022
+ error: `Empty watch array. Add at least one pattern: watch: ["**/*"]`,
1023
+ };
714
1024
  }
715
1025
  watchPatterns = inner.split(",").map((p) => p.trim().replace(/^["']|["']$/g, ""));
716
1026
  // Validate patterns aren't empty
717
1027
  if (watchPatterns.some((p) => !p)) {
718
- return { error: `Invalid watch pattern. Check for trailing commas or empty values` };
1028
+ return {
1029
+ error: `Invalid watch pattern. Check for trailing commas or empty values`,
1030
+ };
719
1031
  }
720
1032
  }
721
1033
 
@@ -831,7 +1143,9 @@ function gcMailbox(maxAgeHours = 24) {
831
1143
  /** @returns {string} */
832
1144
  function getCurrentBranch() {
833
1145
  try {
834
- return execSync("git branch --show-current 2>/dev/null", { encoding: "utf-8" }).trim();
1146
+ return execSync("git branch --show-current 2>/dev/null", {
1147
+ encoding: "utf-8",
1148
+ }).trim();
835
1149
  } catch {
836
1150
  return "unknown";
837
1151
  }
@@ -840,7 +1154,9 @@ function getCurrentBranch() {
840
1154
  /** @returns {string} */
841
1155
  function getCurrentCommit() {
842
1156
  try {
843
- return execSync("git rev-parse --short HEAD 2>/dev/null", { encoding: "utf-8" }).trim();
1157
+ return execSync("git rev-parse --short HEAD 2>/dev/null", {
1158
+ encoding: "utf-8",
1159
+ }).trim();
844
1160
  } catch {
845
1161
  return "unknown";
846
1162
  }
@@ -864,7 +1180,9 @@ function getMainBranch() {
864
1180
  /** @returns {string} */
865
1181
  function getStagedDiff() {
866
1182
  try {
867
- return execSync("git diff --cached 2>/dev/null", { encoding: "utf-8" }).trim();
1183
+ return execSync("git diff --cached 2>/dev/null", {
1184
+ encoding: "utf-8",
1185
+ }).trim();
868
1186
  } catch {
869
1187
  return "";
870
1188
  }
@@ -889,20 +1207,18 @@ function getRecentCommitsDiff(hoursAgo = 4) {
889
1207
  const since = `--since="${hoursAgo} hours ago"`;
890
1208
 
891
1209
  // Get list of commits in range
892
- const commits = execSync(
893
- `git log ${mainBranch}..HEAD ${since} --oneline 2>/dev/null`,
894
- { encoding: "utf-8" }
895
- ).trim();
1210
+ const commits = execSync(`git log ${mainBranch}..HEAD ${since} --oneline 2>/dev/null`, {
1211
+ encoding: "utf-8",
1212
+ }).trim();
896
1213
 
897
1214
  if (!commits) return "";
898
1215
 
899
1216
  // Get diff for those commits
900
1217
  const firstCommit = commits.split("\n").filter(Boolean).pop()?.split(" ")[0];
901
1218
  if (!firstCommit) return "";
902
- return execSync(
903
- `git diff ${firstCommit}^..HEAD 2>/dev/null`,
904
- { encoding: "utf-8" }
905
- ).trim();
1219
+ return execSync(`git diff ${firstCommit}^..HEAD 2>/dev/null`, {
1220
+ encoding: "utf-8",
1221
+ }).trim();
906
1222
  } catch {
907
1223
  return "";
908
1224
  }
@@ -917,7 +1233,10 @@ function truncateDiff(diff, maxLines = 200) {
917
1233
  if (!diff) return "";
918
1234
  const lines = diff.split("\n");
919
1235
  if (lines.length <= maxLines) return diff;
920
- return lines.slice(0, maxLines).join("\n") + `\n\n... (truncated, ${lines.length - maxLines} more lines)`;
1236
+ return (
1237
+ lines.slice(0, maxLines).join("\n") +
1238
+ `\n\n... (truncated, ${lines.length - maxLines} more lines)`
1239
+ );
921
1240
  }
922
1241
 
923
1242
  /**
@@ -1002,18 +1321,23 @@ function findCurrentClaudeSession() {
1002
1321
  const claudeProjectDir = path.join(CLAUDE_CONFIG_DIR, "projects", projectPath);
1003
1322
  if (existsSync(claudeProjectDir)) {
1004
1323
  try {
1005
- const files = readdirSync(claudeProjectDir).filter(f => f.endsWith(".jsonl"));
1324
+ const files = readdirSync(claudeProjectDir).filter((f) => f.endsWith(".jsonl"));
1006
1325
  for (const file of files) {
1007
1326
  const uuid = file.replace(".jsonl", "");
1008
1327
  // Skip if we already have this from tmux sessions
1009
- if (candidates.some(c => c.uuid === uuid)) continue;
1328
+ if (candidates.some((c) => c.uuid === uuid)) continue;
1010
1329
 
1011
1330
  const logPath = path.join(claudeProjectDir, file);
1012
1331
  try {
1013
1332
  const stat = statSync(logPath);
1014
1333
  // Only consider logs modified in the last hour (active sessions)
1015
1334
  if (Date.now() - stat.mtimeMs < MAILBOX_MAX_AGE_MS) {
1016
- candidates.push({ session: null, uuid, mtime: stat.mtimeMs, logPath });
1335
+ candidates.push({
1336
+ session: null,
1337
+ uuid,
1338
+ mtime: stat.mtimeMs,
1339
+ logPath,
1340
+ });
1017
1341
  }
1018
1342
  } catch (err) {
1019
1343
  debugError("findCurrentClaudeSession:logStat", err);
@@ -1085,7 +1409,9 @@ function getParentSessionContext(maxEntries = 20) {
1085
1409
  if (typeof c === "string" && c.length > 10) {
1086
1410
  entries.push({ type: "user", text: c });
1087
1411
  } else if (Array.isArray(c)) {
1088
- const text = c.find(/** @param {{type: string, text?: string}} x */ (x) => x.type === "text")?.text;
1412
+ const text = c.find(
1413
+ /** @param {{type: string, text?: string}} x */ (x) => x.type === "text",
1414
+ )?.text;
1089
1415
  if (text && text.length > 10) {
1090
1416
  entries.push({ type: "user", text });
1091
1417
  }
@@ -1093,7 +1419,10 @@ function getParentSessionContext(maxEntries = 20) {
1093
1419
  } else if (entry.type === "assistant") {
1094
1420
  /** @type {{type: string, text?: string}[]} */
1095
1421
  const parts = entry.message?.content || [];
1096
- const text = parts.filter((p) => p.type === "text").map((p) => p.text || "").join("\n");
1422
+ const text = parts
1423
+ .filter((p) => p.type === "text")
1424
+ .map((p) => p.text || "")
1425
+ .join("\n");
1097
1426
  // Only include assistant responses with meaningful text
1098
1427
  if (text && text.length > 20) {
1099
1428
  entries.push({ type: "assistant", text });
@@ -1105,7 +1434,7 @@ function getParentSessionContext(maxEntries = 20) {
1105
1434
  }
1106
1435
 
1107
1436
  // Format recent conversation
1108
- const formatted = entries.slice(-maxEntries).map(e => {
1437
+ const formatted = entries.slice(-maxEntries).map((e) => {
1109
1438
  const preview = e.text.slice(0, 500).replace(/\n/g, " ");
1110
1439
  return `**${e.type === "user" ? "User" : "Assistant"}**: ${preview}`;
1111
1440
  });
@@ -1148,10 +1477,16 @@ function extractFileEditContext(logPath, filePath) {
1148
1477
 
1149
1478
  // Parse all entries
1150
1479
  /** @type {any[]} */
1151
- const entries = lines.map((line, idx) => {
1152
- try { return { idx, ...JSON.parse(line) }; }
1153
- catch (err) { debugError("extractFileEditContext:parse", err); return null; }
1154
- }).filter(Boolean);
1480
+ const entries = lines
1481
+ .map((line, idx) => {
1482
+ try {
1483
+ return { idx, ...JSON.parse(line) };
1484
+ } catch (err) {
1485
+ debugError("extractFileEditContext:parse", err);
1486
+ return null;
1487
+ }
1488
+ })
1489
+ .filter(Boolean);
1155
1490
 
1156
1491
  // Find Write/Edit tool calls for this file (scan backwards, want most recent)
1157
1492
  /** @type {any} */
@@ -1164,9 +1499,10 @@ function extractFileEditContext(logPath, filePath) {
1164
1499
 
1165
1500
  /** @type {any[]} */
1166
1501
  const msgContent = entry.message?.content || [];
1167
- const toolCalls = msgContent.filter((/** @type {any} */ c) =>
1168
- (c.type === "tool_use" || c.type === "tool_call") &&
1169
- (c.name === "Write" || c.name === "Edit")
1502
+ const toolCalls = msgContent.filter(
1503
+ (/** @type {any} */ c) =>
1504
+ (c.type === "tool_use" || c.type === "tool_call") &&
1505
+ (c.name === "Write" || c.name === "Edit"),
1170
1506
  );
1171
1507
 
1172
1508
  for (const tc of toolCalls) {
@@ -1217,8 +1553,9 @@ function extractFileEditContext(logPath, filePath) {
1217
1553
 
1218
1554
  /** @type {any[]} */
1219
1555
  const msgContent = entry.message?.content || [];
1220
- const readCalls = msgContent.filter((/** @type {any} */ c) =>
1221
- (c.type === "tool_use" || c.type === "tool_call") && c.name === "Read"
1556
+ const readCalls = msgContent.filter(
1557
+ (/** @type {any} */ c) =>
1558
+ (c.type === "tool_use" || c.type === "tool_call") && c.name === "Read",
1222
1559
  );
1223
1560
 
1224
1561
  for (const rc of readCalls) {
@@ -1233,9 +1570,10 @@ function extractFileEditContext(logPath, filePath) {
1233
1570
  if (entry.type !== "assistant") continue;
1234
1571
  /** @type {any[]} */
1235
1572
  const msgContent = entry.message?.content || [];
1236
- const edits = msgContent.filter((/** @type {any} */ c) =>
1237
- (c.type === "tool_use" || c.type === "tool_call") &&
1238
- (c.name === "Write" || c.name === "Edit")
1573
+ const edits = msgContent.filter(
1574
+ (/** @type {any} */ c) =>
1575
+ (c.type === "tool_use" || c.type === "tool_call") &&
1576
+ (c.name === "Write" || c.name === "Edit"),
1239
1577
  );
1240
1578
  for (const e of edits) {
1241
1579
  const input = e.input || e.arguments || {};
@@ -1250,11 +1588,11 @@ function extractFileEditContext(logPath, filePath) {
1250
1588
  toolCall: {
1251
1589
  name: editEntry.toolCall.name,
1252
1590
  input: editEntry.toolCall.input || editEntry.toolCall.arguments,
1253
- id: editEntry.toolCall.id
1591
+ id: editEntry.toolCall.id,
1254
1592
  },
1255
1593
  subsequentErrors,
1256
1594
  readsBefore: [...new Set(readsBefore)].slice(0, 10),
1257
- editSequence
1595
+ editSequence,
1258
1596
  };
1259
1597
  }
1260
1598
 
@@ -1348,10 +1686,11 @@ function watchForChanges(patterns, callback) {
1348
1686
  }
1349
1687
  }
1350
1688
 
1351
- return () => { for (const w of watchers) w.close(); };
1689
+ return () => {
1690
+ for (const w of watchers) w.close();
1691
+ };
1352
1692
  }
1353
1693
 
1354
-
1355
1694
  // =============================================================================
1356
1695
  // State
1357
1696
  // =============================================================================
@@ -1373,7 +1712,7 @@ const State = {
1373
1712
  * @param {string} config.promptSymbol - Symbol indicating ready state
1374
1713
  * @param {string[]} [config.spinners] - Spinner characters indicating thinking
1375
1714
  * @param {RegExp} [config.rateLimitPattern] - Pattern for rate limit detection
1376
- * @param {string[]} [config.thinkingPatterns] - Text patterns indicating thinking
1715
+ * @param {(string | RegExp | ((lines: string) => boolean))[]} [config.thinkingPatterns] - Text patterns indicating thinking
1377
1716
  * @param {(string | ((lines: string) => boolean))[]} [config.confirmPatterns] - Patterns for confirmation dialogs
1378
1717
  * @param {{screen: string[], lastLines: string[]} | null} [config.updatePromptPatterns] - Patterns for update prompts
1379
1718
  * @returns {string} The detected state
@@ -1391,14 +1730,33 @@ function detectState(screen, config) {
1391
1730
  return State.RATE_LIMITED;
1392
1731
  }
1393
1732
 
1394
- // Thinking - spinners (full screen, they're unique UI elements)
1733
+ // Confirming - check before THINKING because "Running…" in tool output matches thinking patterns
1734
+ const confirmPatterns = config.confirmPatterns || [];
1735
+ for (const pattern of confirmPatterns) {
1736
+ if (typeof pattern === "function") {
1737
+ // Functions check lastLines first (most specific), then recentLines
1738
+ if (pattern(lastLines)) return State.CONFIRMING;
1739
+ if (pattern(recentLines)) return State.CONFIRMING;
1740
+ } else {
1741
+ // String patterns check recentLines (bounded range)
1742
+ if (recentLines.includes(pattern)) return State.CONFIRMING;
1743
+ }
1744
+ }
1745
+
1746
+ // Thinking - spinners (check last lines only to avoid false positives from timing messages like "✻ Crunched for 32s")
1395
1747
  const spinners = config.spinners || [];
1396
- if (spinners.some((s) => screen.includes(s))) {
1748
+ if (spinners.some((s) => lastLines.includes(s))) {
1397
1749
  return State.THINKING;
1398
1750
  }
1399
- // Thinking - text patterns (last lines)
1751
+ // Thinking - text patterns (last lines) - supports strings, regexes, and functions
1400
1752
  const thinkingPatterns = config.thinkingPatterns || [];
1401
- if (thinkingPatterns.some((p) => lastLines.includes(p))) {
1753
+ if (
1754
+ thinkingPatterns.some((p) => {
1755
+ if (typeof p === "function") return p(lastLines);
1756
+ if (p instanceof RegExp) return p.test(lastLines);
1757
+ return lastLines.includes(p);
1758
+ })
1759
+ ) {
1402
1760
  return State.THINKING;
1403
1761
  }
1404
1762
 
@@ -1410,26 +1768,13 @@ function detectState(screen, config) {
1410
1768
  }
1411
1769
  }
1412
1770
 
1413
- // Confirming - check recent lines (not full screen to avoid history false positives)
1414
- const confirmPatterns = config.confirmPatterns || [];
1415
- for (const pattern of confirmPatterns) {
1416
- if (typeof pattern === "function") {
1417
- // Functions check lastLines first (most specific), then recentLines
1418
- if (pattern(lastLines)) return State.CONFIRMING;
1419
- if (pattern(recentLines)) return State.CONFIRMING;
1420
- } else {
1421
- // String patterns check recentLines (bounded range)
1422
- if (recentLines.includes(pattern)) return State.CONFIRMING;
1423
- }
1424
- }
1425
-
1426
1771
  // Ready - only if prompt symbol is visible AND not followed by pasted content
1427
1772
  // "[Pasted text" indicates user has pasted content and Claude is still processing
1428
1773
  if (lastLines.includes(config.promptSymbol)) {
1429
1774
  // Check if any line has the prompt followed by pasted content indicator
1430
1775
  const linesArray = lastLines.split("\n");
1431
1776
  const promptWithPaste = linesArray.some(
1432
- (l) => l.includes(config.promptSymbol) && l.includes("[Pasted text")
1777
+ (l) => l.includes(config.promptSymbol) && l.includes("[Pasted text"),
1433
1778
  );
1434
1779
  if (!promptWithPaste) {
1435
1780
  return State.READY;
@@ -1457,12 +1802,13 @@ function detectState(screen, config) {
1457
1802
  /**
1458
1803
  * @typedef {Object} AgentConfigInput
1459
1804
  * @property {string} name
1805
+ * @property {string} displayName
1460
1806
  * @property {string} startCommand
1461
1807
  * @property {string} yoloCommand
1462
1808
  * @property {string} promptSymbol
1463
1809
  * @property {string[]} [spinners]
1464
1810
  * @property {RegExp} [rateLimitPattern]
1465
- * @property {string[]} [thinkingPatterns]
1811
+ * @property {(string | RegExp | ((lines: string) => boolean))[]} [thinkingPatterns]
1466
1812
  * @property {ConfirmPattern[]} [confirmPatterns]
1467
1813
  * @property {UpdatePromptPatterns | null} [updatePromptPatterns]
1468
1814
  * @property {string[]} [responseMarkers]
@@ -1472,6 +1818,8 @@ function detectState(screen, config) {
1472
1818
  * @property {string} [approveKey]
1473
1819
  * @property {string} [rejectKey]
1474
1820
  * @property {string} [safeAllowedTools]
1821
+ * @property {string | null} [sessionIdFlag]
1822
+ * @property {((sessionName: string) => string | null) | null} [logPathFinder]
1475
1823
  */
1476
1824
 
1477
1825
  class Agent {
@@ -1482,6 +1830,8 @@ class Agent {
1482
1830
  /** @type {string} */
1483
1831
  this.name = config.name;
1484
1832
  /** @type {string} */
1833
+ this.displayName = config.displayName;
1834
+ /** @type {string} */
1485
1835
  this.startCommand = config.startCommand;
1486
1836
  /** @type {string} */
1487
1837
  this.yoloCommand = config.yoloCommand;
@@ -1491,7 +1841,7 @@ class Agent {
1491
1841
  this.spinners = config.spinners || [];
1492
1842
  /** @type {RegExp | undefined} */
1493
1843
  this.rateLimitPattern = config.rateLimitPattern;
1494
- /** @type {string[]} */
1844
+ /** @type {(string | RegExp | ((lines: string) => boolean))[]} */
1495
1845
  this.thinkingPatterns = config.thinkingPatterns || [];
1496
1846
  /** @type {ConfirmPattern[]} */
1497
1847
  this.confirmPatterns = config.confirmPatterns || [];
@@ -1511,6 +1861,10 @@ class Agent {
1511
1861
  this.rejectKey = config.rejectKey || "n";
1512
1862
  /** @type {string | undefined} */
1513
1863
  this.safeAllowedTools = config.safeAllowedTools;
1864
+ /** @type {string | null} */
1865
+ this.sessionIdFlag = config.sessionIdFlag || null;
1866
+ /** @type {((sessionName: string) => string | null) | null} */
1867
+ this.logPathFinder = config.logPathFinder || null;
1514
1868
  }
1515
1869
 
1516
1870
  /**
@@ -1528,11 +1882,11 @@ class Agent {
1528
1882
  } else {
1529
1883
  base = this.startCommand;
1530
1884
  }
1531
- // Claude supports --session-id for deterministic session tracking
1532
- if (this.name === "claude" && sessionName) {
1885
+ // Some agents support session ID flags for deterministic session tracking
1886
+ if (this.sessionIdFlag && sessionName) {
1533
1887
  const parsed = parseSessionName(sessionName);
1534
1888
  if (parsed?.uuid) {
1535
- return `${base} --session-id ${parsed.uuid}`;
1889
+ return `${base} ${this.sessionIdFlag} ${parsed.uuid}`;
1536
1890
  }
1537
1891
  }
1538
1892
  return base;
@@ -1590,13 +1944,8 @@ class Agent {
1590
1944
  * @returns {string | null}
1591
1945
  */
1592
1946
  findLogPath(sessionName) {
1593
- const parsed = parseSessionName(sessionName);
1594
- if (this.name === "claude") {
1595
- const uuid = parsed?.uuid;
1596
- if (uuid) return findClaudeLogPath(uuid, sessionName);
1597
- }
1598
- if (this.name === "codex") {
1599
- return findCodexLogPath(sessionName);
1947
+ if (this.logPathFinder) {
1948
+ return this.logPathFinder(sessionName);
1600
1949
  }
1601
1950
  return null;
1602
1951
  }
@@ -1640,7 +1989,12 @@ class Agent {
1640
1989
  if (/^(run|execute|create|delete|modify|write)/i.test(line)) return line;
1641
1990
  }
1642
1991
 
1643
- return lines.filter((l) => l && !l.match(/^[╭╮╰╯│─]+$/)).slice(0, 2).join(" | ") || "action";
1992
+ return (
1993
+ lines
1994
+ .filter((l) => l && !l.match(/^[╭╮╰╯│─]+$/))
1995
+ .slice(0, 2)
1996
+ .join(" | ") || "action"
1997
+ );
1644
1998
  }
1645
1999
 
1646
2000
  /**
@@ -1716,7 +2070,9 @@ class Agent {
1716
2070
 
1717
2071
  // Fallback: extract after last prompt
1718
2072
  if (filtered.length === 0) {
1719
- const lastPromptIdx = lines.findLastIndex((/** @type {string} */ l) => l.startsWith(this.promptSymbol));
2073
+ const lastPromptIdx = lines.findLastIndex((/** @type {string} */ l) =>
2074
+ l.startsWith(this.promptSymbol),
2075
+ );
1720
2076
  if (lastPromptIdx >= 0 && lastPromptIdx < lines.length - 1) {
1721
2077
  const afterPrompt = lines
1722
2078
  .slice(lastPromptIdx + 1)
@@ -1730,14 +2086,14 @@ class Agent {
1730
2086
  // This handles the case where Claude finished and shows a new empty prompt
1731
2087
  if (lastPromptIdx >= 0) {
1732
2088
  const lastPromptLine = lines[lastPromptIdx];
1733
- const isEmptyPrompt = lastPromptLine.trim() === this.promptSymbol ||
1734
- lastPromptLine.match(/^❯\s*$/);
2089
+ const isEmptyPrompt =
2090
+ lastPromptLine.trim() === this.promptSymbol || lastPromptLine.match(/^❯\s*$/);
1735
2091
  if (isEmptyPrompt) {
1736
2092
  // Find the previous prompt (user's input) and extract content between
1737
2093
  // Note: [Pasted text is Claude's truncated output indicator, NOT a prompt
1738
- const prevPromptIdx = lines.slice(0, lastPromptIdx).findLastIndex(
1739
- (/** @type {string} */ l) => l.startsWith(this.promptSymbol)
1740
- );
2094
+ const prevPromptIdx = lines
2095
+ .slice(0, lastPromptIdx)
2096
+ .findLastIndex((/** @type {string} */ l) => l.startsWith(this.promptSymbol));
1741
2097
  if (prevPromptIdx >= 0) {
1742
2098
  const betweenPrompts = lines
1743
2099
  .slice(prevPromptIdx + 1, lastPromptIdx)
@@ -1758,23 +2114,25 @@ class Agent {
1758
2114
  * @returns {string}
1759
2115
  */
1760
2116
  cleanResponse(response) {
1761
- return response
1762
- // Remove tool call lines (Search, Read, Grep, etc.)
1763
- .replace(/^[⏺•]\s*(Search|Read|Grep|Glob|Write|Edit|Bash)\([^)]*\).*$/gm, "")
1764
- // Remove tool result lines
1765
- .replace(/^⎿\s+.*$/gm, "")
1766
- // Remove "Sautéed for Xs" timing lines
1767
- .replace(/^✻\s+Sautéed for.*$/gm, "")
1768
- // Remove expand hints
1769
- .replace(/\(ctrl\+o to expand\)/g, "")
1770
- // Clean up multiple blank lines
1771
- .replace(/\n{3,}/g, "\n\n")
1772
- // Original cleanup
1773
- .replace(/^[•⏺-]\s*/, "")
1774
- .replace(/^\*\*(.+)\*\*/, "$1")
1775
- .replace(/\n /g, "\n")
1776
- .replace(/─+\s*$/, "")
1777
- .trim();
2117
+ return (
2118
+ response
2119
+ // Remove tool call lines (Search, Read, Grep, etc.)
2120
+ .replace(/^[⏺•]\s*(Search|Read|Grep|Glob|Write|Edit|Bash)\([^)]*\).*$/gm, "")
2121
+ // Remove tool result lines
2122
+ .replace(/^⎿\s+.*$/gm, "")
2123
+ // Remove "Sautéed for Xs" timing lines
2124
+ .replace(/^✻\s+Sautéed for.*$/gm, "")
2125
+ // Remove expand hints
2126
+ .replace(/\(ctrl\+o to expand\)/g, "")
2127
+ // Clean up multiple blank lines
2128
+ .replace(/\n{3,}/g, "\n\n")
2129
+ // Original cleanup
2130
+ .replace(/^[•⏺-]\s*/, "")
2131
+ .replace(/^\*\*(.+)\*\*/, "$1")
2132
+ .replace(/\n /g, "\n")
2133
+ .replace(/─+\s*$/, "")
2134
+ .trim()
2135
+ );
1778
2136
  }
1779
2137
 
1780
2138
  /**
@@ -1815,6 +2173,7 @@ class Agent {
1815
2173
 
1816
2174
  const CodexAgent = new Agent({
1817
2175
  name: "codex",
2176
+ displayName: "Codex",
1818
2177
  startCommand: "codex --sandbox read-only",
1819
2178
  yoloCommand: "codex --dangerously-bypass-approvals-and-sandbox",
1820
2179
  promptSymbol: "›",
@@ -1834,6 +2193,7 @@ const CodexAgent = new Agent({
1834
2193
  chromePatterns: ["context left", "for shortcuts"],
1835
2194
  reviewOptions: { pr: "1", uncommitted: "2", commit: "3", custom: "4" },
1836
2195
  envVar: "AX_SESSION",
2196
+ logPathFinder: findCodexLogPath,
1837
2197
  });
1838
2198
 
1839
2199
  // =============================================================================
@@ -1842,12 +2202,15 @@ const CodexAgent = new Agent({
1842
2202
 
1843
2203
  const ClaudeAgent = new Agent({
1844
2204
  name: "claude",
2205
+ displayName: "Claude",
1845
2206
  startCommand: "claude",
1846
2207
  yoloCommand: "claude --dangerously-skip-permissions",
1847
2208
  promptSymbol: "❯",
1848
- spinners: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
2209
+ // Claude Code spinners: ·✢✳✶✻✽ (from cli.js source)
2210
+ spinners: ["·", "✢", "✳", "✶", "✻", "✽"],
1849
2211
  rateLimitPattern: /rate.?limit/i,
1850
- thinkingPatterns: ["Thinking"],
2212
+ // Claude uses whimsical verbs like "Wibbling…", "Dancing…", etc. Match any capitalized -ing word + ellipsis (… or ...)
2213
+ thinkingPatterns: ["Thinking", /[A-Z][a-z]+ing(…|\.\.\.)/],
1851
2214
  confirmPatterns: [
1852
2215
  "Do you want to make this edit",
1853
2216
  "Do you want to run this command",
@@ -1857,12 +2220,28 @@ const ClaudeAgent = new Agent({
1857
2220
  ],
1858
2221
  updatePromptPatterns: null,
1859
2222
  responseMarkers: ["⏺", "•", "- ", "**"],
1860
- chromePatterns: ["↵ send", "Esc to cancel", "shortcuts", "for more options", "docs.anthropic.com", "⏵⏵", "bypass permissions", "shift+Tab to cycle"],
2223
+ chromePatterns: [
2224
+ "↵ send",
2225
+ "Esc to cancel",
2226
+ "shortcuts",
2227
+ "for more options",
2228
+ "docs.anthropic.com",
2229
+ "⏵⏵",
2230
+ "bypass permissions",
2231
+ "shift+Tab to cycle",
2232
+ ],
1861
2233
  reviewOptions: null,
1862
- safeAllowedTools: "Bash(git:*) Read Glob Grep", // Default: auto-approve read-only tools
2234
+ safeAllowedTools: "Bash(git:*) Read Glob Grep", // Default: auto-approve read-only tools
1863
2235
  envVar: "AX_SESSION",
1864
2236
  approveKey: "1",
1865
2237
  rejectKey: "Escape",
2238
+ sessionIdFlag: "--session-id",
2239
+ logPathFinder: (sessionName) => {
2240
+ const parsed = parseSessionName(sessionName);
2241
+ const uuid = parsed?.uuid;
2242
+ if (uuid) return findClaudeLogPath(uuid, sessionName);
2243
+ return null;
2244
+ },
1866
2245
  });
1867
2246
 
1868
2247
  // =============================================================================
@@ -1883,7 +2262,11 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
1883
2262
  const initialState = agent.getState(initialScreen);
1884
2263
 
1885
2264
  // Already in terminal state
1886
- if (initialState === State.RATE_LIMITED || initialState === State.CONFIRMING || initialState === State.READY) {
2265
+ if (
2266
+ initialState === State.RATE_LIMITED ||
2267
+ initialState === State.CONFIRMING ||
2268
+ initialState === State.READY
2269
+ ) {
1887
2270
  return { state: initialState, screen: initialScreen };
1888
2271
  }
1889
2272
 
@@ -1896,30 +2279,38 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
1896
2279
  return { state, screen };
1897
2280
  }
1898
2281
  }
1899
- throw new Error("timeout");
2282
+ throw new TimeoutError(session);
1900
2283
  }
1901
2284
 
1902
2285
  /**
1903
- * Wait for agent to process a new message and respond.
1904
- * Waits for screen activity before considering the response complete.
2286
+ * Core polling loop for waiting on agent responses.
1905
2287
  * @param {Agent} agent
1906
2288
  * @param {string} session
1907
- * @param {number} [timeoutMs]
2289
+ * @param {number} timeoutMs
2290
+ * @param {{onPoll?: (screen: string, state: string) => void, onStateChange?: (state: string, lastState: string | null, screen: string) => void, onReady?: (screen: string) => void}} [hooks]
1908
2291
  * @returns {Promise<{state: string, screen: string}>}
1909
2292
  */
1910
- async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2293
+ async function pollForResponse(agent, session, timeoutMs, hooks = {}) {
2294
+ const { onPoll, onStateChange, onReady } = hooks;
1911
2295
  const start = Date.now();
1912
2296
  const initialScreen = tmuxCapture(session);
1913
2297
 
1914
2298
  let lastScreen = initialScreen;
2299
+ let lastState = null;
1915
2300
  let stableAt = null;
1916
2301
  let sawActivity = false;
1917
2302
 
1918
2303
  while (Date.now() - start < timeoutMs) {
1919
- await sleep(POLL_MS);
1920
2304
  const screen = tmuxCapture(session);
1921
2305
  const state = agent.getState(screen);
1922
2306
 
2307
+ if (onPoll) onPoll(screen, state);
2308
+
2309
+ if (state !== lastState) {
2310
+ if (onStateChange) onStateChange(state, lastState, screen);
2311
+ lastState = state;
2312
+ }
2313
+
1923
2314
  if (state === State.RATE_LIMITED || state === State.CONFIRMING) {
1924
2315
  return { state, screen };
1925
2316
  }
@@ -1934,6 +2325,7 @@ async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
1934
2325
 
1935
2326
  if (sawActivity && stableAt && Date.now() - stableAt >= STABLE_MS) {
1936
2327
  if (state === State.READY) {
2328
+ if (onReady) onReady(screen);
1937
2329
  return { state, screen };
1938
2330
  }
1939
2331
  }
@@ -1941,26 +2333,86 @@ async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
1941
2333
  if (state === State.THINKING) {
1942
2334
  sawActivity = true;
1943
2335
  }
2336
+
2337
+ await sleep(POLL_MS);
1944
2338
  }
1945
- throw new Error("timeout");
2339
+ throw new TimeoutError(session);
1946
2340
  }
1947
2341
 
1948
2342
  /**
1949
- * Auto-approve loop that keeps approving confirmations until the agent is ready or rate limited.
1950
- * Used by callers to implement yolo mode on sessions not started with native --yolo.
2343
+ * Wait for agent response without streaming output.
2344
+ * @param {Agent} agent
2345
+ * @param {string} session
2346
+ * @param {number} [timeoutMs]
2347
+ * @returns {Promise<{state: string, screen: string}>}
2348
+ */
2349
+ async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2350
+ return pollForResponse(agent, session, timeoutMs);
2351
+ }
2352
+
2353
+ /**
2354
+ * Wait for agent response with streaming output to console.
1951
2355
  * @param {Agent} agent
1952
2356
  * @param {string} session
1953
2357
  * @param {number} [timeoutMs]
1954
2358
  * @returns {Promise<{state: string, screen: string}>}
1955
2359
  */
1956
- async function autoApproveLoop(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2360
+ async function streamResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2361
+ let logPath = agent.findLogPath(session);
2362
+ let logOffset = logPath && existsSync(logPath) ? statSync(logPath).size : 0;
2363
+ let printedThinking = false;
2364
+
2365
+ const streamNewEntries = () => {
2366
+ if (!logPath) {
2367
+ logPath = agent.findLogPath(session);
2368
+ if (logPath && existsSync(logPath)) {
2369
+ logOffset = statSync(logPath).size;
2370
+ }
2371
+ }
2372
+ if (logPath) {
2373
+ const { entries, newOffset } = tailJsonl(logPath, logOffset);
2374
+ logOffset = newOffset;
2375
+ for (const entry of entries) {
2376
+ const formatted = formatEntry(entry);
2377
+ if (formatted) console.log(formatted);
2378
+ }
2379
+ }
2380
+ };
2381
+
2382
+ return pollForResponse(agent, session, timeoutMs, {
2383
+ onPoll: () => streamNewEntries(),
2384
+ onStateChange: (state, lastState, screen) => {
2385
+ if (state === State.THINKING && !printedThinking) {
2386
+ console.log("[THINKING]");
2387
+ printedThinking = true;
2388
+ } else if (state === State.CONFIRMING) {
2389
+ const pendingTool = extractPendingToolFromScreen(screen);
2390
+ console.log(pendingTool ? `[CONFIRMING] ${pendingTool}` : "[CONFIRMING]");
2391
+ }
2392
+ if (lastState === State.THINKING && state !== State.THINKING) {
2393
+ printedThinking = false;
2394
+ }
2395
+ },
2396
+ onReady: () => streamNewEntries(),
2397
+ });
2398
+ }
2399
+
2400
+ /**
2401
+ * Auto-approve loop that keeps approving confirmations until the agent is ready or rate limited.
2402
+ * @param {Agent} agent
2403
+ * @param {string} session
2404
+ * @param {number} timeoutMs
2405
+ * @param {Function} waitFn - waitForResponse or streamResponse
2406
+ * @returns {Promise<{state: string, screen: string}>}
2407
+ */
2408
+ async function autoApproveLoop(agent, session, timeoutMs, waitFn) {
1957
2409
  const deadline = Date.now() + timeoutMs;
1958
2410
 
1959
2411
  while (Date.now() < deadline) {
1960
2412
  const remaining = deadline - Date.now();
1961
2413
  if (remaining <= 0) break;
1962
2414
 
1963
- const { state, screen } = await waitForResponse(agent, session, remaining);
2415
+ const { state, screen } = await waitFn(agent, session, remaining);
1964
2416
 
1965
2417
  if (state === State.RATE_LIMITED || state === State.READY) {
1966
2418
  return { state, screen };
@@ -1972,11 +2424,10 @@ async function autoApproveLoop(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
1972
2424
  continue;
1973
2425
  }
1974
2426
 
1975
- // Unexpected state - log and continue polling
1976
2427
  debugError("autoApproveLoop", new Error(`unexpected state: ${state}`));
1977
2428
  }
1978
2429
 
1979
- throw new Error("timeout");
2430
+ throw new TimeoutError(session);
1980
2431
  }
1981
2432
 
1982
2433
  /**
@@ -2035,9 +2486,22 @@ function cmdAgents() {
2035
2486
 
2036
2487
  if (agentSessions.length === 0) {
2037
2488
  console.log("No agents running");
2489
+ // Still check for orphans
2490
+ const orphans = findOrphanedProcesses();
2491
+ if (orphans.length > 0) {
2492
+ console.log(`\nOrphaned (${orphans.length}):`);
2493
+ for (const { pid, command } of orphans) {
2494
+ console.log(` PID ${pid}: ${command}`);
2495
+ }
2496
+ console.log(`\n Run 'ax kill --orphans' to clean up`);
2497
+ }
2038
2498
  return;
2039
2499
  }
2040
2500
 
2501
+ // Get default session for each agent type
2502
+ const claudeDefault = ClaudeAgent.getDefaultSession();
2503
+ const codexDefault = CodexAgent.getDefaultSession();
2504
+
2041
2505
  // Get info for each agent
2042
2506
  const agents = agentSessions.map((session) => {
2043
2507
  const parsed = /** @type {ParsedSession} */ (parseSessionName(session));
@@ -2046,30 +2510,45 @@ function cmdAgents() {
2046
2510
  const state = agent.getState(screen);
2047
2511
  const logPath = agent.findLogPath(session);
2048
2512
  const type = parsed.archangelName ? "archangel" : "-";
2513
+ const isDefault =
2514
+ (parsed.tool === "claude" && session === claudeDefault) ||
2515
+ (parsed.tool === "codex" && session === codexDefault);
2049
2516
 
2050
2517
  return {
2051
2518
  session,
2052
2519
  tool: parsed.tool,
2053
2520
  state: state || "unknown",
2521
+ target: isDefault ? "*" : "",
2054
2522
  type,
2055
2523
  log: logPath || "-",
2056
2524
  };
2057
2525
  });
2058
2526
 
2059
- // Print table
2527
+ // Print sessions table
2060
2528
  const maxSession = Math.max(7, ...agents.map((a) => a.session.length));
2061
2529
  const maxTool = Math.max(4, ...agents.map((a) => a.tool.length));
2062
2530
  const maxState = Math.max(5, ...agents.map((a) => a.state.length));
2531
+ const maxTarget = Math.max(6, ...agents.map((a) => a.target.length));
2063
2532
  const maxType = Math.max(4, ...agents.map((a) => a.type.length));
2064
2533
 
2065
2534
  console.log(
2066
- `${"SESSION".padEnd(maxSession)} ${"TOOL".padEnd(maxTool)} ${"STATE".padEnd(maxState)} ${"TYPE".padEnd(maxType)} LOG`
2535
+ `${"SESSION".padEnd(maxSession)} ${"TOOL".padEnd(maxTool)} ${"STATE".padEnd(maxState)} ${"TARGET".padEnd(maxTarget)} ${"TYPE".padEnd(maxType)} LOG`,
2067
2536
  );
2068
2537
  for (const a of agents) {
2069
2538
  console.log(
2070
- `${a.session.padEnd(maxSession)} ${a.tool.padEnd(maxTool)} ${a.state.padEnd(maxState)} ${a.type.padEnd(maxType)} ${a.log}`
2539
+ `${a.session.padEnd(maxSession)} ${a.tool.padEnd(maxTool)} ${a.state.padEnd(maxState)} ${a.target.padEnd(maxTarget)} ${a.type.padEnd(maxType)} ${a.log}`,
2071
2540
  );
2072
2541
  }
2542
+
2543
+ // Print orphaned processes if any
2544
+ const orphans = findOrphanedProcesses();
2545
+ if (orphans.length > 0) {
2546
+ console.log(`\nOrphaned (${orphans.length}):`);
2547
+ for (const { pid, command } of orphans) {
2548
+ console.log(` PID ${pid}: ${command}`);
2549
+ }
2550
+ console.log(`\n Run 'ax kill --orphans' to clean up`);
2551
+ }
2073
2552
  }
2074
2553
 
2075
2554
  // =============================================================================
@@ -2114,7 +2593,9 @@ function startArchangel(config, parentSession = null) {
2114
2593
  env,
2115
2594
  });
2116
2595
  child.unref();
2117
- console.log(`Summoning: ${config.name} (pid ${child.pid})${parentSession ? ` [parent: ${parentSession.session}]` : ""}`);
2596
+ console.log(
2597
+ `Summoning: ${config.name} (pid ${child.pid})${parentSession ? ` [parent: ${parentSession.session}]` : ""}`,
2598
+ );
2118
2599
  }
2119
2600
 
2120
2601
  // =============================================================================
@@ -2150,7 +2631,9 @@ async function cmdArchangel(agentName) {
2150
2631
  // Check agent CLI is installed before trying to start
2151
2632
  const cliCheck = spawnSync("which", [agent.name], { encoding: "utf-8" });
2152
2633
  if (cliCheck.status !== 0) {
2153
- console.error(`[archangel:${agentName}] ERROR: ${agent.name} CLI is not installed or not in PATH`);
2634
+ console.error(
2635
+ `[archangel:${agentName}] ERROR: ${agent.name} CLI is not installed or not in PATH`,
2636
+ );
2154
2637
  process.exit(1);
2155
2638
  }
2156
2639
 
@@ -2212,7 +2695,7 @@ async function cmdArchangel(agentName) {
2212
2695
  isProcessing = true;
2213
2696
 
2214
2697
  const files = [...changedFiles];
2215
- changedFiles = new Set(); // atomic swap to avoid losing changes during processing
2698
+ changedFiles = new Set(); // atomic swap to avoid losing changes during processing
2216
2699
 
2217
2700
  try {
2218
2701
  // Get parent session log path for JSONL extraction
@@ -2221,7 +2704,8 @@ async function cmdArchangel(agentName) {
2221
2704
 
2222
2705
  // Build file-specific context from JSONL
2223
2706
  const fileContexts = [];
2224
- for (const file of files.slice(0, 5)) { // Limit to 5 files
2707
+ for (const file of files.slice(0, 5)) {
2708
+ // Limit to 5 files
2225
2709
  const ctx = extractFileEditContext(logPath, file);
2226
2710
  if (ctx) {
2227
2711
  fileContexts.push({ file, ...ctx });
@@ -2248,26 +2732,34 @@ async function cmdArchangel(agentName) {
2248
2732
  }
2249
2733
 
2250
2734
  if (ctx.readsBefore.length > 0) {
2251
- const reads = ctx.readsBefore.map(f => f.split("/").pop()).join(", ");
2735
+ const reads = ctx.readsBefore.map((f) => f.split("/").pop()).join(", ");
2252
2736
  prompt += `**Files read before:** ${reads}\n`;
2253
2737
  }
2254
2738
  }
2255
2739
 
2256
2740
  prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
2257
2741
 
2258
- const gitContext = buildGitContext(ARCHANGEL_GIT_CONTEXT_HOURS, ARCHANGEL_GIT_CONTEXT_MAX_LINES);
2742
+ const gitContext = buildGitContext(
2743
+ ARCHANGEL_GIT_CONTEXT_HOURS,
2744
+ ARCHANGEL_GIT_CONTEXT_MAX_LINES,
2745
+ );
2259
2746
  if (gitContext) {
2260
2747
  prompt += "\n\n## Git Context\n\n" + gitContext;
2261
2748
  }
2262
2749
 
2263
- 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."';
2750
+ prompt +=
2751
+ '\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."';
2264
2752
  } else {
2265
2753
  // Fallback: no JSONL context available, use conversation + git context
2266
2754
  const parentContext = getParentSessionContext(ARCHANGEL_PARENT_CONTEXT_ENTRIES);
2267
- const gitContext = buildGitContext(ARCHANGEL_GIT_CONTEXT_HOURS, ARCHANGEL_GIT_CONTEXT_MAX_LINES);
2755
+ const gitContext = buildGitContext(
2756
+ ARCHANGEL_GIT_CONTEXT_HOURS,
2757
+ ARCHANGEL_GIT_CONTEXT_MAX_LINES,
2758
+ );
2268
2759
 
2269
2760
  if (parentContext) {
2270
- prompt += "\n\n## Main Session Context\n\nThe user is currently working on:\n\n" + parentContext;
2761
+ prompt +=
2762
+ "\n\n## Main Session Context\n\nThe user is currently working on:\n\n" + parentContext;
2271
2763
  }
2272
2764
 
2273
2765
  prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
@@ -2276,10 +2768,10 @@ async function cmdArchangel(agentName) {
2276
2768
  prompt += "\n\n## Git Context\n\n" + gitContext;
2277
2769
  }
2278
2770
 
2279
- 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."';
2771
+ prompt +=
2772
+ '\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."';
2280
2773
  }
2281
2774
 
2282
-
2283
2775
  // Check session still exists
2284
2776
  if (!tmuxHasSession(sessionName)) {
2285
2777
  console.log(`[archangel:${agentName}] Session gone, exiting`);
@@ -2308,22 +2800,30 @@ async function cmdArchangel(agentName) {
2308
2800
  await sleep(100); // Ensure Enter is processed
2309
2801
 
2310
2802
  // Wait for response
2311
- const { state: endState, screen: afterScreen } = await waitForResponse(agent, sessionName, ARCHANGEL_RESPONSE_TIMEOUT_MS);
2803
+ const { state: endState, screen: afterScreen } = await waitForResponse(
2804
+ agent,
2805
+ sessionName,
2806
+ ARCHANGEL_RESPONSE_TIMEOUT_MS,
2807
+ );
2312
2808
 
2313
2809
  if (endState === State.RATE_LIMITED) {
2314
2810
  console.error(`[archangel:${agentName}] Rate limited - stopping`);
2315
2811
  process.exit(2);
2316
2812
  }
2317
2813
 
2318
-
2319
2814
  const cleanedResponse = agent.getResponse(sessionName, afterScreen) || "";
2320
2815
 
2321
2816
  // Sanity check: skip garbage responses (screen scraping artifacts)
2322
- const isGarbage = cleanedResponse.includes("[Pasted text") ||
2817
+ const isGarbage =
2818
+ cleanedResponse.includes("[Pasted text") ||
2323
2819
  cleanedResponse.match(/^\+\d+ lines\]/) ||
2324
2820
  cleanedResponse.length < 20;
2325
2821
 
2326
- if (cleanedResponse && !isGarbage && !cleanedResponse.toLowerCase().includes("no issues found")) {
2822
+ if (
2823
+ cleanedResponse &&
2824
+ !isGarbage &&
2825
+ !cleanedResponse.toLowerCase().includes("no issues found")
2826
+ ) {
2327
2827
  writeToMailbox({
2328
2828
  agent: /** @type {string} */ (agentName),
2329
2829
  session: sessionName,
@@ -2345,7 +2845,10 @@ async function cmdArchangel(agentName) {
2345
2845
 
2346
2846
  function scheduleProcessChanges() {
2347
2847
  processChanges().catch((err) => {
2348
- console.error(`[archangel:${agentName}] Unhandled error:`, err instanceof Error ? err.message : err);
2848
+ console.error(
2849
+ `[archangel:${agentName}] Unhandled error:`,
2850
+ err instanceof Error ? err.message : err,
2851
+ );
2349
2852
  });
2350
2853
  }
2351
2854
 
@@ -2499,7 +3002,7 @@ async function cmdRecall(name = null) {
2499
3002
  }
2500
3003
 
2501
3004
  // Version of the hook script template - bump when making changes
2502
- const HOOK_SCRIPT_VERSION = "3";
3005
+ const HOOK_SCRIPT_VERSION = "4";
2503
3006
 
2504
3007
  function ensureMailboxHookScript() {
2505
3008
  const hooksDir = HOOKS_DIR;
@@ -2522,24 +3025,49 @@ ${versionMarker}
2522
3025
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
2523
3026
  import { dirname, join } from "node:path";
2524
3027
  import { fileURLToPath } from "node:url";
3028
+ import { createHash } from "node:crypto";
2525
3029
 
2526
3030
  const __dirname = dirname(fileURLToPath(import.meta.url));
2527
3031
  const AI_DIR = join(__dirname, "..");
2528
3032
  const DEBUG = process.env.AX_DEBUG === "1";
2529
3033
  const MAILBOX = join(AI_DIR, "mailbox.jsonl");
2530
- const LAST_SEEN = join(AI_DIR, "mailbox-last-seen");
2531
3034
  const MAX_AGE_MS = 60 * 60 * 1000;
2532
3035
 
3036
+ // Read hook input from stdin
3037
+ let hookInput = {};
3038
+ try {
3039
+ const stdinData = readFileSync(0, "utf-8").trim();
3040
+ if (stdinData) hookInput = JSON.parse(stdinData);
3041
+ } catch (err) {
3042
+ if (DEBUG) console.error("[hook] stdin parse:", err.message);
3043
+ }
3044
+
3045
+ const sessionId = hookInput.session_id || "";
3046
+ const hookEvent = hookInput.hook_event_name || "";
3047
+
3048
+ if (DEBUG) console.error("[hook] session:", sessionId, "event:", hookEvent);
3049
+
3050
+ // NO-OP for archangel or partner sessions
3051
+ if (sessionId.includes("-archangel-") || sessionId.includes("-partner-")) {
3052
+ if (DEBUG) console.error("[hook] skipping non-parent session");
3053
+ process.exit(0);
3054
+ }
3055
+
3056
+ // Per-session last-seen tracking (single JSON file, self-cleaning)
3057
+ const sessionHash = sessionId ? createHash("md5").update(sessionId).digest("hex").slice(0, 8) : "default";
3058
+ const LAST_SEEN_FILE = join(AI_DIR, "mailbox-last-seen.json");
3059
+
2533
3060
  if (!existsSync(MAILBOX)) process.exit(0);
2534
3061
 
2535
- let lastSeen = 0;
3062
+ let lastSeenMap = {};
2536
3063
  try {
2537
- if (existsSync(LAST_SEEN)) {
2538
- lastSeen = parseInt(readFileSync(LAST_SEEN, "utf-8").trim(), 10) || 0;
3064
+ if (existsSync(LAST_SEEN_FILE)) {
3065
+ lastSeenMap = JSON.parse(readFileSync(LAST_SEEN_FILE, "utf-8"));
2539
3066
  }
2540
3067
  } catch (err) {
2541
3068
  if (DEBUG) console.error("[hook] readLastSeen:", err.message);
2542
3069
  }
3070
+ const lastSeen = lastSeenMap[sessionHash] || 0;
2543
3071
 
2544
3072
  const now = Date.now();
2545
3073
  const lines = readFileSync(MAILBOX, "utf-8").trim().split("\\n").filter(Boolean);
@@ -2561,21 +3089,39 @@ for (const line of lines) {
2561
3089
  }
2562
3090
 
2563
3091
  if (relevant.length > 0) {
2564
- console.log("## Background Agents");
2565
- console.log("");
2566
- console.log("Background agents watching your files found:");
2567
- console.log("");
2568
3092
  const sessionPrefixes = new Set();
3093
+ let messageLines = [];
3094
+ messageLines.push("## Background Agents");
3095
+ messageLines.push("");
3096
+ messageLines.push("Background agents watching your files found:");
3097
+ messageLines.push("");
2569
3098
  for (const { agent, sessionPrefix, message } of relevant) {
2570
3099
  if (sessionPrefix) sessionPrefixes.add(sessionPrefix);
2571
- console.log("**[" + agent + "]**");
2572
- console.log("");
2573
- console.log(message);
2574
- console.log("");
3100
+ messageLines.push("**[" + agent + "]**");
3101
+ messageLines.push("");
3102
+ messageLines.push(message);
3103
+ messageLines.push("");
2575
3104
  }
2576
3105
  const sessionList = [...sessionPrefixes].map(s => "\\\`./ax.js log " + s + "\\\`").join(" or ");
2577
- console.log("> For more context: \\\`./ax.js mailbox\\\`" + (sessionList ? " or " + sessionList : ""));
2578
- writeFileSync(LAST_SEEN, now.toString());
3106
+ messageLines.push("> For more context: \\\`./ax.js mailbox\\\`" + (sessionList ? " or " + sessionList : ""));
3107
+
3108
+ const formattedMessage = messageLines.join("\\n");
3109
+
3110
+ // For Stop hook, return blocking JSON to force acknowledgment
3111
+ if (hookEvent === "Stop") {
3112
+ console.log(JSON.stringify({ decision: "block", reason: formattedMessage }));
3113
+ } else {
3114
+ // For other hooks, just output the context
3115
+ console.log(formattedMessage);
3116
+ }
3117
+
3118
+ // Update last-seen and prune entries older than 24 hours
3119
+ const PRUNE_AGE_MS = 24 * 60 * 60 * 1000;
3120
+ lastSeenMap[sessionHash] = now;
3121
+ for (const key of Object.keys(lastSeenMap)) {
3122
+ if (now - lastSeenMap[key] > PRUNE_AGE_MS) delete lastSeenMap[key];
3123
+ }
3124
+ writeFileSync(LAST_SEEN_FILE, JSON.stringify(lastSeenMap));
2579
3125
  }
2580
3126
 
2581
3127
  process.exit(0);
@@ -2590,18 +3136,9 @@ process.exit(0);
2590
3136
  console.log(`\nTo enable manually, add to .claude/settings.json:\n`);
2591
3137
  console.log(`{
2592
3138
  "hooks": {
2593
- "UserPromptSubmit": [
2594
- {
2595
- "matcher": "",
2596
- "hooks": [
2597
- {
2598
- "type": "command",
2599
- "command": "node .ai/hooks/mailbox-inject.js",
2600
- "timeout": 5
2601
- }
2602
- ]
2603
- }
2604
- ]
3139
+ "UserPromptSubmit": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }],
3140
+ "PostToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }],
3141
+ "Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }]
2605
3142
  }
2606
3143
  }`);
2607
3144
  }
@@ -2611,6 +3148,7 @@ function ensureClaudeHookConfig() {
2611
3148
  const settingsDir = ".claude";
2612
3149
  const settingsPath = path.join(settingsDir, "settings.json");
2613
3150
  const hookCommand = "node .ai/hooks/mailbox-inject.js";
3151
+ const hookEvents = ["UserPromptSubmit", "PostToolUse", "Stop"];
2614
3152
 
2615
3153
  try {
2616
3154
  /** @type {ClaudeSettings} */
@@ -2629,33 +3167,41 @@ function ensureClaudeHookConfig() {
2629
3167
 
2630
3168
  // Ensure hooks structure exists
2631
3169
  if (!settings.hooks) settings.hooks = {};
2632
- if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
2633
-
2634
- // Check if our hook is already configured
2635
- const hookExists = settings.hooks.UserPromptSubmit.some(
2636
- /** @param {{hooks?: Array<{command: string}>}} entry */
2637
- (entry) => entry.hooks?.some(/** @param {{command: string}} h */ (h) => h.command === hookCommand)
2638
- );
2639
3170
 
2640
- if (hookExists) {
2641
- return true; // Already configured
3171
+ let anyAdded = false;
3172
+
3173
+ for (const eventName of hookEvents) {
3174
+ if (!settings.hooks[eventName]) settings.hooks[eventName] = [];
3175
+
3176
+ // Check if our hook is already configured for this event
3177
+ const hookExists = settings.hooks[eventName].some(
3178
+ /** @param {{hooks?: Array<{command: string}>}} entry */
3179
+ (entry) =>
3180
+ entry.hooks?.some(/** @param {{command: string}} h */ (h) => h.command === hookCommand),
3181
+ );
3182
+
3183
+ if (!hookExists) {
3184
+ // Add the hook for this event
3185
+ settings.hooks[eventName].push({
3186
+ matcher: "",
3187
+ hooks: [
3188
+ {
3189
+ type: "command",
3190
+ command: hookCommand,
3191
+ timeout: 5,
3192
+ },
3193
+ ],
3194
+ });
3195
+ anyAdded = true;
3196
+ }
2642
3197
  }
2643
3198
 
2644
- // Add the hook
2645
- settings.hooks.UserPromptSubmit.push({
2646
- matcher: "",
2647
- hooks: [
2648
- {
2649
- type: "command",
2650
- command: hookCommand,
2651
- timeout: 5,
2652
- },
2653
- ],
2654
- });
3199
+ if (anyAdded) {
3200
+ // Write settings
3201
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
3202
+ console.log(`Configured hooks in: ${settingsPath}`);
3203
+ }
2655
3204
 
2656
- // Write settings
2657
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2658
- console.log(`Configured hook in: ${settingsPath}`);
2659
3205
  return true;
2660
3206
  } catch {
2661
3207
  // If we can't configure automatically, return false so manual instructions are shown
@@ -2665,9 +3211,31 @@ function ensureClaudeHookConfig() {
2665
3211
 
2666
3212
  /**
2667
3213
  * @param {string | null | undefined} session
2668
- * @param {{all?: boolean}} [options]
3214
+ * @param {{all?: boolean, orphans?: boolean, force?: boolean}} [options]
2669
3215
  */
2670
- function cmdKill(session, { all = false } = {}) {
3216
+ function cmdKill(session, { all = false, orphans = false, force = false } = {}) {
3217
+ // Handle orphaned processes
3218
+ if (orphans) {
3219
+ const orphanedProcesses = findOrphanedProcesses();
3220
+
3221
+ if (orphanedProcesses.length === 0) {
3222
+ console.log("No orphaned processes found");
3223
+ return;
3224
+ }
3225
+
3226
+ const signal = force ? "-9" : "-15"; // SIGKILL vs SIGTERM
3227
+ let killed = 0;
3228
+ for (const { pid, command } of orphanedProcesses) {
3229
+ const result = spawnSync("kill", [signal, pid]);
3230
+ if (result.status === 0) {
3231
+ console.log(`Killed: PID ${pid} (${command.slice(0, 40)})`);
3232
+ killed++;
3233
+ }
3234
+ }
3235
+ console.log(`Killed ${killed} orphaned process(es)${force ? " (forced)" : ""}`);
3236
+ return;
3237
+ }
3238
+
2671
3239
  // If specific session provided, kill just that one
2672
3240
  if (session) {
2673
3241
  if (!tmuxHasSession(session)) {
@@ -2727,7 +3295,9 @@ function cmdAttach(session) {
2727
3295
  }
2728
3296
 
2729
3297
  // Hand over to tmux attach
2730
- const result = spawnSync("tmux", ["attach", "-t", resolved], { stdio: "inherit" });
3298
+ const result = spawnSync("tmux", ["attach", "-t", resolved], {
3299
+ stdio: "inherit",
3300
+ });
2731
3301
  process.exit(result.status || 0);
2732
3302
  }
2733
3303
 
@@ -2786,13 +3356,15 @@ function cmdLog(sessionName, { tail = 50, reasoning = false, follow = false } =
2786
3356
 
2787
3357
  if (newLines.length === 0) return;
2788
3358
 
2789
- const entries = newLines.map((line) => {
2790
- try {
2791
- return JSON.parse(line);
2792
- } catch {
2793
- return null;
2794
- }
2795
- }).filter(Boolean);
3359
+ const entries = newLines
3360
+ .map((line) => {
3361
+ try {
3362
+ return JSON.parse(line);
3363
+ } catch {
3364
+ return null;
3365
+ }
3366
+ })
3367
+ .filter(Boolean);
2796
3368
 
2797
3369
  const output = [];
2798
3370
  if (isInitial) {
@@ -2805,7 +3377,10 @@ function cmdLog(sessionName, { tail = 50, reasoning = false, follow = false } =
2805
3377
  const ts = entry.timestamp || entry.ts || entry.createdAt;
2806
3378
  if (ts && ts !== lastTimestamp) {
2807
3379
  const date = new Date(ts);
2808
- const timeStr = date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
3380
+ const timeStr = date.toLocaleTimeString("en-GB", {
3381
+ hour: "2-digit",
3382
+ minute: "2-digit",
3383
+ });
2809
3384
  if (formatted.isUserMessage) {
2810
3385
  output.push(`\n### ${timeStr}\n`);
2811
3386
  }
@@ -2855,7 +3430,10 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2855
3430
  if (type === "user" || type === "human") {
2856
3431
  const text = extractTextContent(content);
2857
3432
  if (text) {
2858
- return { text: `**User**: ${truncate(text, TRUNCATE_USER_LEN)}\n`, isUserMessage: true };
3433
+ return {
3434
+ text: `**User**: ${truncate(text, TRUNCATE_USER_LEN)}\n`,
3435
+ isUserMessage: true,
3436
+ };
2859
3437
  }
2860
3438
  }
2861
3439
 
@@ -2871,10 +3449,12 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2871
3449
  // Extract tool calls (compressed)
2872
3450
  const tools = extractToolCalls(content);
2873
3451
  if (tools.length > 0) {
2874
- const toolSummary = tools.map((t) => {
2875
- if (t.error) return `${t.name}(${t.target}) ✗`;
2876
- return `${t.name}(${t.target})`;
2877
- }).join(", ");
3452
+ const toolSummary = tools
3453
+ .map((t) => {
3454
+ if (t.error) return `${t.name}(${t.target}) ✗`;
3455
+ return `${t.name}(${t.target})`;
3456
+ })
3457
+ .join(", ");
2878
3458
  parts.push(`> ${toolSummary}\n`);
2879
3459
  }
2880
3460
 
@@ -2896,7 +3476,10 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2896
3476
  const error = entry.error || entry.is_error;
2897
3477
  if (error) {
2898
3478
  const name = entry.tool_name || entry.name || "tool";
2899
- return { text: `> ${name} ✗ (${truncate(String(error), 100)})\n`, isUserMessage: false };
3479
+ return {
3480
+ text: `> ${name} ✗ (${truncate(String(error), 100)})\n`,
3481
+ isUserMessage: false,
3482
+ };
2900
3483
  }
2901
3484
  }
2902
3485
 
@@ -2933,7 +3516,8 @@ function extractToolCalls(content) {
2933
3516
  const name = c.name || c.tool || "tool";
2934
3517
  const input = c.input || c.arguments || {};
2935
3518
  // Extract a reasonable target from the input
2936
- const target = input.file_path || input.path || input.command?.slice(0, 30) || input.pattern || "";
3519
+ const target =
3520
+ input.file_path || input.path || input.command?.slice(0, 30) || input.pattern || "";
2937
3521
  const shortTarget = target.split("/").pop() || target.slice(0, 20);
2938
3522
  return { name, target: shortTarget, error: c.error };
2939
3523
  });
@@ -2978,8 +3562,14 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
2978
3562
 
2979
3563
  for (const entry of entries) {
2980
3564
  const ts = new Date(entry.timestamp);
2981
- const timeStr = ts.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
2982
- const dateStr = ts.toLocaleDateString("en-GB", { month: "short", day: "numeric" });
3565
+ const timeStr = ts.toLocaleTimeString("en-GB", {
3566
+ hour: "2-digit",
3567
+ minute: "2-digit",
3568
+ });
3569
+ const dateStr = ts.toLocaleDateString("en-GB", {
3570
+ month: "short",
3571
+ day: "numeric",
3572
+ });
2983
3573
  const p = entry.payload || {};
2984
3574
 
2985
3575
  console.log(`### [${p.agent || "unknown"}] ${dateStr} ${timeStr}\n`);
@@ -3008,7 +3598,7 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
3008
3598
  * @param {string} message
3009
3599
  * @param {{noWait?: boolean, yolo?: boolean, timeoutMs?: number}} [options]
3010
3600
  */
3011
- async function cmdAsk(agent, session, message, { noWait = false, yolo = false, timeoutMs } = {}) {
3601
+ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
3012
3602
  const sessionExists = session != null && tmuxHasSession(session);
3013
3603
  const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
3014
3604
 
@@ -3020,20 +3610,31 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3020
3610
  }
3021
3611
 
3022
3612
  /** @type {string} */
3023
- const activeSession = sessionExists ? /** @type {string} */ (session) : await cmdStart(agent, session, { yolo });
3613
+ const activeSession = sessionExists
3614
+ ? /** @type {string} */ (session)
3615
+ : await cmdStart(agent, session, { yolo });
3024
3616
 
3025
3617
  tmuxSendLiteral(activeSession, message);
3026
3618
  await sleep(50);
3027
3619
  tmuxSend(activeSession, "Enter");
3028
3620
 
3029
- if (noWait) return;
3621
+ if (noWait) {
3622
+ const parsed = parseSessionName(activeSession);
3623
+ const shortId = parsed?.uuid?.slice(0, 8) || activeSession;
3624
+ const cli = path.basename(process.argv[1], ".js");
3625
+ console.log(`Sent to: ${shortId}
3626
+
3627
+ e.g.
3628
+ ${cli} status --session=${shortId}
3629
+ ${cli} output --session=${shortId}`);
3630
+ return;
3631
+ }
3030
3632
 
3031
- // Yolo mode on a safe session: auto-approve until done
3032
3633
  const useAutoApprove = yolo && !nativeYolo;
3033
3634
 
3034
3635
  const { state, screen } = useAutoApprove
3035
- ? await autoApproveLoop(agent, activeSession, timeoutMs)
3036
- : await waitForResponse(agent, activeSession, timeoutMs);
3636
+ ? await autoApproveLoop(agent, activeSession, timeoutMs, streamResponse)
3637
+ : await streamResponse(agent, activeSession, timeoutMs);
3037
3638
 
3038
3639
  if (state === State.RATE_LIMITED) {
3039
3640
  console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
@@ -3041,14 +3642,9 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3041
3642
  }
3042
3643
 
3043
3644
  if (state === State.CONFIRMING) {
3044
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3645
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3045
3646
  process.exit(3);
3046
3647
  }
3047
-
3048
- const output = agent.getResponse(activeSession, screen);
3049
- if (output) {
3050
- console.log(output);
3051
- }
3052
3648
  }
3053
3649
 
3054
3650
  /**
@@ -3063,9 +3659,10 @@ async function cmdApprove(agent, session, { wait = false, timeoutMs } = {}) {
3063
3659
  }
3064
3660
 
3065
3661
  const before = tmuxCapture(session);
3066
- if (agent.getState(before) !== State.CONFIRMING) {
3067
- console.log("ERROR: not confirming");
3068
- process.exit(1);
3662
+ const beforeState = agent.getState(before);
3663
+ if (beforeState !== State.CONFIRMING) {
3664
+ console.log(`Already ${beforeState}`);
3665
+ return;
3069
3666
  }
3070
3667
 
3071
3668
  tmuxSend(session, agent.approveKey);
@@ -3080,7 +3677,7 @@ async function cmdApprove(agent, session, { wait = false, timeoutMs } = {}) {
3080
3677
  }
3081
3678
 
3082
3679
  if (state === State.CONFIRMING) {
3083
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3680
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3084
3681
  process.exit(3);
3085
3682
  }
3086
3683
 
@@ -3099,6 +3696,13 @@ async function cmdReject(agent, session, { wait = false, timeoutMs } = {}) {
3099
3696
  process.exit(1);
3100
3697
  }
3101
3698
 
3699
+ const before = tmuxCapture(session);
3700
+ const beforeState = agent.getState(before);
3701
+ if (beforeState !== State.CONFIRMING) {
3702
+ console.log(`Already ${beforeState}`);
3703
+ return;
3704
+ }
3705
+
3102
3706
  tmuxSend(session, agent.rejectKey);
3103
3707
 
3104
3708
  if (!wait) return;
@@ -3121,7 +3725,13 @@ async function cmdReject(agent, session, { wait = false, timeoutMs } = {}) {
3121
3725
  * @param {string | null | undefined} customInstructions
3122
3726
  * @param {{wait?: boolean, yolo?: boolean, fresh?: boolean, timeoutMs?: number}} [options]
3123
3727
  */
3124
- async function cmdReview(agent, session, option, customInstructions, { wait = true, yolo = true, fresh = false, timeoutMs = REVIEW_TIMEOUT_MS } = {}) {
3728
+ async function cmdReview(
3729
+ agent,
3730
+ session,
3731
+ option,
3732
+ customInstructions,
3733
+ { wait = true, yolo = false, fresh = false, timeoutMs = REVIEW_TIMEOUT_MS } = {},
3734
+ ) {
3125
3735
  const sessionExists = session != null && tmuxHasSession(session);
3126
3736
 
3127
3737
  // Reset conversation if --fresh and session exists
@@ -3147,7 +3757,11 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3147
3757
 
3148
3758
  // AX_REVIEW_MODE=exec: bypass /review command, send instructions directly
3149
3759
  if (process.env.AX_REVIEW_MODE === "exec" && option === "custom" && customInstructions) {
3150
- return cmdAsk(agent, session, customInstructions, { noWait: !wait, yolo, timeoutMs });
3760
+ return cmdAsk(agent, session, customInstructions, {
3761
+ noWait: !wait,
3762
+ yolo,
3763
+ timeoutMs,
3764
+ });
3151
3765
  }
3152
3766
  const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
3153
3767
 
@@ -3159,7 +3773,9 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3159
3773
  }
3160
3774
 
3161
3775
  /** @type {string} */
3162
- const activeSession = sessionExists ? /** @type {string} */ (session) : await cmdStart(agent, session, { yolo });
3776
+ const activeSession = sessionExists
3777
+ ? /** @type {string} */ (session)
3778
+ : await cmdStart(agent, session, { yolo });
3163
3779
 
3164
3780
  tmuxSendLiteral(activeSession, "/review");
3165
3781
  await sleep(50);
@@ -3181,12 +3797,11 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3181
3797
 
3182
3798
  if (!wait) return;
3183
3799
 
3184
- // Yolo mode on a safe session: auto-approve until done
3185
3800
  const useAutoApprove = yolo && !nativeYolo;
3186
3801
 
3187
3802
  const { state, screen } = useAutoApprove
3188
- ? await autoApproveLoop(agent, activeSession, timeoutMs)
3189
- : await waitForResponse(agent, activeSession, timeoutMs);
3803
+ ? await autoApproveLoop(agent, activeSession, timeoutMs, streamResponse)
3804
+ : await streamResponse(agent, activeSession, timeoutMs);
3190
3805
 
3191
3806
  if (state === State.RATE_LIMITED) {
3192
3807
  console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
@@ -3194,12 +3809,9 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3194
3809
  }
3195
3810
 
3196
3811
  if (state === State.CONFIRMING) {
3197
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3812
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3198
3813
  process.exit(3);
3199
3814
  }
3200
-
3201
- const response = agent.getResponse(activeSession, screen);
3202
- console.log(response || "");
3203
3815
  }
3204
3816
 
3205
3817
  /**
@@ -3230,7 +3842,7 @@ async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs }
3230
3842
  }
3231
3843
 
3232
3844
  if (state === State.CONFIRMING) {
3233
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3845
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3234
3846
  process.exit(3);
3235
3847
  }
3236
3848
 
@@ -3242,6 +3854,8 @@ async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs }
3242
3854
  const output = agent.getResponse(session, screen, index);
3243
3855
  if (output) {
3244
3856
  console.log(output);
3857
+ } else {
3858
+ console.log("READY_NO_CONTENT");
3245
3859
  }
3246
3860
  }
3247
3861
 
@@ -3264,7 +3878,7 @@ function cmdStatus(agent, session) {
3264
3878
  }
3265
3879
 
3266
3880
  if (state === State.CONFIRMING) {
3267
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
3881
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3268
3882
  process.exit(3);
3269
3883
  }
3270
3884
 
@@ -3272,6 +3886,10 @@ function cmdStatus(agent, session) {
3272
3886
  console.log("THINKING");
3273
3887
  process.exit(4);
3274
3888
  }
3889
+
3890
+ // READY (or STARTING/UPDATE_PROMPT which are transient)
3891
+ console.log("READY");
3892
+ process.exit(0);
3275
3893
  }
3276
3894
 
3277
3895
  /**
@@ -3385,7 +4003,7 @@ async function cmdSelect(agent, session, n, { wait = false, timeoutMs } = {}) {
3385
4003
  }
3386
4004
 
3387
4005
  if (state === State.CONFIRMING) {
3388
- console.log(`CONFIRM: ${agent.parseAction(screen)}`);
4006
+ console.log(`CONFIRM: ${formatConfirmationOutput(screen, agent)}`);
3389
4007
  process.exit(3);
3390
4008
  }
3391
4009
 
@@ -3420,23 +4038,30 @@ function getAgentFromInvocation() {
3420
4038
  */
3421
4039
  function printHelp(agent, cliName) {
3422
4040
  const name = cliName;
3423
- const backendName = agent.name === "codex" ? "OpenAI Codex" : "Claude";
4041
+ const backendName = agent.displayName;
3424
4042
  const hasReview = !!agent.reviewOptions;
3425
4043
 
3426
4044
  console.log(`${name} v${VERSION} - agentic assistant CLI (${backendName})
3427
4045
 
4046
+ Usage: ${name} [OPTIONS] <command|message> [ARGS...]
4047
+
3428
4048
  Commands:
3429
4049
  agents List all running agents with state and log paths
4050
+ target Show default target session for current tool
3430
4051
  attach [SESSION] Attach to agent session interactively
3431
4052
  log SESSION View conversation log (--tail=N, --follow, --reasoning)
3432
4053
  mailbox View archangel observations (--limit=N, --branch=X, --all)
3433
4054
  summon [name] Summon archangels (all, or by name)
3434
4055
  recall [name] Recall archangels (all, or by name)
3435
- kill Kill sessions in current project (--all for all, --session=NAME for one)
4056
+ kill Kill sessions (--all, --session=NAME, --orphans [--force])
3436
4057
  status Check state (exit: 0=ready, 2=rate_limited, 3=confirming, 4=thinking)
3437
4058
  output [-N] Show response (0=last, -1=prev, -2=older)
3438
- debug Show raw screen output and detected state${hasReview ? `
3439
- review [TYPE] Review code: pr, uncommitted, commit, custom` : ""}
4059
+ debug Show raw screen output and detected state${
4060
+ hasReview
4061
+ ? `
4062
+ review [TYPE] Review code: pr, uncommitted, commit, custom`
4063
+ : ""
4064
+ }
3440
4065
  select N Select menu option N
3441
4066
  approve Approve pending action (send 'y')
3442
4067
  reject Reject pending action (send 'n')
@@ -3448,37 +4073,41 @@ Commands:
3448
4073
  Flags:
3449
4074
  --tool=NAME Use specific agent (codex, claude)
3450
4075
  --session=NAME Target session by name, archangel name, or UUID prefix (self = current)
3451
- --wait Wait for response (for review, approve, etc)
3452
- --no-wait Don't wait (for messages, which wait by default)
3453
- --timeout=N Set timeout in seconds (default: 120)
4076
+ --wait Wait for response (default for messages; required for approve/reject)
4077
+ --no-wait Fire-and-forget: send message, print session ID, exit immediately
4078
+ --timeout=N Set timeout in seconds (default: ${DEFAULT_TIMEOUT_MS / 1000}, reviews: ${REVIEW_TIMEOUT_MS / 1000})
3454
4079
  --yolo Skip all confirmations (dangerous)
3455
4080
  --fresh Reset conversation before review
4081
+ --orphans Kill orphaned claude/codex processes (PPID=1)
4082
+ --force Use SIGKILL instead of SIGTERM (with --orphans)
3456
4083
 
3457
4084
  Environment:
3458
- AX_DEFAULT_TOOL Default agent when using 'ax' (claude or codex, default: codex)
3459
- ${agent.envVar} Override default session name
3460
- AX_CLAUDE_CONFIG_DIR Override Claude config directory (default: ~/.claude)
3461
- AX_CODEX_CONFIG_DIR Override Codex config directory (default: ~/.codex)
3462
- AX_REVIEW_MODE=exec Bypass /review, send instructions directly (codex only)
3463
- AX_DEBUG=1 Enable debug logging
4085
+ AX_DEFAULT_TOOL Default agent when using 'ax' (claude or codex, default: codex)
4086
+ ${agent.envVar} Override default session name
4087
+ AX_CLAUDE_CONFIG_DIR Override Claude config directory (default: ~/.claude)
4088
+ AX_CODEX_CONFIG_DIR Override Codex config directory (default: ~/.codex)
4089
+ AX_REVIEW_MODE=exec Bypass /review, send instructions directly (codex only)
4090
+ AX_DEBUG=1 Enable debug logging
3464
4091
 
3465
4092
  Examples:
3466
- ./${name}.js "explain this codebase"
3467
- ./${name}.js "please review the error handling" # Auto custom review
3468
- ./${name}.js review uncommitted --wait
3469
- ./${name}.js approve --wait
3470
- ./${name}.js kill # Kill agents in current project
3471
- ./${name}.js kill --all # Kill all agents across all projects
3472
- ./${name}.js kill --session=NAME # Kill specific session
3473
- ./${name}.js send "1[Enter]" # Recovery: select option 1 and press Enter
3474
- ./${name}.js send "[Escape][Escape]" # Recovery: escape out of a dialog
3475
-
3476
- Archangels:
3477
- ./${name}.js summon # Summon all archangels from .ai/agents/*.md
3478
- ./${name}.js summon reviewer # Summon by name (creates config if new)
3479
- ./${name}.js recall # Recall all archangels
3480
- ./${name}.js recall reviewer # Recall one by name
3481
- ./${name}.js agents # List all agents (shows TYPE=archangel)`);
4093
+ ${name} "explain this codebase"
4094
+ ${name} "review the error handling" # Auto custom review (${REVIEW_TIMEOUT_MS / 60000}min timeout)
4095
+ ${name} "FYI: auth was refactored" --no-wait # Send context to a working session (no response needed)
4096
+ ${name} review uncommitted --wait
4097
+ ${name} approve --wait
4098
+ ${name} kill # Kill agents in current project
4099
+ ${name} kill --all # Kill all agents across all projects
4100
+ ${name} kill --session=NAME # Kill specific session
4101
+ ${name} send "1[Enter]" # Recovery: select option 1 and press Enter
4102
+ ${name} send "[Escape][Escape]" # Recovery: escape out of a dialog
4103
+ ${name} summon # Summon all archangels from .ai/agents/*.md
4104
+ ${name} summon reviewer # Summon by name (creates config if new)
4105
+ ${name} recall # Recall all archangels
4106
+ ${name} recall reviewer # Recall one by name
4107
+ ${name} agents # List all agents (shows TYPE=archangel)
4108
+
4109
+ Note: Reviews and complex tasks may take several minutes.
4110
+ Use Bash run_in_background for long operations (not --no-wait).`);
3482
4111
  }
3483
4112
 
3484
4113
  async function main() {
@@ -3493,38 +4122,32 @@ async function main() {
3493
4122
  const args = process.argv.slice(2);
3494
4123
  const cliName = path.basename(process.argv[1], ".js");
3495
4124
 
3496
- if (args.includes("--version") || args.includes("-V")) {
4125
+ // Parse all flags and positionals in one place
4126
+ const { flags, positionals } = parseCliArgs(args);
4127
+
4128
+ if (flags.version) {
3497
4129
  console.log(VERSION);
3498
4130
  process.exit(0);
3499
4131
  }
3500
4132
 
3501
- // Parse flags
3502
- const wait = args.includes("--wait");
3503
- const noWait = args.includes("--no-wait");
3504
- const yolo = args.includes("--yolo");
3505
- const fresh = args.includes("--fresh");
3506
- const reasoning = args.includes("--reasoning");
3507
- const follow = args.includes("--follow") || args.includes("-f");
4133
+ // Extract flags into local variables for convenience
4134
+ const { wait, noWait, yolo, fresh, reasoning, follow, all, orphans, force } = flags;
3508
4135
 
3509
4136
  // Agent selection
3510
4137
  let agent = getAgentFromInvocation();
3511
- const toolArg = args.find((a) => a.startsWith("--tool="));
3512
- if (toolArg) {
3513
- const tool = toolArg.split("=")[1];
3514
- if (tool === "claude") agent = ClaudeAgent;
3515
- else if (tool === "codex") agent = CodexAgent;
4138
+ if (flags.tool) {
4139
+ if (flags.tool === "claude") agent = ClaudeAgent;
4140
+ else if (flags.tool === "codex") agent = CodexAgent;
3516
4141
  else {
3517
- console.log(`ERROR: unknown tool '${tool}'`);
4142
+ console.log(`ERROR: unknown tool '${flags.tool}'`);
3518
4143
  process.exit(1);
3519
4144
  }
3520
4145
  }
3521
4146
 
3522
4147
  // Session resolution
3523
4148
  let session = agent.getDefaultSession();
3524
- const sessionArg = args.find((a) => a.startsWith("--session="));
3525
- if (sessionArg) {
3526
- const val = sessionArg.split("=")[1];
3527
- if (val === "self") {
4149
+ if (flags.session) {
4150
+ if (flags.session === "self") {
3528
4151
  const current = tmuxCurrentSession();
3529
4152
  if (!current) {
3530
4153
  console.log("ERROR: --session=self requires running inside tmux");
@@ -3533,99 +4156,93 @@ async function main() {
3533
4156
  session = current;
3534
4157
  } else {
3535
4158
  // Resolve partial names, archangel names, and UUID prefixes
3536
- session = resolveSessionName(val);
4159
+ session = resolveSessionName(flags.session);
3537
4160
  }
3538
4161
  }
3539
4162
 
3540
- // Timeout
4163
+ // Timeout (convert seconds to milliseconds)
3541
4164
  let timeoutMs = DEFAULT_TIMEOUT_MS;
3542
- const timeoutArg = args.find((a) => a.startsWith("--timeout="));
3543
- if (timeoutArg) {
3544
- const val = parseInt(timeoutArg.split("=")[1], 10);
3545
- if (isNaN(val) || val <= 0) {
4165
+ if (flags.timeout !== undefined) {
4166
+ if (isNaN(flags.timeout) || flags.timeout <= 0) {
3546
4167
  console.log("ERROR: invalid timeout");
3547
4168
  process.exit(1);
3548
4169
  }
3549
- timeoutMs = val * 1000;
4170
+ timeoutMs = flags.timeout * 1000;
3550
4171
  }
3551
4172
 
3552
4173
  // Tail (for log command)
3553
- let tail = 50;
3554
- const tailArg = args.find((a) => a.startsWith("--tail="));
3555
- if (tailArg) {
3556
- tail = parseInt(tailArg.split("=")[1], 10) || 50;
3557
- }
4174
+ const tail = flags.tail ?? 50;
3558
4175
 
3559
4176
  // Limit (for mailbox command)
3560
- let limit = 20;
3561
- const limitArg = args.find((a) => a.startsWith("--limit="));
3562
- if (limitArg) {
3563
- limit = parseInt(limitArg.split("=")[1], 10) || 20;
3564
- }
4177
+ const limit = flags.limit ?? 20;
3565
4178
 
3566
4179
  // Branch filter (for mailbox command)
3567
- let branch = null;
3568
- const branchArg = args.find((a) => a.startsWith("--branch="));
3569
- if (branchArg) {
3570
- branch = branchArg.split("=")[1] || null;
3571
- }
3572
-
3573
- // All flag (for mailbox command - show all regardless of age)
3574
- const all = args.includes("--all");
3575
-
3576
- // Filter out flags
3577
- const filteredArgs = args.filter(
3578
- (a) =>
3579
- !["--wait", "--no-wait", "--yolo", "--reasoning", "--follow", "-f", "--all"].includes(a) &&
3580
- !a.startsWith("--timeout") &&
3581
- !a.startsWith("--session") &&
3582
- !a.startsWith("--tool") &&
3583
- !a.startsWith("--tail") &&
3584
- !a.startsWith("--limit") &&
3585
- !a.startsWith("--branch")
3586
- );
3587
- const cmd = filteredArgs[0];
4180
+ const branch = flags.branch ?? null;
4181
+
4182
+ // Command is first positional
4183
+ const cmd = positionals[0];
3588
4184
 
3589
4185
  // Dispatch commands
3590
4186
  if (cmd === "agents") return cmdAgents();
3591
- if (cmd === "summon") return cmdSummon(filteredArgs[1]);
3592
- if (cmd === "recall") return cmdRecall(filteredArgs[1]);
3593
- if (cmd === "archangel") return cmdArchangel(filteredArgs[1]);
3594
- if (cmd === "kill") return cmdKill(session, { all });
3595
- if (cmd === "attach") return cmdAttach(filteredArgs[1] || session);
3596
- if (cmd === "log") return cmdLog(filteredArgs[1] || session, { tail, reasoning, follow });
4187
+ if (cmd === "target") {
4188
+ const defaultSession = agent.getDefaultSession();
4189
+ if (defaultSession) {
4190
+ console.log(defaultSession);
4191
+ } else {
4192
+ console.log("NO_TARGET");
4193
+ process.exit(1);
4194
+ }
4195
+ return;
4196
+ }
4197
+ if (cmd === "summon") return cmdSummon(positionals[1]);
4198
+ if (cmd === "recall") return cmdRecall(positionals[1]);
4199
+ if (cmd === "archangel") return cmdArchangel(positionals[1]);
4200
+ if (cmd === "kill") return cmdKill(session, { all, orphans, force });
4201
+ if (cmd === "attach") return cmdAttach(positionals[1] || session);
4202
+ if (cmd === "log") return cmdLog(positionals[1] || session, { tail, reasoning, follow });
3597
4203
  if (cmd === "mailbox") return cmdMailbox({ limit, branch, all });
3598
4204
  if (cmd === "approve") return cmdApprove(agent, session, { wait, timeoutMs });
3599
4205
  if (cmd === "reject") return cmdReject(agent, session, { wait, timeoutMs });
3600
- if (cmd === "review") return cmdReview(agent, session, filteredArgs[1], filteredArgs[2], { wait, yolo, fresh, timeoutMs });
4206
+ if (cmd === "review")
4207
+ return cmdReview(agent, session, positionals[1], positionals[2], {
4208
+ wait,
4209
+ fresh,
4210
+ timeoutMs,
4211
+ });
3601
4212
  if (cmd === "status") return cmdStatus(agent, session);
3602
4213
  if (cmd === "debug") return cmdDebug(agent, session);
3603
4214
  if (cmd === "output") {
3604
- const indexArg = filteredArgs[1];
4215
+ const indexArg = positionals[1];
3605
4216
  const index = indexArg?.startsWith("-") ? parseInt(indexArg, 10) : 0;
3606
4217
  return cmdOutput(agent, session, index, { wait, timeoutMs });
3607
4218
  }
3608
- if (cmd === "send" && filteredArgs.length > 1) return cmdSend(session, filteredArgs.slice(1).join(" "));
4219
+ if (cmd === "send" && positionals.length > 1)
4220
+ return cmdSend(session, positionals.slice(1).join(" "));
3609
4221
  if (cmd === "compact") return cmdAsk(agent, session, "/compact", { noWait: true, timeoutMs });
3610
4222
  if (cmd === "reset") return cmdAsk(agent, session, "/new", { noWait: true, timeoutMs });
3611
- if (cmd === "select" && filteredArgs[1]) return cmdSelect(agent, session, filteredArgs[1], { wait, timeoutMs });
4223
+ if (cmd === "select" && positionals[1])
4224
+ return cmdSelect(agent, session, positionals[1], { wait, timeoutMs });
3612
4225
 
3613
4226
  // Default: send message
3614
- let message = filteredArgs.join(" ");
4227
+ let message = positionals.join(" ");
3615
4228
  if (!message && hasStdinData()) {
3616
4229
  message = await readStdin();
3617
4230
  }
3618
4231
 
3619
- if (!message || cmd === "--help" || cmd === "-h") {
4232
+ if (!message || flags.help) {
3620
4233
  printHelp(agent, cliName);
3621
4234
  process.exit(0);
3622
4235
  }
3623
4236
 
3624
- // Detect "please review" and route to custom review mode
3625
- const reviewMatch = message.match(/^please review\s*(.*)/i);
4237
+ // Detect "review ..." or "please review ..." and route to custom review mode
4238
+ const reviewMatch = message.match(/^(?:please )?review\s*(.*)/i);
3626
4239
  if (reviewMatch && agent.reviewOptions) {
3627
4240
  const customInstructions = reviewMatch[1].trim() || null;
3628
- return cmdReview(agent, session, "custom", customInstructions, { wait: !noWait, yolo, timeoutMs });
4241
+ return cmdReview(agent, session, "custom", customInstructions, {
4242
+ wait: !noWait,
4243
+ yolo,
4244
+ timeoutMs: flags.timeout !== undefined ? timeoutMs : REVIEW_TIMEOUT_MS,
4245
+ });
3629
4246
  }
3630
4247
 
3631
4248
  return cmdAsk(agent, session, message, { noWait, yolo, timeoutMs });
@@ -3633,16 +4250,21 @@ async function main() {
3633
4250
 
3634
4251
  // Run main() only when executed directly (not when imported for testing)
3635
4252
  // Use realpathSync to handle symlinks (e.g., axclaude, axcodex bin entries)
3636
- const isDirectRun = process.argv[1] && (() => {
3637
- try {
3638
- return realpathSync(process.argv[1]) === __filename;
3639
- } catch {
3640
- return false;
3641
- }
3642
- })();
4253
+ const isDirectRun =
4254
+ process.argv[1] &&
4255
+ (() => {
4256
+ try {
4257
+ return realpathSync(process.argv[1]) === __filename;
4258
+ } catch {
4259
+ return false;
4260
+ }
4261
+ })();
3643
4262
  if (isDirectRun) {
3644
4263
  main().catch((err) => {
3645
4264
  console.log(`ERROR: ${err.message}`);
4265
+ if (err instanceof TimeoutError && err.session) {
4266
+ console.log(`Hint: Use 'ax debug --session=${err.session}' to see current screen state`);
4267
+ }
3646
4268
  process.exit(1);
3647
4269
  });
3648
4270
  }
@@ -3652,6 +4274,7 @@ export {
3652
4274
  parseSessionName,
3653
4275
  parseAgentConfig,
3654
4276
  parseKeySequence,
4277
+ parseCliArgs,
3655
4278
  getClaudeProjectPath,
3656
4279
  matchesPattern,
3657
4280
  getBaseDir,
@@ -3663,4 +4286,3 @@ export {
3663
4286
  detectState,
3664
4287
  State,
3665
4288
  };
3666
-