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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +20 -3
  2. package/ax.js +585 -376
  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,13 +9,21 @@
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
+ } from "node:fs";
18
27
  import { randomUUID } from "node:crypto";
19
28
  import { fileURLToPath } from "node:url";
20
29
  import path from "node:path";
@@ -32,12 +41,12 @@ const VERSION = packageJson.version;
32
41
  /**
33
42
  * @typedef {Object} ParsedSession
34
43
  * @property {string} tool
35
- * @property {string} [daemonName]
44
+ * @property {string} [archangelName]
36
45
  * @property {string} [uuid]
37
46
  */
38
47
 
39
48
  /**
40
- * @typedef {Object} DaemonConfig
49
+ * @typedef {Object} ArchangelConfig
41
50
  * @property {string} name
42
51
  * @property {ToolName} tool
43
52
  * @property {string[]} watch
@@ -110,8 +119,9 @@ const VERSION = packageJson.version;
110
119
  */
111
120
 
112
121
  /**
122
+ * @typedef {{matcher: string, hooks: Array<{type: string, command: string, timeout?: number}>}} ClaudeHookEntry
113
123
  * @typedef {Object} ClaudeSettings
114
- * @property {{UserPromptSubmit?: Array<{matcher: string, hooks: Array<{type: string, command: string, timeout?: number}>}>}} [hooks]
124
+ * @property {{UserPromptSubmit?: ClaudeHookEntry[], PostToolUse?: ClaudeHookEntry[], Stop?: ClaudeHookEntry[], [key: string]: ClaudeHookEntry[] | undefined}} [hooks]
115
125
  */
116
126
 
117
127
  const DEBUG = process.env.AX_DEBUG === "1";
@@ -221,7 +231,9 @@ function tmuxKill(session) {
221
231
  */
222
232
  function tmuxNewSession(session, command) {
223
233
  // Use spawnSync to avoid command injection via session/command
224
- const result = spawnSync("tmux", ["new-session", "-d", "-s", session, command], { encoding: "utf-8" });
234
+ const result = spawnSync("tmux", ["new-session", "-d", "-s", session, command], {
235
+ encoding: "utf-8",
236
+ });
225
237
  if (result.status !== 0) throw new Error(result.stderr || "tmux new-session failed");
226
238
  }
227
239
 
@@ -230,7 +242,9 @@ function tmuxNewSession(session, command) {
230
242
  */
231
243
  function tmuxCurrentSession() {
232
244
  if (!process.env.TMUX) return null;
233
- const result = spawnSync("tmux", ["display-message", "-p", "#S"], { encoding: "utf-8" });
245
+ const result = spawnSync("tmux", ["display-message", "-p", "#S"], {
246
+ encoding: "utf-8",
247
+ });
234
248
  if (result.status !== 0) return null;
235
249
  return result.stdout.trim();
236
250
  }
@@ -242,9 +256,13 @@ function tmuxCurrentSession() {
242
256
  */
243
257
  function isYoloSession(session) {
244
258
  try {
245
- const result = spawnSync("tmux", ["display-message", "-t", session, "-p", "#{pane_start_command}"], {
246
- encoding: "utf-8",
247
- });
259
+ const result = spawnSync(
260
+ "tmux",
261
+ ["display-message", "-t", session, "-p", "#{pane_start_command}"],
262
+ {
263
+ encoding: "utf-8",
264
+ },
265
+ );
248
266
  if (result.status !== 0) return false;
249
267
  const cmd = result.stdout.trim();
250
268
  return cmd.includes("--dangerously-");
@@ -264,9 +282,15 @@ const POLL_MS = parseInt(process.env.AX_POLL_MS || "200", 10);
264
282
  const DEFAULT_TIMEOUT_MS = parseInt(process.env.AX_TIMEOUT_MS || "120000", 10);
265
283
  const REVIEW_TIMEOUT_MS = parseInt(process.env.AX_REVIEW_TIMEOUT_MS || "900000", 10); // 15 minutes
266
284
  const STARTUP_TIMEOUT_MS = parseInt(process.env.AX_STARTUP_TIMEOUT_MS || "30000", 10);
267
- const DAEMON_STARTUP_TIMEOUT_MS = parseInt(process.env.AX_DAEMON_STARTUP_TIMEOUT_MS || "60000", 10);
268
- const DAEMON_RESPONSE_TIMEOUT_MS = parseInt(process.env.AX_DAEMON_RESPONSE_TIMEOUT_MS || "300000", 10); // 5 minutes
269
- const DAEMON_HEALTH_CHECK_MS = parseInt(process.env.AX_DAEMON_HEALTH_CHECK_MS || "30000", 10);
285
+ const ARCHANGEL_STARTUP_TIMEOUT_MS = parseInt(
286
+ process.env.AX_ARCHANGEL_STARTUP_TIMEOUT_MS || "60000",
287
+ 10,
288
+ );
289
+ const ARCHANGEL_RESPONSE_TIMEOUT_MS = parseInt(
290
+ process.env.AX_ARCHANGEL_RESPONSE_TIMEOUT_MS || "300000",
291
+ 10,
292
+ ); // 5 minutes
293
+ const ARCHANGEL_HEALTH_CHECK_MS = parseInt(process.env.AX_ARCHANGEL_HEALTH_CHECK_MS || "30000", 10);
270
294
  const STABLE_MS = parseInt(process.env.AX_STABLE_MS || "1000", 10);
271
295
  const APPROVE_DELAY_MS = parseInt(process.env.AX_APPROVE_DELAY_MS || "100", 10);
272
296
  const MAILBOX_MAX_AGE_MS = parseInt(process.env.AX_MAILBOX_MAX_AGE_MS || "3600000", 10); // 1 hour
@@ -274,9 +298,9 @@ const CLAUDE_CONFIG_DIR = process.env.AX_CLAUDE_CONFIG_DIR || path.join(os.homed
274
298
  const CODEX_CONFIG_DIR = process.env.AX_CODEX_CONFIG_DIR || path.join(os.homedir(), ".codex");
275
299
  const TRUNCATE_USER_LEN = 500;
276
300
  const TRUNCATE_THINKING_LEN = 300;
277
- const DAEMON_GIT_CONTEXT_HOURS = 4;
278
- const DAEMON_GIT_CONTEXT_MAX_LINES = 200;
279
- const DAEMON_PARENT_CONTEXT_ENTRIES = 10;
301
+ const ARCHANGEL_GIT_CONTEXT_HOURS = 4;
302
+ const ARCHANGEL_GIT_CONTEXT_MAX_LINES = 200;
303
+ const ARCHANGEL_PARENT_CONTEXT_ENTRIES = 10;
280
304
 
281
305
  /**
282
306
  * @param {string} session
@@ -304,7 +328,9 @@ async function waitFor(session, predicate, timeoutMs = STARTUP_TIMEOUT_MS) {
304
328
  function findCallerPid() {
305
329
  let pid = process.ppid;
306
330
  while (pid > 1) {
307
- const result = spawnSync("ps", ["-p", pid.toString(), "-o", "ppid=,comm="], { encoding: "utf-8" });
331
+ const result = spawnSync("ps", ["-p", pid.toString(), "-o", "ppid=,comm="], {
332
+ encoding: "utf-8",
333
+ });
308
334
  if (result.status !== 0) break;
309
335
  const parts = result.stdout.trim().split(/\s+/);
310
336
  const ppid = parseInt(parts[0], 10);
@@ -359,14 +385,18 @@ function parseSessionName(session) {
359
385
  const tool = match[1].toLowerCase();
360
386
  const rest = match[2];
361
387
 
362
- // Daemon: {tool}-daemon-{name}-{uuid}
363
- const daemonMatch = rest.match(/^daemon-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
364
- if (daemonMatch) {
365
- return { tool, daemonName: daemonMatch[1], uuid: daemonMatch[2] };
388
+ // Archangel: {tool}-archangel-{name}-{uuid}
389
+ const archangelMatch = rest.match(
390
+ /^archangel-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i,
391
+ );
392
+ if (archangelMatch) {
393
+ return { tool, archangelName: archangelMatch[1], uuid: archangelMatch[2] };
366
394
  }
367
395
 
368
396
  // 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);
397
+ const partnerMatch = rest.match(
398
+ /^partner-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i,
399
+ );
370
400
  if (partnerMatch) {
371
401
  return { tool, uuid: partnerMatch[1] };
372
402
  }
@@ -399,9 +429,13 @@ function getClaudeProjectPath(cwd) {
399
429
  */
400
430
  function getTmuxSessionCwd(sessionName) {
401
431
  try {
402
- const result = spawnSync("tmux", ["display-message", "-t", sessionName, "-p", "#{pane_current_path}"], {
403
- encoding: "utf-8",
404
- });
432
+ const result = spawnSync(
433
+ "tmux",
434
+ ["display-message", "-t", sessionName, "-p", "#{pane_current_path}"],
435
+ {
436
+ encoding: "utf-8",
437
+ },
438
+ );
405
439
  if (result.status === 0) return result.stdout.trim();
406
440
  } catch (err) {
407
441
  debugError("getTmuxSessionCwd", err);
@@ -425,7 +459,9 @@ function findClaudeLogPath(sessionId, sessionName) {
425
459
  if (existsSync(indexPath)) {
426
460
  try {
427
461
  const index = JSON.parse(readFileSync(indexPath, "utf-8"));
428
- const entry = index.entries?.find(/** @param {{sessionId: string, fullPath?: string}} e */ (e) => e.sessionId === sessionId);
462
+ const entry = index.entries?.find(
463
+ /** @param {{sessionId: string, fullPath?: string}} e */ (e) => e.sessionId === sessionId,
464
+ );
429
465
  if (entry?.fullPath) return entry.fullPath;
430
466
  } catch (err) {
431
467
  debugError("findClaudeLogPath", err);
@@ -447,9 +483,13 @@ function findCodexLogPath(sessionName) {
447
483
  // For Codex, we need to match by timing since we can't control the session ID
448
484
  // Get tmux session creation time
449
485
  try {
450
- const result = spawnSync("tmux", ["display-message", "-t", sessionName, "-p", "#{session_created}"], {
451
- encoding: "utf-8",
452
- });
486
+ const result = spawnSync(
487
+ "tmux",
488
+ ["display-message", "-t", sessionName, "-p", "#{session_created}"],
489
+ {
490
+ encoding: "utf-8",
491
+ },
492
+ );
453
493
  if (result.status !== 0) return null;
454
494
  const createdTs = parseInt(result.stdout.trim(), 10) * 1000; // tmux gives seconds, we need ms
455
495
  if (isNaN(createdTs)) return null;
@@ -483,7 +523,11 @@ function findCodexLogPath(sessionName) {
483
523
  // Log file should be created shortly after session start
484
524
  // Allow small negative diff (-2s) for clock skew, up to 60s for slow starts
485
525
  if (diff >= -2000 && diff < 60000) {
486
- candidates.push({ file, diff: Math.abs(diff), path: path.join(dayDir, file) });
526
+ candidates.push({
527
+ file,
528
+ diff: Math.abs(diff),
529
+ path: path.join(dayDir, file),
530
+ });
487
531
  }
488
532
  }
489
533
 
@@ -496,7 +540,6 @@ function findCodexLogPath(sessionName) {
496
540
  }
497
541
  }
498
542
 
499
-
500
543
  /**
501
544
  * Extract assistant text responses from a JSONL log file.
502
545
  * This provides clean text without screen-scraped artifacts.
@@ -567,15 +610,15 @@ function resolveSessionName(partial) {
567
610
  // Exact match
568
611
  if (agentSessions.includes(partial)) return partial;
569
612
 
570
- // Daemon name match (e.g., "reviewer" matches "claude-daemon-reviewer-uuid")
571
- const daemonMatches = agentSessions.filter((s) => {
613
+ // Archangel name match (e.g., "reviewer" matches "claude-archangel-reviewer-uuid")
614
+ const archangelMatches = agentSessions.filter((s) => {
572
615
  const parsed = parseSessionName(s);
573
- return parsed?.daemonName === partial;
616
+ return parsed?.archangelName === partial;
574
617
  });
575
- if (daemonMatches.length === 1) return daemonMatches[0];
576
- if (daemonMatches.length > 1) {
577
- console.log("ERROR: ambiguous daemon name. Matches:");
578
- for (const m of daemonMatches) console.log(` ${m}`);
618
+ if (archangelMatches.length === 1) return archangelMatches[0];
619
+ if (archangelMatches.length > 1) {
620
+ console.log("ERROR: ambiguous archangel name. Matches:");
621
+ for (const m of archangelMatches) console.log(` ${m}`);
579
622
  process.exit(1);
580
623
  }
581
624
 
@@ -604,25 +647,25 @@ function resolveSessionName(partial) {
604
647
  }
605
648
 
606
649
  // =============================================================================
607
- // Helpers - daemon agents
650
+ // Helpers - archangels
608
651
  // =============================================================================
609
652
 
610
653
  /**
611
- * @returns {DaemonConfig[]}
654
+ * @returns {ArchangelConfig[]}
612
655
  */
613
656
  function loadAgentConfigs() {
614
657
  const agentsDir = AGENTS_DIR;
615
658
  if (!existsSync(agentsDir)) return [];
616
659
 
617
660
  const files = readdirSync(agentsDir).filter((f) => f.endsWith(".md"));
618
- /** @type {DaemonConfig[]} */
661
+ /** @type {ArchangelConfig[]} */
619
662
  const configs = [];
620
663
 
621
664
  for (const file of files) {
622
665
  try {
623
666
  const content = readFileSync(path.join(agentsDir, file), "utf-8");
624
667
  const config = parseAgentConfig(file, content);
625
- if (config && 'error' in config) {
668
+ if (config && "error" in config) {
626
669
  console.error(`ERROR: ${file}: ${config.error}`);
627
670
  continue;
628
671
  }
@@ -638,7 +681,7 @@ function loadAgentConfigs() {
638
681
  /**
639
682
  * @param {string} filename
640
683
  * @param {string} content
641
- * @returns {DaemonConfig | {error: string} | null}
684
+ * @returns {ArchangelConfig | {error: string} | null}
642
685
  */
643
686
  function parseAgentConfig(filename, content) {
644
687
  const name = filename.replace(/\.md$/, "");
@@ -653,7 +696,9 @@ function parseAgentConfig(filename, content) {
653
696
  return { error: `Missing frontmatter. File must start with '---'` };
654
697
  }
655
698
  if (!normalized.includes("\n---\n")) {
656
- return { error: `Frontmatter not closed. Add '---' on its own line after the YAML block` };
699
+ return {
700
+ error: `Frontmatter not closed. Add '---' on its own line after the YAML block`,
701
+ };
657
702
  }
658
703
  return { error: `Invalid frontmatter format` };
659
704
  }
@@ -674,9 +719,13 @@ function parseAgentConfig(filename, content) {
674
719
  const fieldName = line.trim().match(/^(\w+):/)?.[1];
675
720
  if (fieldName && !knownFields.includes(fieldName)) {
676
721
  // Suggest closest match
677
- const suggestions = knownFields.filter((f) => f[0] === fieldName[0] || fieldName.includes(f.slice(0, 3)));
722
+ const suggestions = knownFields.filter(
723
+ (f) => f[0] === fieldName[0] || fieldName.includes(f.slice(0, 3)),
724
+ );
678
725
  const hint = suggestions.length > 0 ? ` Did you mean '${suggestions[0]}'?` : "";
679
- return { error: `Unknown field '${fieldName}'.${hint} Valid fields: ${knownFields.join(", ")}` };
726
+ return {
727
+ error: `Unknown field '${fieldName}'.${hint} Valid fields: ${knownFields.join(", ")}`,
728
+ };
680
729
  }
681
730
  }
682
731
 
@@ -694,7 +743,9 @@ function parseAgentConfig(filename, content) {
694
743
  const rawValue = intervalMatch[1].trim();
695
744
  const parsed = parseInt(rawValue, 10);
696
745
  if (isNaN(parsed)) {
697
- return { error: `Invalid interval '${rawValue}'. Must be a number (seconds)` };
746
+ return {
747
+ error: `Invalid interval '${rawValue}'. Must be a number (seconds)`,
748
+ };
698
749
  }
699
750
  interval = Math.max(10, Math.min(3600, parsed)); // Clamp to 10s - 1hr
700
751
  }
@@ -706,16 +757,22 @@ function parseAgentConfig(filename, content) {
706
757
  const rawWatch = watchLine[1].trim();
707
758
  // Must be array format
708
759
  if (!rawWatch.startsWith("[") || !rawWatch.endsWith("]")) {
709
- return { error: `Invalid watch format. Must be an array: watch: ["src/**/*.ts"]` };
760
+ return {
761
+ error: `Invalid watch format. Must be an array: watch: ["src/**/*.ts"]`,
762
+ };
710
763
  }
711
764
  const inner = rawWatch.slice(1, -1).trim();
712
765
  if (!inner) {
713
- return { error: `Empty watch array. Add at least one pattern: watch: ["**/*"]` };
766
+ return {
767
+ error: `Empty watch array. Add at least one pattern: watch: ["**/*"]`,
768
+ };
714
769
  }
715
770
  watchPatterns = inner.split(",").map((p) => p.trim().replace(/^["']|["']$/g, ""));
716
771
  // Validate patterns aren't empty
717
772
  if (watchPatterns.some((p) => !p)) {
718
- return { error: `Invalid watch pattern. Check for trailing commas or empty values` };
773
+ return {
774
+ error: `Invalid watch pattern. Check for trailing commas or empty values`,
775
+ };
719
776
  }
720
777
  }
721
778
 
@@ -723,11 +780,11 @@ function parseAgentConfig(filename, content) {
723
780
  }
724
781
 
725
782
  /**
726
- * @param {DaemonConfig} config
783
+ * @param {ArchangelConfig} config
727
784
  * @returns {string}
728
785
  */
729
- function getDaemonSessionPattern(config) {
730
- return `${config.tool}-daemon-${config.name}`;
786
+ function getArchangelSessionPattern(config) {
787
+ return `${config.tool}-archangel-${config.name}`;
731
788
  }
732
789
 
733
790
  // =============================================================================
@@ -831,7 +888,9 @@ function gcMailbox(maxAgeHours = 24) {
831
888
  /** @returns {string} */
832
889
  function getCurrentBranch() {
833
890
  try {
834
- return execSync("git branch --show-current 2>/dev/null", { encoding: "utf-8" }).trim();
891
+ return execSync("git branch --show-current 2>/dev/null", {
892
+ encoding: "utf-8",
893
+ }).trim();
835
894
  } catch {
836
895
  return "unknown";
837
896
  }
@@ -840,7 +899,9 @@ function getCurrentBranch() {
840
899
  /** @returns {string} */
841
900
  function getCurrentCommit() {
842
901
  try {
843
- return execSync("git rev-parse --short HEAD 2>/dev/null", { encoding: "utf-8" }).trim();
902
+ return execSync("git rev-parse --short HEAD 2>/dev/null", {
903
+ encoding: "utf-8",
904
+ }).trim();
844
905
  } catch {
845
906
  return "unknown";
846
907
  }
@@ -864,7 +925,9 @@ function getMainBranch() {
864
925
  /** @returns {string} */
865
926
  function getStagedDiff() {
866
927
  try {
867
- return execSync("git diff --cached 2>/dev/null", { encoding: "utf-8" }).trim();
928
+ return execSync("git diff --cached 2>/dev/null", {
929
+ encoding: "utf-8",
930
+ }).trim();
868
931
  } catch {
869
932
  return "";
870
933
  }
@@ -889,20 +952,18 @@ function getRecentCommitsDiff(hoursAgo = 4) {
889
952
  const since = `--since="${hoursAgo} hours ago"`;
890
953
 
891
954
  // 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();
955
+ const commits = execSync(`git log ${mainBranch}..HEAD ${since} --oneline 2>/dev/null`, {
956
+ encoding: "utf-8",
957
+ }).trim();
896
958
 
897
959
  if (!commits) return "";
898
960
 
899
961
  // Get diff for those commits
900
962
  const firstCommit = commits.split("\n").filter(Boolean).pop()?.split(" ")[0];
901
963
  if (!firstCommit) return "";
902
- return execSync(
903
- `git diff ${firstCommit}^..HEAD 2>/dev/null`,
904
- { encoding: "utf-8" }
905
- ).trim();
964
+ return execSync(`git diff ${firstCommit}^..HEAD 2>/dev/null`, {
965
+ encoding: "utf-8",
966
+ }).trim();
906
967
  } catch {
907
968
  return "";
908
969
  }
@@ -917,7 +978,10 @@ function truncateDiff(diff, maxLines = 200) {
917
978
  if (!diff) return "";
918
979
  const lines = diff.split("\n");
919
980
  if (lines.length <= maxLines) return diff;
920
- return lines.slice(0, maxLines).join("\n") + `\n\n... (truncated, ${lines.length - maxLines} more lines)`;
981
+ return (
982
+ lines.slice(0, maxLines).join("\n") +
983
+ `\n\n... (truncated, ${lines.length - maxLines} more lines)`
984
+ );
921
985
  }
922
986
 
923
987
  /**
@@ -950,9 +1014,9 @@ function buildGitContext(hoursAgo = 4, maxLinesPerSection = 200) {
950
1014
  // Helpers - parent session context
951
1015
  // =============================================================================
952
1016
 
953
- // Environment variables used to pass parent session info to daemons
954
- const AX_DAEMON_PARENT_SESSION_ENV = "AX_DAEMON_PARENT_SESSION";
955
- const AX_DAEMON_PARENT_UUID_ENV = "AX_DAEMON_PARENT_UUID";
1017
+ // Environment variables used to pass parent session info to archangels
1018
+ const AX_ARCHANGEL_PARENT_SESSION_ENV = "AX_ARCHANGEL_PARENT_SESSION";
1019
+ const AX_ARCHANGEL_PARENT_UUID_ENV = "AX_ARCHANGEL_PARENT_UUID";
956
1020
 
957
1021
  /**
958
1022
  * @returns {ParentSession | null}
@@ -962,7 +1026,7 @@ function findCurrentClaudeSession() {
962
1026
  const current = tmuxCurrentSession();
963
1027
  if (current) {
964
1028
  const parsed = parseSessionName(current);
965
- if (parsed?.tool === "claude" && !parsed.daemonName && parsed.uuid) {
1029
+ if (parsed?.tool === "claude" && !parsed.archangelName && parsed.uuid) {
966
1030
  return { session: current, uuid: parsed.uuid };
967
1031
  }
968
1032
  }
@@ -979,7 +1043,7 @@ function findCurrentClaudeSession() {
979
1043
  for (const session of sessions) {
980
1044
  const parsed = parseSessionName(session);
981
1045
  if (!parsed || parsed.tool !== "claude") continue;
982
- if (parsed.daemonName) continue;
1046
+ if (parsed.archangelName) continue;
983
1047
  if (!parsed.uuid) continue;
984
1048
 
985
1049
  const sessionCwd = getTmuxSessionCwd(session);
@@ -1002,18 +1066,23 @@ function findCurrentClaudeSession() {
1002
1066
  const claudeProjectDir = path.join(CLAUDE_CONFIG_DIR, "projects", projectPath);
1003
1067
  if (existsSync(claudeProjectDir)) {
1004
1068
  try {
1005
- const files = readdirSync(claudeProjectDir).filter(f => f.endsWith(".jsonl"));
1069
+ const files = readdirSync(claudeProjectDir).filter((f) => f.endsWith(".jsonl"));
1006
1070
  for (const file of files) {
1007
1071
  const uuid = file.replace(".jsonl", "");
1008
1072
  // Skip if we already have this from tmux sessions
1009
- if (candidates.some(c => c.uuid === uuid)) continue;
1073
+ if (candidates.some((c) => c.uuid === uuid)) continue;
1010
1074
 
1011
1075
  const logPath = path.join(claudeProjectDir, file);
1012
1076
  try {
1013
1077
  const stat = statSync(logPath);
1014
1078
  // Only consider logs modified in the last hour (active sessions)
1015
1079
  if (Date.now() - stat.mtimeMs < MAILBOX_MAX_AGE_MS) {
1016
- candidates.push({ session: null, uuid, mtime: stat.mtimeMs, logPath });
1080
+ candidates.push({
1081
+ session: null,
1082
+ uuid,
1083
+ mtime: stat.mtimeMs,
1084
+ logPath,
1085
+ });
1017
1086
  }
1018
1087
  } catch (err) {
1019
1088
  debugError("findCurrentClaudeSession:logStat", err);
@@ -1035,15 +1104,15 @@ function findCurrentClaudeSession() {
1035
1104
  * @returns {ParentSession | null}
1036
1105
  */
1037
1106
  function findParentSession() {
1038
- // First check if parent session was passed via environment (for daemons)
1039
- const envUuid = process.env[AX_DAEMON_PARENT_UUID_ENV];
1107
+ // First check if parent session was passed via environment (for archangels)
1108
+ const envUuid = process.env[AX_ARCHANGEL_PARENT_UUID_ENV];
1040
1109
  if (envUuid) {
1041
1110
  // Session name is optional (may be null for non-tmux sessions)
1042
- const envSession = process.env[AX_DAEMON_PARENT_SESSION_ENV] || null;
1111
+ const envSession = process.env[AX_ARCHANGEL_PARENT_SESSION_ENV] || null;
1043
1112
  return { session: envSession, uuid: envUuid };
1044
1113
  }
1045
1114
 
1046
- // Fallback to detecting current session (shouldn't be needed for daemons)
1115
+ // Fallback to detecting current session (shouldn't be needed for archangels)
1047
1116
  return findCurrentClaudeSession();
1048
1117
  }
1049
1118
 
@@ -1085,7 +1154,9 @@ function getParentSessionContext(maxEntries = 20) {
1085
1154
  if (typeof c === "string" && c.length > 10) {
1086
1155
  entries.push({ type: "user", text: c });
1087
1156
  } else if (Array.isArray(c)) {
1088
- const text = c.find(/** @param {{type: string, text?: string}} x */ (x) => x.type === "text")?.text;
1157
+ const text = c.find(
1158
+ /** @param {{type: string, text?: string}} x */ (x) => x.type === "text",
1159
+ )?.text;
1089
1160
  if (text && text.length > 10) {
1090
1161
  entries.push({ type: "user", text });
1091
1162
  }
@@ -1093,7 +1164,10 @@ function getParentSessionContext(maxEntries = 20) {
1093
1164
  } else if (entry.type === "assistant") {
1094
1165
  /** @type {{type: string, text?: string}[]} */
1095
1166
  const parts = entry.message?.content || [];
1096
- const text = parts.filter((p) => p.type === "text").map((p) => p.text || "").join("\n");
1167
+ const text = parts
1168
+ .filter((p) => p.type === "text")
1169
+ .map((p) => p.text || "")
1170
+ .join("\n");
1097
1171
  // Only include assistant responses with meaningful text
1098
1172
  if (text && text.length > 20) {
1099
1173
  entries.push({ type: "assistant", text });
@@ -1105,7 +1179,7 @@ function getParentSessionContext(maxEntries = 20) {
1105
1179
  }
1106
1180
 
1107
1181
  // Format recent conversation
1108
- const formatted = entries.slice(-maxEntries).map(e => {
1182
+ const formatted = entries.slice(-maxEntries).map((e) => {
1109
1183
  const preview = e.text.slice(0, 500).replace(/\n/g, " ");
1110
1184
  return `**${e.type === "user" ? "User" : "Assistant"}**: ${preview}`;
1111
1185
  });
@@ -1148,10 +1222,16 @@ function extractFileEditContext(logPath, filePath) {
1148
1222
 
1149
1223
  // Parse all entries
1150
1224
  /** @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);
1225
+ const entries = lines
1226
+ .map((line, idx) => {
1227
+ try {
1228
+ return { idx, ...JSON.parse(line) };
1229
+ } catch (err) {
1230
+ debugError("extractFileEditContext:parse", err);
1231
+ return null;
1232
+ }
1233
+ })
1234
+ .filter(Boolean);
1155
1235
 
1156
1236
  // Find Write/Edit tool calls for this file (scan backwards, want most recent)
1157
1237
  /** @type {any} */
@@ -1164,9 +1244,10 @@ function extractFileEditContext(logPath, filePath) {
1164
1244
 
1165
1245
  /** @type {any[]} */
1166
1246
  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")
1247
+ const toolCalls = msgContent.filter(
1248
+ (/** @type {any} */ c) =>
1249
+ (c.type === "tool_use" || c.type === "tool_call") &&
1250
+ (c.name === "Write" || c.name === "Edit"),
1170
1251
  );
1171
1252
 
1172
1253
  for (const tc of toolCalls) {
@@ -1217,8 +1298,9 @@ function extractFileEditContext(logPath, filePath) {
1217
1298
 
1218
1299
  /** @type {any[]} */
1219
1300
  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"
1301
+ const readCalls = msgContent.filter(
1302
+ (/** @type {any} */ c) =>
1303
+ (c.type === "tool_use" || c.type === "tool_call") && c.name === "Read",
1222
1304
  );
1223
1305
 
1224
1306
  for (const rc of readCalls) {
@@ -1233,9 +1315,10 @@ function extractFileEditContext(logPath, filePath) {
1233
1315
  if (entry.type !== "assistant") continue;
1234
1316
  /** @type {any[]} */
1235
1317
  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")
1318
+ const edits = msgContent.filter(
1319
+ (/** @type {any} */ c) =>
1320
+ (c.type === "tool_use" || c.type === "tool_call") &&
1321
+ (c.name === "Write" || c.name === "Edit"),
1239
1322
  );
1240
1323
  for (const e of edits) {
1241
1324
  const input = e.input || e.arguments || {};
@@ -1250,11 +1333,11 @@ function extractFileEditContext(logPath, filePath) {
1250
1333
  toolCall: {
1251
1334
  name: editEntry.toolCall.name,
1252
1335
  input: editEntry.toolCall.input || editEntry.toolCall.arguments,
1253
- id: editEntry.toolCall.id
1336
+ id: editEntry.toolCall.id,
1254
1337
  },
1255
1338
  subsequentErrors,
1256
1339
  readsBefore: [...new Set(readsBefore)].slice(0, 10),
1257
- editSequence
1340
+ editSequence,
1258
1341
  };
1259
1342
  }
1260
1343
 
@@ -1348,10 +1431,11 @@ function watchForChanges(patterns, callback) {
1348
1431
  }
1349
1432
  }
1350
1433
 
1351
- return () => { for (const w of watchers) w.close(); };
1434
+ return () => {
1435
+ for (const w of watchers) w.close();
1436
+ };
1352
1437
  }
1353
1438
 
1354
-
1355
1439
  // =============================================================================
1356
1440
  // State
1357
1441
  // =============================================================================
@@ -1429,7 +1513,7 @@ function detectState(screen, config) {
1429
1513
  // Check if any line has the prompt followed by pasted content indicator
1430
1514
  const linesArray = lastLines.split("\n");
1431
1515
  const promptWithPaste = linesArray.some(
1432
- (l) => l.includes(config.promptSymbol) && l.includes("[Pasted text")
1516
+ (l) => l.includes(config.promptSymbol) && l.includes("[Pasted text"),
1433
1517
  );
1434
1518
  if (!promptWithPaste) {
1435
1519
  return State.READY;
@@ -1640,7 +1724,12 @@ class Agent {
1640
1724
  if (/^(run|execute|create|delete|modify|write)/i.test(line)) return line;
1641
1725
  }
1642
1726
 
1643
- return lines.filter((l) => l && !l.match(/^[╭╮╰╯│─]+$/)).slice(0, 2).join(" | ") || "action";
1727
+ return (
1728
+ lines
1729
+ .filter((l) => l && !l.match(/^[╭╮╰╯│─]+$/))
1730
+ .slice(0, 2)
1731
+ .join(" | ") || "action"
1732
+ );
1644
1733
  }
1645
1734
 
1646
1735
  /**
@@ -1716,7 +1805,9 @@ class Agent {
1716
1805
 
1717
1806
  // Fallback: extract after last prompt
1718
1807
  if (filtered.length === 0) {
1719
- const lastPromptIdx = lines.findLastIndex((/** @type {string} */ l) => l.startsWith(this.promptSymbol));
1808
+ const lastPromptIdx = lines.findLastIndex((/** @type {string} */ l) =>
1809
+ l.startsWith(this.promptSymbol),
1810
+ );
1720
1811
  if (lastPromptIdx >= 0 && lastPromptIdx < lines.length - 1) {
1721
1812
  const afterPrompt = lines
1722
1813
  .slice(lastPromptIdx + 1)
@@ -1730,14 +1821,14 @@ class Agent {
1730
1821
  // This handles the case where Claude finished and shows a new empty prompt
1731
1822
  if (lastPromptIdx >= 0) {
1732
1823
  const lastPromptLine = lines[lastPromptIdx];
1733
- const isEmptyPrompt = lastPromptLine.trim() === this.promptSymbol ||
1734
- lastPromptLine.match(/^❯\s*$/);
1824
+ const isEmptyPrompt =
1825
+ lastPromptLine.trim() === this.promptSymbol || lastPromptLine.match(/^❯\s*$/);
1735
1826
  if (isEmptyPrompt) {
1736
1827
  // Find the previous prompt (user's input) and extract content between
1737
1828
  // 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
- );
1829
+ const prevPromptIdx = lines
1830
+ .slice(0, lastPromptIdx)
1831
+ .findLastIndex((/** @type {string} */ l) => l.startsWith(this.promptSymbol));
1741
1832
  if (prevPromptIdx >= 0) {
1742
1833
  const betweenPrompts = lines
1743
1834
  .slice(prevPromptIdx + 1, lastPromptIdx)
@@ -1758,23 +1849,25 @@ class Agent {
1758
1849
  * @returns {string}
1759
1850
  */
1760
1851
  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();
1852
+ return (
1853
+ response
1854
+ // Remove tool call lines (Search, Read, Grep, etc.)
1855
+ .replace(/^[⏺•]\s*(Search|Read|Grep|Glob|Write|Edit|Bash)\([^)]*\).*$/gm, "")
1856
+ // Remove tool result lines
1857
+ .replace(/^⎿\s+.*$/gm, "")
1858
+ // Remove "Sautéed for Xs" timing lines
1859
+ .replace(/^✻\s+Sautéed for.*$/gm, "")
1860
+ // Remove expand hints
1861
+ .replace(/\(ctrl\+o to expand\)/g, "")
1862
+ // Clean up multiple blank lines
1863
+ .replace(/\n{3,}/g, "\n\n")
1864
+ // Original cleanup
1865
+ .replace(/^[•⏺-]\s*/, "")
1866
+ .replace(/^\*\*(.+)\*\*/, "$1")
1867
+ .replace(/\n /g, "\n")
1868
+ .replace(/─+\s*$/, "")
1869
+ .trim()
1870
+ );
1778
1871
  }
1779
1872
 
1780
1873
  /**
@@ -1857,9 +1950,18 @@ const ClaudeAgent = new Agent({
1857
1950
  ],
1858
1951
  updatePromptPatterns: null,
1859
1952
  responseMarkers: ["⏺", "•", "- ", "**"],
1860
- chromePatterns: ["↵ send", "Esc to cancel", "shortcuts", "for more options", "docs.anthropic.com", "⏵⏵", "bypass permissions", "shift+Tab to cycle"],
1953
+ chromePatterns: [
1954
+ "↵ send",
1955
+ "Esc to cancel",
1956
+ "shortcuts",
1957
+ "for more options",
1958
+ "docs.anthropic.com",
1959
+ "⏵⏵",
1960
+ "bypass permissions",
1961
+ "shift+Tab to cycle",
1962
+ ],
1861
1963
  reviewOptions: null,
1862
- safeAllowedTools: "Bash(git:*) Read Glob Grep", // Default: auto-approve read-only tools
1964
+ safeAllowedTools: "Bash(git:*) Read Glob Grep", // Default: auto-approve read-only tools
1863
1965
  envVar: "AX_SESSION",
1864
1966
  approveKey: "1",
1865
1967
  rejectKey: "Escape",
@@ -1883,7 +1985,11 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
1883
1985
  const initialState = agent.getState(initialScreen);
1884
1986
 
1885
1987
  // Already in terminal state
1886
- if (initialState === State.RATE_LIMITED || initialState === State.CONFIRMING || initialState === State.READY) {
1988
+ if (
1989
+ initialState === State.RATE_LIMITED ||
1990
+ initialState === State.CONFIRMING ||
1991
+ initialState === State.READY
1992
+ ) {
1887
1993
  return { state: initialState, screen: initialScreen };
1888
1994
  }
1889
1995
 
@@ -2045,7 +2151,7 @@ function cmdAgents() {
2045
2151
  const screen = tmuxCapture(session);
2046
2152
  const state = agent.getState(screen);
2047
2153
  const logPath = agent.findLogPath(session);
2048
- const type = parsed.daemonName ? "daemon" : "-";
2154
+ const type = parsed.archangelName ? "archangel" : "-";
2049
2155
 
2050
2156
  return {
2051
2157
  session,
@@ -2063,97 +2169,98 @@ function cmdAgents() {
2063
2169
  const maxType = Math.max(4, ...agents.map((a) => a.type.length));
2064
2170
 
2065
2171
  console.log(
2066
- `${"SESSION".padEnd(maxSession)} ${"TOOL".padEnd(maxTool)} ${"STATE".padEnd(maxState)} ${"TYPE".padEnd(maxType)} LOG`
2172
+ `${"SESSION".padEnd(maxSession)} ${"TOOL".padEnd(maxTool)} ${"STATE".padEnd(maxState)} ${"TYPE".padEnd(maxType)} LOG`,
2067
2173
  );
2068
2174
  for (const a of agents) {
2069
2175
  console.log(
2070
- `${a.session.padEnd(maxSession)} ${a.tool.padEnd(maxTool)} ${a.state.padEnd(maxState)} ${a.type.padEnd(maxType)} ${a.log}`
2176
+ `${a.session.padEnd(maxSession)} ${a.tool.padEnd(maxTool)} ${a.state.padEnd(maxState)} ${a.type.padEnd(maxType)} ${a.log}`,
2071
2177
  );
2072
2178
  }
2073
2179
  }
2074
2180
 
2075
2181
  // =============================================================================
2076
- // Command: daemons
2182
+ // Command: summon/recall
2077
2183
  // =============================================================================
2078
2184
 
2079
2185
  /**
2080
2186
  * @param {string} pattern
2081
2187
  * @returns {string | undefined}
2082
2188
  */
2083
- function findDaemonSession(pattern) {
2189
+ function findArchangelSession(pattern) {
2084
2190
  const sessions = tmuxListSessions();
2085
2191
  return sessions.find((s) => s.startsWith(pattern));
2086
2192
  }
2087
2193
 
2088
2194
  /**
2089
- * @param {DaemonConfig} config
2195
+ * @param {ArchangelConfig} config
2090
2196
  * @returns {string}
2091
2197
  */
2092
- function generateDaemonSessionName(config) {
2093
- return `${config.tool}-daemon-${config.name}-${randomUUID()}`;
2198
+ function generateArchangelSessionName(config) {
2199
+ return `${config.tool}-archangel-${config.name}-${randomUUID()}`;
2094
2200
  }
2095
2201
 
2096
2202
  /**
2097
- * @param {DaemonConfig} config
2203
+ * @param {ArchangelConfig} config
2098
2204
  * @param {ParentSession | null} [parentSession]
2099
2205
  */
2100
- function startDaemonAgent(config, parentSession = null) {
2101
- // Build environment with parent session info if available
2206
+ function startArchangel(config, parentSession = null) {
2102
2207
  /** @type {NodeJS.ProcessEnv} */
2103
2208
  const env = { ...process.env };
2104
2209
  if (parentSession?.uuid) {
2105
- // Session name may be null for non-tmux sessions, but uuid is required
2106
2210
  if (parentSession.session) {
2107
- env[AX_DAEMON_PARENT_SESSION_ENV] = parentSession.session;
2211
+ env[AX_ARCHANGEL_PARENT_SESSION_ENV] = parentSession.session;
2108
2212
  }
2109
- env[AX_DAEMON_PARENT_UUID_ENV] = parentSession.uuid;
2213
+ env[AX_ARCHANGEL_PARENT_UUID_ENV] = parentSession.uuid;
2110
2214
  }
2111
2215
 
2112
- // Spawn ax.js daemon <name> as a detached background process
2113
- const child = spawn("node", [process.argv[1], "daemon", config.name], {
2216
+ const child = spawn("node", [process.argv[1], "archangel", config.name], {
2114
2217
  detached: true,
2115
2218
  stdio: "ignore",
2116
2219
  cwd: process.cwd(),
2117
2220
  env,
2118
2221
  });
2119
2222
  child.unref();
2120
- console.log(`Starting daemon: ${config.name} (pid ${child.pid})${parentSession ? ` [parent: ${parentSession.session}]` : ""}`);
2223
+ console.log(
2224
+ `Summoning: ${config.name} (pid ${child.pid})${parentSession ? ` [parent: ${parentSession.session}]` : ""}`,
2225
+ );
2121
2226
  }
2122
2227
 
2123
2228
  // =============================================================================
2124
- // Command: daemon (runs as the daemon process itself)
2229
+ // Command: archangel (runs as the archangel process itself)
2125
2230
  // =============================================================================
2126
2231
 
2127
2232
  /**
2128
2233
  * @param {string | undefined} agentName
2129
2234
  */
2130
- async function cmdDaemon(agentName) {
2235
+ async function cmdArchangel(agentName) {
2131
2236
  if (!agentName) {
2132
- console.error("Usage: ./ax.js daemon <name>");
2237
+ console.error("Usage: ./ax.js archangel <name>");
2133
2238
  process.exit(1);
2134
2239
  }
2135
2240
  // Load agent config
2136
2241
  const configPath = path.join(AGENTS_DIR, `${agentName}.md`);
2137
2242
  if (!existsSync(configPath)) {
2138
- console.error(`[daemon:${agentName}] Config not found: ${configPath}`);
2243
+ console.error(`[archangel:${agentName}] Config not found: ${configPath}`);
2139
2244
  process.exit(1);
2140
2245
  }
2141
2246
 
2142
2247
  const content = readFileSync(configPath, "utf-8");
2143
2248
  const configResult = parseAgentConfig(`${agentName}.md`, content);
2144
2249
  if (!configResult || "error" in configResult) {
2145
- console.error(`[daemon:${agentName}] Invalid config`);
2250
+ console.error(`[archangel:${agentName}] Invalid config`);
2146
2251
  process.exit(1);
2147
2252
  }
2148
2253
  const config = configResult;
2149
2254
 
2150
2255
  const agent = config.tool === "claude" ? ClaudeAgent : CodexAgent;
2151
- const sessionName = generateDaemonSessionName(config);
2256
+ const sessionName = generateArchangelSessionName(config);
2152
2257
 
2153
2258
  // Check agent CLI is installed before trying to start
2154
2259
  const cliCheck = spawnSync("which", [agent.name], { encoding: "utf-8" });
2155
2260
  if (cliCheck.status !== 0) {
2156
- console.error(`[daemon:${agentName}] ERROR: ${agent.name} CLI is not installed or not in PATH`);
2261
+ console.error(
2262
+ `[archangel:${agentName}] ERROR: ${agent.name} CLI is not installed or not in PATH`,
2263
+ );
2157
2264
  process.exit(1);
2158
2265
  }
2159
2266
 
@@ -2163,7 +2270,7 @@ async function cmdDaemon(agentName) {
2163
2270
 
2164
2271
  // Wait for agent to be ready
2165
2272
  const start = Date.now();
2166
- while (Date.now() - start < DAEMON_STARTUP_TIMEOUT_MS) {
2273
+ while (Date.now() - start < ARCHANGEL_STARTUP_TIMEOUT_MS) {
2167
2274
  const screen = tmuxCapture(sessionName);
2168
2275
  const state = agent.getState(screen);
2169
2276
 
@@ -2174,7 +2281,7 @@ async function cmdDaemon(agentName) {
2174
2281
 
2175
2282
  // Handle bypass permissions confirmation dialog (Claude Code shows this for --dangerously-skip-permissions)
2176
2283
  if (screen.includes("Bypass Permissions mode") && screen.includes("Yes, I accept")) {
2177
- console.log(`[daemon:${agentName}] Accepting bypass permissions dialog`);
2284
+ console.log(`[archangel:${agentName}] Accepting bypass permissions dialog`);
2178
2285
  tmuxSend(sessionName, "2"); // Select "Yes, I accept"
2179
2286
  await sleep(300);
2180
2287
  tmuxSend(sessionName, "Enter");
@@ -2183,7 +2290,7 @@ async function cmdDaemon(agentName) {
2183
2290
  }
2184
2291
 
2185
2292
  if (state === State.READY) {
2186
- console.log(`[daemon:${agentName}] Started session: ${sessionName}`);
2293
+ console.log(`[archangel:${agentName}] Started session: ${sessionName}`);
2187
2294
  break;
2188
2295
  }
2189
2296
 
@@ -2215,7 +2322,7 @@ async function cmdDaemon(agentName) {
2215
2322
  isProcessing = true;
2216
2323
 
2217
2324
  const files = [...changedFiles];
2218
- changedFiles = new Set(); // atomic swap to avoid losing changes during processing
2325
+ changedFiles = new Set(); // atomic swap to avoid losing changes during processing
2219
2326
 
2220
2327
  try {
2221
2328
  // Get parent session log path for JSONL extraction
@@ -2224,7 +2331,8 @@ async function cmdDaemon(agentName) {
2224
2331
 
2225
2332
  // Build file-specific context from JSONL
2226
2333
  const fileContexts = [];
2227
- for (const file of files.slice(0, 5)) { // Limit to 5 files
2334
+ for (const file of files.slice(0, 5)) {
2335
+ // Limit to 5 files
2228
2336
  const ctx = extractFileEditContext(logPath, file);
2229
2337
  if (ctx) {
2230
2338
  fileContexts.push({ file, ...ctx });
@@ -2251,26 +2359,34 @@ async function cmdDaemon(agentName) {
2251
2359
  }
2252
2360
 
2253
2361
  if (ctx.readsBefore.length > 0) {
2254
- const reads = ctx.readsBefore.map(f => f.split("/").pop()).join(", ");
2362
+ const reads = ctx.readsBefore.map((f) => f.split("/").pop()).join(", ");
2255
2363
  prompt += `**Files read before:** ${reads}\n`;
2256
2364
  }
2257
2365
  }
2258
2366
 
2259
2367
  prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
2260
2368
 
2261
- const gitContext = buildGitContext(DAEMON_GIT_CONTEXT_HOURS, DAEMON_GIT_CONTEXT_MAX_LINES);
2369
+ const gitContext = buildGitContext(
2370
+ ARCHANGEL_GIT_CONTEXT_HOURS,
2371
+ ARCHANGEL_GIT_CONTEXT_MAX_LINES,
2372
+ );
2262
2373
  if (gitContext) {
2263
2374
  prompt += "\n\n## Git Context\n\n" + gitContext;
2264
2375
  }
2265
2376
 
2266
- 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."';
2377
+ prompt +=
2378
+ '\n\nReview these changes in the context of what the user is working on. Report any issues found. Keep your response concise.\nIf there are no significant issues, respond with just "No issues found."';
2267
2379
  } else {
2268
2380
  // Fallback: no JSONL context available, use conversation + git context
2269
- const parentContext = getParentSessionContext(DAEMON_PARENT_CONTEXT_ENTRIES);
2270
- const gitContext = buildGitContext(DAEMON_GIT_CONTEXT_HOURS, DAEMON_GIT_CONTEXT_MAX_LINES);
2381
+ const parentContext = getParentSessionContext(ARCHANGEL_PARENT_CONTEXT_ENTRIES);
2382
+ const gitContext = buildGitContext(
2383
+ ARCHANGEL_GIT_CONTEXT_HOURS,
2384
+ ARCHANGEL_GIT_CONTEXT_MAX_LINES,
2385
+ );
2271
2386
 
2272
2387
  if (parentContext) {
2273
- prompt += "\n\n## Main Session Context\n\nThe user is currently working on:\n\n" + parentContext;
2388
+ prompt +=
2389
+ "\n\n## Main Session Context\n\nThe user is currently working on:\n\n" + parentContext;
2274
2390
  }
2275
2391
 
2276
2392
  prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
@@ -2279,13 +2395,13 @@ async function cmdDaemon(agentName) {
2279
2395
  prompt += "\n\n## Git Context\n\n" + gitContext;
2280
2396
  }
2281
2397
 
2282
- 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."';
2398
+ prompt +=
2399
+ '\n\nReview these changes in the context of what the user is working on. Report any issues found. Keep your response concise.\nIf there are no significant issues, respond with just "No issues found."';
2283
2400
  }
2284
2401
 
2285
-
2286
2402
  // Check session still exists
2287
2403
  if (!tmuxHasSession(sessionName)) {
2288
- console.log(`[daemon:${agentName}] Session gone, exiting`);
2404
+ console.log(`[archangel:${agentName}] Session gone, exiting`);
2289
2405
  process.exit(0);
2290
2406
  }
2291
2407
 
@@ -2294,12 +2410,12 @@ async function cmdDaemon(agentName) {
2294
2410
  const state = agent.getState(screen);
2295
2411
 
2296
2412
  if (state === State.RATE_LIMITED) {
2297
- console.error(`[daemon:${agentName}] Rate limited - stopping`);
2413
+ console.error(`[archangel:${agentName}] Rate limited - stopping`);
2298
2414
  process.exit(2);
2299
2415
  }
2300
2416
 
2301
2417
  if (state !== State.READY) {
2302
- console.log(`[daemon:${agentName}] Agent not ready (${state}), skipping`);
2418
+ console.log(`[archangel:${agentName}] Agent not ready (${state}), skipping`);
2303
2419
  isProcessing = false;
2304
2420
  return;
2305
2421
  }
@@ -2311,22 +2427,30 @@ async function cmdDaemon(agentName) {
2311
2427
  await sleep(100); // Ensure Enter is processed
2312
2428
 
2313
2429
  // Wait for response
2314
- const { state: endState, screen: afterScreen } = await waitForResponse(agent, sessionName, DAEMON_RESPONSE_TIMEOUT_MS);
2430
+ const { state: endState, screen: afterScreen } = await waitForResponse(
2431
+ agent,
2432
+ sessionName,
2433
+ ARCHANGEL_RESPONSE_TIMEOUT_MS,
2434
+ );
2315
2435
 
2316
2436
  if (endState === State.RATE_LIMITED) {
2317
- console.error(`[daemon:${agentName}] Rate limited - stopping`);
2437
+ console.error(`[archangel:${agentName}] Rate limited - stopping`);
2318
2438
  process.exit(2);
2319
2439
  }
2320
2440
 
2321
-
2322
2441
  const cleanedResponse = agent.getResponse(sessionName, afterScreen) || "";
2323
2442
 
2324
2443
  // Sanity check: skip garbage responses (screen scraping artifacts)
2325
- const isGarbage = cleanedResponse.includes("[Pasted text") ||
2444
+ const isGarbage =
2445
+ cleanedResponse.includes("[Pasted text") ||
2326
2446
  cleanedResponse.match(/^\+\d+ lines\]/) ||
2327
2447
  cleanedResponse.length < 20;
2328
2448
 
2329
- if (cleanedResponse && !isGarbage && !cleanedResponse.toLowerCase().includes("no issues found")) {
2449
+ if (
2450
+ cleanedResponse &&
2451
+ !isGarbage &&
2452
+ !cleanedResponse.toLowerCase().includes("no issues found")
2453
+ ) {
2330
2454
  writeToMailbox({
2331
2455
  agent: /** @type {string} */ (agentName),
2332
2456
  session: sessionName,
@@ -2335,12 +2459,12 @@ async function cmdDaemon(agentName) {
2335
2459
  files,
2336
2460
  message: cleanedResponse.slice(0, 1000),
2337
2461
  });
2338
- console.log(`[daemon:${agentName}] Wrote observation for ${files.length} file(s)`);
2462
+ console.log(`[archangel:${agentName}] Wrote observation for ${files.length} file(s)`);
2339
2463
  } else if (isGarbage) {
2340
- console.log(`[daemon:${agentName}] Skipped garbage response`);
2464
+ console.log(`[archangel:${agentName}] Skipped garbage response`);
2341
2465
  }
2342
2466
  } catch (err) {
2343
- console.error(`[daemon:${agentName}] Error:`, err instanceof Error ? err.message : err);
2467
+ console.error(`[archangel:${agentName}] Error:`, err instanceof Error ? err.message : err);
2344
2468
  }
2345
2469
 
2346
2470
  isProcessing = false;
@@ -2348,7 +2472,10 @@ async function cmdDaemon(agentName) {
2348
2472
 
2349
2473
  function scheduleProcessChanges() {
2350
2474
  processChanges().catch((err) => {
2351
- console.error(`[daemon:${agentName}] Unhandled error:`, err instanceof Error ? err.message : err);
2475
+ console.error(
2476
+ `[archangel:${agentName}] Unhandled error:`,
2477
+ err instanceof Error ? err.message : err,
2478
+ );
2352
2479
  });
2353
2480
  }
2354
2481
 
@@ -2369,16 +2496,16 @@ async function cmdDaemon(agentName) {
2369
2496
  // Check if session still exists periodically
2370
2497
  const sessionCheck = setInterval(() => {
2371
2498
  if (!tmuxHasSession(sessionName)) {
2372
- console.log(`[daemon:${agentName}] Session gone, exiting`);
2499
+ console.log(`[archangel:${agentName}] Session gone, exiting`);
2373
2500
  stopWatching();
2374
2501
  clearInterval(sessionCheck);
2375
2502
  process.exit(0);
2376
2503
  }
2377
- }, DAEMON_HEALTH_CHECK_MS);
2504
+ }, ARCHANGEL_HEALTH_CHECK_MS);
2378
2505
 
2379
2506
  // Handle graceful shutdown
2380
2507
  process.on("SIGTERM", () => {
2381
- console.log(`[daemon:${agentName}] Received SIGTERM, shutting down`);
2508
+ console.log(`[archangel:${agentName}] Received SIGTERM, shutting down`);
2382
2509
  stopWatching();
2383
2510
  clearInterval(sessionCheck);
2384
2511
  tmuxSend(sessionName, "C-c");
@@ -2389,7 +2516,7 @@ async function cmdDaemon(agentName) {
2389
2516
  });
2390
2517
 
2391
2518
  process.on("SIGINT", () => {
2392
- console.log(`[daemon:${agentName}] Received SIGINT, shutting down`);
2519
+ console.log(`[archangel:${agentName}] Received SIGINT, shutting down`);
2393
2520
  stopWatching();
2394
2521
  clearInterval(sessionCheck);
2395
2522
  tmuxSend(sessionName, "C-c");
@@ -2399,48 +2526,33 @@ async function cmdDaemon(agentName) {
2399
2526
  }, 500);
2400
2527
  });
2401
2528
 
2402
- console.log(`[daemon:${agentName}] Watching: ${config.watch.join(", ")}`);
2529
+ console.log(`[archangel:${agentName}] Watching: ${config.watch.join(", ")}`);
2403
2530
 
2404
2531
  // Keep the process alive
2405
2532
  await new Promise(() => {});
2406
2533
  }
2407
2534
 
2408
2535
  /**
2409
- * @param {string} action
2410
- * @param {string | null} [daemonName]
2536
+ * @param {string | null} [name]
2411
2537
  */
2412
- async function cmdDaemons(action, daemonName = null) {
2413
- if (action !== "start" && action !== "stop" && action !== "init") {
2414
- console.log("Usage: ./ax.js daemons <start|stop|init> [name]");
2415
- process.exit(1);
2416
- }
2417
-
2418
- // Handle init action separately
2419
- if (action === "init") {
2420
- if (!daemonName) {
2421
- console.log("Usage: ./ax.js daemons init <name>");
2422
- console.log("Example: ./ax.js daemons init reviewer");
2423
- process.exit(1);
2424
- }
2425
-
2426
- // Validate name (alphanumeric, dashes, underscores only)
2427
- if (!/^[a-zA-Z0-9_-]+$/.test(daemonName)) {
2428
- console.log("ERROR: Daemon name must contain only letters, numbers, dashes, and underscores");
2429
- process.exit(1);
2430
- }
2538
+ async function cmdSummon(name = null) {
2539
+ const configs = loadAgentConfigs();
2431
2540
 
2432
- const agentPath = path.join(AGENTS_DIR, `${daemonName}.md`);
2433
- if (existsSync(agentPath)) {
2434
- console.log(`ERROR: Agent config already exists: ${agentPath}`);
2435
- process.exit(1);
2436
- }
2541
+ // If name provided but doesn't exist, create it
2542
+ if (name) {
2543
+ const exists = configs.some((c) => c.name === name);
2544
+ if (!exists) {
2545
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
2546
+ console.log("ERROR: Name must contain only letters, numbers, dashes, and underscores");
2547
+ process.exit(1);
2548
+ }
2437
2549
 
2438
- // Create agents directory if needed
2439
- if (!existsSync(AGENTS_DIR)) {
2440
- mkdirSync(AGENTS_DIR, { recursive: true });
2441
- }
2550
+ if (!existsSync(AGENTS_DIR)) {
2551
+ mkdirSync(AGENTS_DIR, { recursive: true });
2552
+ }
2442
2553
 
2443
- const template = `---
2554
+ const agentPath = path.join(AGENTS_DIR, `${name}.md`);
2555
+ const template = `---
2444
2556
  tool: claude
2445
2557
  watch: ["**/*.{ts,tsx,js,jsx,mjs,mts}"]
2446
2558
  interval: 30
@@ -2448,75 +2560,76 @@ interval: 30
2448
2560
 
2449
2561
  Review changed files for bugs, type errors, and edge cases.
2450
2562
  `;
2563
+ writeFileSync(agentPath, template);
2564
+ console.log(`Created: ${agentPath}`);
2565
+ console.log(`Edit the file to customize, then run: ax summon ${name}`);
2566
+ return;
2567
+ }
2568
+ }
2451
2569
 
2452
- writeFileSync(agentPath, template);
2453
- console.log(`Created agent config: ${agentPath}`);
2454
- console.log(`Edit the file to customize the daemon, then run: ./ax.js daemons start ${daemonName}`);
2570
+ if (configs.length === 0) {
2571
+ console.log(`No archangels found in ${AGENTS_DIR}/`);
2455
2572
  return;
2456
2573
  }
2457
2574
 
2575
+ const targetConfigs = name ? configs.filter((c) => c.name === name) : configs;
2576
+
2577
+ ensureMailboxHookScript();
2578
+
2579
+ const parentSession = findCurrentClaudeSession();
2580
+ if (parentSession) {
2581
+ console.log(`Parent session: ${parentSession.session || "(non-tmux)"} [${parentSession.uuid}]`);
2582
+ }
2583
+
2584
+ for (const config of targetConfigs) {
2585
+ const sessionPattern = getArchangelSessionPattern(config);
2586
+ const existing = findArchangelSession(sessionPattern);
2587
+
2588
+ if (!existing) {
2589
+ startArchangel(config, parentSession);
2590
+ } else {
2591
+ console.log(`Already running: ${config.name} (${existing})`);
2592
+ }
2593
+ }
2594
+
2595
+ gcMailbox(24);
2596
+ }
2597
+
2598
+ /**
2599
+ * @param {string | null} [name]
2600
+ */
2601
+ async function cmdRecall(name = null) {
2458
2602
  const configs = loadAgentConfigs();
2459
2603
 
2460
2604
  if (configs.length === 0) {
2461
- console.log(`No agent configs found in ${AGENTS_DIR}/`);
2605
+ console.log(`No archangels found in ${AGENTS_DIR}/`);
2462
2606
  return;
2463
2607
  }
2464
2608
 
2465
- // Filter to specific daemon if name provided
2466
- const targetConfigs = daemonName
2467
- ? configs.filter((c) => c.name === daemonName)
2468
- : configs;
2609
+ const targetConfigs = name ? configs.filter((c) => c.name === name) : configs;
2469
2610
 
2470
- if (daemonName && targetConfigs.length === 0) {
2471
- console.log(`ERROR: daemon '${daemonName}' not found in ${AGENTS_DIR}/`);
2611
+ if (name && targetConfigs.length === 0) {
2612
+ console.log(`ERROR: archangel '${name}' not found in ${AGENTS_DIR}/`);
2472
2613
  process.exit(1);
2473
2614
  }
2474
2615
 
2475
- // Ensure hook script exists on start
2476
- if (action === "start") {
2477
- ensureMailboxHookScript();
2478
- }
2616
+ for (const config of targetConfigs) {
2617
+ const sessionPattern = getArchangelSessionPattern(config);
2618
+ const existing = findArchangelSession(sessionPattern);
2479
2619
 
2480
- // Find current Claude session to pass as parent (if we're inside one)
2481
- const parentSession = action === "start" ? findCurrentClaudeSession() : null;
2482
- if (action === "start") {
2483
- if (parentSession) {
2484
- console.log(`Parent session: ${parentSession.session || "(non-tmux)"} [${parentSession.uuid}]`);
2620
+ if (existing) {
2621
+ tmuxSend(existing, "C-c");
2622
+ await sleep(300);
2623
+ tmuxKill(existing);
2624
+ console.log(`Recalled: ${config.name} (${existing})`);
2485
2625
  } else {
2486
- console.log("Parent session: null (not running from Claude or no active sessions)");
2487
- }
2488
- }
2489
-
2490
- for (const config of targetConfigs) {
2491
- const sessionPattern = getDaemonSessionPattern(config);
2492
- const existing = findDaemonSession(sessionPattern);
2493
-
2494
- if (action === "stop") {
2495
- if (existing) {
2496
- tmuxSend(existing, "C-c");
2497
- await sleep(300);
2498
- tmuxKill(existing);
2499
- console.log(`Stopped daemon: ${config.name} (${existing})`);
2500
- } else {
2501
- console.log(`Daemon not running: ${config.name}`);
2502
- }
2503
- } else if (action === "start") {
2504
- if (!existing) {
2505
- startDaemonAgent(config, parentSession);
2506
- } else {
2507
- console.log(`Daemon already running: ${config.name} (${existing})`);
2508
- }
2626
+ console.log(`Not running: ${config.name}`);
2509
2627
  }
2510
2628
  }
2511
-
2512
- // GC mailbox on start
2513
- if (action === "start") {
2514
- gcMailbox(24);
2515
- }
2516
2629
  }
2517
2630
 
2518
2631
  // Version of the hook script template - bump when making changes
2519
- const HOOK_SCRIPT_VERSION = "3";
2632
+ const HOOK_SCRIPT_VERSION = "4";
2520
2633
 
2521
2634
  function ensureMailboxHookScript() {
2522
2635
  const hooksDir = HOOKS_DIR;
@@ -2539,24 +2652,49 @@ ${versionMarker}
2539
2652
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
2540
2653
  import { dirname, join } from "node:path";
2541
2654
  import { fileURLToPath } from "node:url";
2655
+ import { createHash } from "node:crypto";
2542
2656
 
2543
2657
  const __dirname = dirname(fileURLToPath(import.meta.url));
2544
2658
  const AI_DIR = join(__dirname, "..");
2545
2659
  const DEBUG = process.env.AX_DEBUG === "1";
2546
2660
  const MAILBOX = join(AI_DIR, "mailbox.jsonl");
2547
- const LAST_SEEN = join(AI_DIR, "mailbox-last-seen");
2548
2661
  const MAX_AGE_MS = 60 * 60 * 1000;
2549
2662
 
2663
+ // Read hook input from stdin
2664
+ let hookInput = {};
2665
+ try {
2666
+ const stdinData = readFileSync(0, "utf-8").trim();
2667
+ if (stdinData) hookInput = JSON.parse(stdinData);
2668
+ } catch (err) {
2669
+ if (DEBUG) console.error("[hook] stdin parse:", err.message);
2670
+ }
2671
+
2672
+ const sessionId = hookInput.session_id || "";
2673
+ const hookEvent = hookInput.hook_event_name || "";
2674
+
2675
+ if (DEBUG) console.error("[hook] session:", sessionId, "event:", hookEvent);
2676
+
2677
+ // NO-OP for archangel or partner sessions
2678
+ if (sessionId.includes("-archangel-") || sessionId.includes("-partner-")) {
2679
+ if (DEBUG) console.error("[hook] skipping non-parent session");
2680
+ process.exit(0);
2681
+ }
2682
+
2683
+ // Per-session last-seen tracking (single JSON file, self-cleaning)
2684
+ const sessionHash = sessionId ? createHash("md5").update(sessionId).digest("hex").slice(0, 8) : "default";
2685
+ const LAST_SEEN_FILE = join(AI_DIR, "mailbox-last-seen.json");
2686
+
2550
2687
  if (!existsSync(MAILBOX)) process.exit(0);
2551
2688
 
2552
- let lastSeen = 0;
2689
+ let lastSeenMap = {};
2553
2690
  try {
2554
- if (existsSync(LAST_SEEN)) {
2555
- lastSeen = parseInt(readFileSync(LAST_SEEN, "utf-8").trim(), 10) || 0;
2691
+ if (existsSync(LAST_SEEN_FILE)) {
2692
+ lastSeenMap = JSON.parse(readFileSync(LAST_SEEN_FILE, "utf-8"));
2556
2693
  }
2557
2694
  } catch (err) {
2558
2695
  if (DEBUG) console.error("[hook] readLastSeen:", err.message);
2559
2696
  }
2697
+ const lastSeen = lastSeenMap[sessionHash] || 0;
2560
2698
 
2561
2699
  const now = Date.now();
2562
2700
  const lines = readFileSync(MAILBOX, "utf-8").trim().split("\\n").filter(Boolean);
@@ -2578,21 +2716,39 @@ for (const line of lines) {
2578
2716
  }
2579
2717
 
2580
2718
  if (relevant.length > 0) {
2581
- console.log("## Background Agents");
2582
- console.log("");
2583
- console.log("Background agents watching your files found:");
2584
- console.log("");
2585
2719
  const sessionPrefixes = new Set();
2720
+ let messageLines = [];
2721
+ messageLines.push("## Background Agents");
2722
+ messageLines.push("");
2723
+ messageLines.push("Background agents watching your files found:");
2724
+ messageLines.push("");
2586
2725
  for (const { agent, sessionPrefix, message } of relevant) {
2587
2726
  if (sessionPrefix) sessionPrefixes.add(sessionPrefix);
2588
- console.log("**[" + agent + "]**");
2589
- console.log("");
2590
- console.log(message);
2591
- console.log("");
2727
+ messageLines.push("**[" + agent + "]**");
2728
+ messageLines.push("");
2729
+ messageLines.push(message);
2730
+ messageLines.push("");
2592
2731
  }
2593
2732
  const sessionList = [...sessionPrefixes].map(s => "\\\`./ax.js log " + s + "\\\`").join(" or ");
2594
- console.log("> For more context: \\\`./ax.js mailbox\\\`" + (sessionList ? " or " + sessionList : ""));
2595
- writeFileSync(LAST_SEEN, now.toString());
2733
+ messageLines.push("> For more context: \\\`./ax.js mailbox\\\`" + (sessionList ? " or " + sessionList : ""));
2734
+
2735
+ const formattedMessage = messageLines.join("\\n");
2736
+
2737
+ // For Stop hook, return blocking JSON to force acknowledgment
2738
+ if (hookEvent === "Stop") {
2739
+ console.log(JSON.stringify({ decision: "block", reason: formattedMessage }));
2740
+ } else {
2741
+ // For other hooks, just output the context
2742
+ console.log(formattedMessage);
2743
+ }
2744
+
2745
+ // Update last-seen and prune entries older than 24 hours
2746
+ const PRUNE_AGE_MS = 24 * 60 * 60 * 1000;
2747
+ lastSeenMap[sessionHash] = now;
2748
+ for (const key of Object.keys(lastSeenMap)) {
2749
+ if (now - lastSeenMap[key] > PRUNE_AGE_MS) delete lastSeenMap[key];
2750
+ }
2751
+ writeFileSync(LAST_SEEN_FILE, JSON.stringify(lastSeenMap));
2596
2752
  }
2597
2753
 
2598
2754
  process.exit(0);
@@ -2607,18 +2763,9 @@ process.exit(0);
2607
2763
  console.log(`\nTo enable manually, add to .claude/settings.json:\n`);
2608
2764
  console.log(`{
2609
2765
  "hooks": {
2610
- "UserPromptSubmit": [
2611
- {
2612
- "matcher": "",
2613
- "hooks": [
2614
- {
2615
- "type": "command",
2616
- "command": "node .ai/hooks/mailbox-inject.js",
2617
- "timeout": 5
2618
- }
2619
- ]
2620
- }
2621
- ]
2766
+ "UserPromptSubmit": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }],
2767
+ "PostToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }],
2768
+ "Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "node .ai/hooks/mailbox-inject.js", "timeout": 5 }] }]
2622
2769
  }
2623
2770
  }`);
2624
2771
  }
@@ -2628,6 +2775,7 @@ function ensureClaudeHookConfig() {
2628
2775
  const settingsDir = ".claude";
2629
2776
  const settingsPath = path.join(settingsDir, "settings.json");
2630
2777
  const hookCommand = "node .ai/hooks/mailbox-inject.js";
2778
+ const hookEvents = ["UserPromptSubmit", "PostToolUse", "Stop"];
2631
2779
 
2632
2780
  try {
2633
2781
  /** @type {ClaudeSettings} */
@@ -2646,33 +2794,41 @@ function ensureClaudeHookConfig() {
2646
2794
 
2647
2795
  // Ensure hooks structure exists
2648
2796
  if (!settings.hooks) settings.hooks = {};
2649
- if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
2650
-
2651
- // Check if our hook is already configured
2652
- const hookExists = settings.hooks.UserPromptSubmit.some(
2653
- /** @param {{hooks?: Array<{command: string}>}} entry */
2654
- (entry) => entry.hooks?.some(/** @param {{command: string}} h */ (h) => h.command === hookCommand)
2655
- );
2656
2797
 
2657
- if (hookExists) {
2658
- return true; // Already configured
2798
+ let anyAdded = false;
2799
+
2800
+ for (const eventName of hookEvents) {
2801
+ if (!settings.hooks[eventName]) settings.hooks[eventName] = [];
2802
+
2803
+ // Check if our hook is already configured for this event
2804
+ const hookExists = settings.hooks[eventName].some(
2805
+ /** @param {{hooks?: Array<{command: string}>}} entry */
2806
+ (entry) =>
2807
+ entry.hooks?.some(/** @param {{command: string}} h */ (h) => h.command === hookCommand),
2808
+ );
2809
+
2810
+ if (!hookExists) {
2811
+ // Add the hook for this event
2812
+ settings.hooks[eventName].push({
2813
+ matcher: "",
2814
+ hooks: [
2815
+ {
2816
+ type: "command",
2817
+ command: hookCommand,
2818
+ timeout: 5,
2819
+ },
2820
+ ],
2821
+ });
2822
+ anyAdded = true;
2823
+ }
2659
2824
  }
2660
2825
 
2661
- // Add the hook
2662
- settings.hooks.UserPromptSubmit.push({
2663
- matcher: "",
2664
- hooks: [
2665
- {
2666
- type: "command",
2667
- command: hookCommand,
2668
- timeout: 5,
2669
- },
2670
- ],
2671
- });
2826
+ if (anyAdded) {
2827
+ // Write settings
2828
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2829
+ console.log(`Configured hooks in: ${settingsPath}`);
2830
+ }
2672
2831
 
2673
- // Write settings
2674
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2675
- console.log(`Configured hook in: ${settingsPath}`);
2676
2832
  return true;
2677
2833
  } catch {
2678
2834
  // If we can't configure automatically, return false so manual instructions are shown
@@ -2744,7 +2900,9 @@ function cmdAttach(session) {
2744
2900
  }
2745
2901
 
2746
2902
  // Hand over to tmux attach
2747
- const result = spawnSync("tmux", ["attach", "-t", resolved], { stdio: "inherit" });
2903
+ const result = spawnSync("tmux", ["attach", "-t", resolved], {
2904
+ stdio: "inherit",
2905
+ });
2748
2906
  process.exit(result.status || 0);
2749
2907
  }
2750
2908
 
@@ -2803,13 +2961,15 @@ function cmdLog(sessionName, { tail = 50, reasoning = false, follow = false } =
2803
2961
 
2804
2962
  if (newLines.length === 0) return;
2805
2963
 
2806
- const entries = newLines.map((line) => {
2807
- try {
2808
- return JSON.parse(line);
2809
- } catch {
2810
- return null;
2811
- }
2812
- }).filter(Boolean);
2964
+ const entries = newLines
2965
+ .map((line) => {
2966
+ try {
2967
+ return JSON.parse(line);
2968
+ } catch {
2969
+ return null;
2970
+ }
2971
+ })
2972
+ .filter(Boolean);
2813
2973
 
2814
2974
  const output = [];
2815
2975
  if (isInitial) {
@@ -2822,7 +2982,10 @@ function cmdLog(sessionName, { tail = 50, reasoning = false, follow = false } =
2822
2982
  const ts = entry.timestamp || entry.ts || entry.createdAt;
2823
2983
  if (ts && ts !== lastTimestamp) {
2824
2984
  const date = new Date(ts);
2825
- const timeStr = date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
2985
+ const timeStr = date.toLocaleTimeString("en-GB", {
2986
+ hour: "2-digit",
2987
+ minute: "2-digit",
2988
+ });
2826
2989
  if (formatted.isUserMessage) {
2827
2990
  output.push(`\n### ${timeStr}\n`);
2828
2991
  }
@@ -2872,7 +3035,10 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2872
3035
  if (type === "user" || type === "human") {
2873
3036
  const text = extractTextContent(content);
2874
3037
  if (text) {
2875
- return { text: `**User**: ${truncate(text, TRUNCATE_USER_LEN)}\n`, isUserMessage: true };
3038
+ return {
3039
+ text: `**User**: ${truncate(text, TRUNCATE_USER_LEN)}\n`,
3040
+ isUserMessage: true,
3041
+ };
2876
3042
  }
2877
3043
  }
2878
3044
 
@@ -2888,10 +3054,12 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2888
3054
  // Extract tool calls (compressed)
2889
3055
  const tools = extractToolCalls(content);
2890
3056
  if (tools.length > 0) {
2891
- const toolSummary = tools.map((t) => {
2892
- if (t.error) return `${t.name}(${t.target}) ✗`;
2893
- return `${t.name}(${t.target})`;
2894
- }).join(", ");
3057
+ const toolSummary = tools
3058
+ .map((t) => {
3059
+ if (t.error) return `${t.name}(${t.target}) ✗`;
3060
+ return `${t.name}(${t.target})`;
3061
+ })
3062
+ .join(", ");
2895
3063
  parts.push(`> ${toolSummary}\n`);
2896
3064
  }
2897
3065
 
@@ -2913,7 +3081,10 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2913
3081
  const error = entry.error || entry.is_error;
2914
3082
  if (error) {
2915
3083
  const name = entry.tool_name || entry.name || "tool";
2916
- return { text: `> ${name} ✗ (${truncate(String(error), 100)})\n`, isUserMessage: false };
3084
+ return {
3085
+ text: `> ${name} ✗ (${truncate(String(error), 100)})\n`,
3086
+ isUserMessage: false,
3087
+ };
2917
3088
  }
2918
3089
  }
2919
3090
 
@@ -2950,7 +3121,8 @@ function extractToolCalls(content) {
2950
3121
  const name = c.name || c.tool || "tool";
2951
3122
  const input = c.input || c.arguments || {};
2952
3123
  // Extract a reasonable target from the input
2953
- const target = input.file_path || input.path || input.command?.slice(0, 30) || input.pattern || "";
3124
+ const target =
3125
+ input.file_path || input.path || input.command?.slice(0, 30) || input.pattern || "";
2954
3126
  const shortTarget = target.split("/").pop() || target.slice(0, 20);
2955
3127
  return { name, target: shortTarget, error: c.error };
2956
3128
  });
@@ -2995,8 +3167,14 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
2995
3167
 
2996
3168
  for (const entry of entries) {
2997
3169
  const ts = new Date(entry.timestamp);
2998
- const timeStr = ts.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
2999
- const dateStr = ts.toLocaleDateString("en-GB", { month: "short", day: "numeric" });
3170
+ const timeStr = ts.toLocaleTimeString("en-GB", {
3171
+ hour: "2-digit",
3172
+ minute: "2-digit",
3173
+ });
3174
+ const dateStr = ts.toLocaleDateString("en-GB", {
3175
+ month: "short",
3176
+ day: "numeric",
3177
+ });
3000
3178
  const p = entry.payload || {};
3001
3179
 
3002
3180
  console.log(`### [${p.agent || "unknown"}] ${dateStr} ${timeStr}\n`);
@@ -3037,7 +3215,9 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3037
3215
  }
3038
3216
 
3039
3217
  /** @type {string} */
3040
- const activeSession = sessionExists ? /** @type {string} */ (session) : await cmdStart(agent, session, { yolo });
3218
+ const activeSession = sessionExists
3219
+ ? /** @type {string} */ (session)
3220
+ : await cmdStart(agent, session, { yolo });
3041
3221
 
3042
3222
  tmuxSendLiteral(activeSession, message);
3043
3223
  await sleep(50);
@@ -3138,7 +3318,13 @@ async function cmdReject(agent, session, { wait = false, timeoutMs } = {}) {
3138
3318
  * @param {string | null | undefined} customInstructions
3139
3319
  * @param {{wait?: boolean, yolo?: boolean, fresh?: boolean, timeoutMs?: number}} [options]
3140
3320
  */
3141
- async function cmdReview(agent, session, option, customInstructions, { wait = true, yolo = true, fresh = false, timeoutMs = REVIEW_TIMEOUT_MS } = {}) {
3321
+ async function cmdReview(
3322
+ agent,
3323
+ session,
3324
+ option,
3325
+ customInstructions,
3326
+ { wait = true, yolo = true, fresh = false, timeoutMs = REVIEW_TIMEOUT_MS } = {},
3327
+ ) {
3142
3328
  const sessionExists = session != null && tmuxHasSession(session);
3143
3329
 
3144
3330
  // Reset conversation if --fresh and session exists
@@ -3164,7 +3350,11 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3164
3350
 
3165
3351
  // AX_REVIEW_MODE=exec: bypass /review command, send instructions directly
3166
3352
  if (process.env.AX_REVIEW_MODE === "exec" && option === "custom" && customInstructions) {
3167
- return cmdAsk(agent, session, customInstructions, { noWait: !wait, yolo, timeoutMs });
3353
+ return cmdAsk(agent, session, customInstructions, {
3354
+ noWait: !wait,
3355
+ yolo,
3356
+ timeoutMs,
3357
+ });
3168
3358
  }
3169
3359
  const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
3170
3360
 
@@ -3176,7 +3366,9 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3176
3366
  }
3177
3367
 
3178
3368
  /** @type {string} */
3179
- const activeSession = sessionExists ? /** @type {string} */ (session) : await cmdStart(agent, session, { yolo });
3369
+ const activeSession = sessionExists
3370
+ ? /** @type {string} */ (session)
3371
+ : await cmdStart(agent, session, { yolo });
3180
3372
 
3181
3373
  tmuxSendLiteral(activeSession, "/review");
3182
3374
  await sleep(50);
@@ -3437,7 +3629,7 @@ function getAgentFromInvocation() {
3437
3629
  */
3438
3630
  function printHelp(agent, cliName) {
3439
3631
  const name = cliName;
3440
- const backendName = agent.name === "codex" ? "OpenAI Codex" : "Claude";
3632
+ const backendName = agent.name === "codex" ? "Codex" : "Claude";
3441
3633
  const hasReview = !!agent.reviewOptions;
3442
3634
 
3443
3635
  console.log(`${name} v${VERSION} - agentic assistant CLI (${backendName})
@@ -3446,14 +3638,18 @@ Commands:
3446
3638
  agents List all running agents with state and log paths
3447
3639
  attach [SESSION] Attach to agent session interactively
3448
3640
  log SESSION View conversation log (--tail=N, --follow, --reasoning)
3449
- mailbox View daemon observations (--limit=N, --branch=X, --all)
3450
- daemons start [name] Start daemon agents (all, or by name)
3451
- daemons stop [name] Stop daemon agents (all, or by name)
3641
+ mailbox View archangel observations (--limit=N, --branch=X, --all)
3642
+ summon [name] Summon archangels (all, or by name)
3643
+ recall [name] Recall archangels (all, or by name)
3452
3644
  kill Kill sessions in current project (--all for all, --session=NAME for one)
3453
3645
  status Check state (exit: 0=ready, 2=rate_limited, 3=confirming, 4=thinking)
3454
3646
  output [-N] Show response (0=last, -1=prev, -2=older)
3455
- debug Show raw screen output and detected state${hasReview ? `
3456
- review [TYPE] Review code: pr, uncommitted, commit, custom` : ""}
3647
+ debug Show raw screen output and detected state${
3648
+ hasReview
3649
+ ? `
3650
+ review [TYPE] Review code: pr, uncommitted, commit, custom`
3651
+ : ""
3652
+ }
3457
3653
  select N Select menu option N
3458
3654
  approve Approve pending action (send 'y')
3459
3655
  reject Reject pending action (send 'n')
@@ -3464,7 +3660,7 @@ Commands:
3464
3660
 
3465
3661
  Flags:
3466
3662
  --tool=NAME Use specific agent (codex, claude)
3467
- --session=NAME Target session by name, daemon name, or UUID prefix (self = current)
3663
+ --session=NAME Target session by name, archangel name, or UUID prefix (self = current)
3468
3664
  --wait Wait for response (for review, approve, etc)
3469
3665
  --no-wait Don't wait (for messages, which wait by default)
3470
3666
  --timeout=N Set timeout in seconds (default: 120)
@@ -3472,29 +3668,28 @@ Flags:
3472
3668
  --fresh Reset conversation before review
3473
3669
 
3474
3670
  Environment:
3475
- AX_DEFAULT_TOOL Default agent when using 'ax' (claude or codex, default: codex)
3476
- ${agent.envVar} Override default session name
3477
- AX_CLAUDE_CONFIG_DIR Override Claude config directory (default: ~/.claude)
3478
- AX_CODEX_CONFIG_DIR Override Codex config directory (default: ~/.codex)
3479
- AX_REVIEW_MODE=exec Bypass /review, send instructions directly (codex only)
3480
- AX_DEBUG=1 Enable debug logging
3671
+ AX_DEFAULT_TOOL Default agent when using 'ax' (claude or codex, default: codex)
3672
+ ${agent.envVar} Override default session name
3673
+ AX_CLAUDE_CONFIG_DIR Override Claude config directory (default: ~/.claude)
3674
+ AX_CODEX_CONFIG_DIR Override Codex config directory (default: ~/.codex)
3675
+ AX_REVIEW_MODE=exec Bypass /review, send instructions directly (codex only)
3676
+ AX_DEBUG=1 Enable debug logging
3481
3677
 
3482
3678
  Examples:
3483
- ./${name}.js "explain this codebase"
3484
- ./${name}.js "please review the error handling" # Auto custom review
3485
- ./${name}.js review uncommitted --wait
3486
- ./${name}.js approve --wait
3487
- ./${name}.js kill # Kill agents in current project
3488
- ./${name}.js kill --all # Kill all agents across all projects
3489
- ./${name}.js kill --session=NAME # Kill specific session
3490
- ./${name}.js send "1[Enter]" # Recovery: select option 1 and press Enter
3491
- ./${name}.js send "[Escape][Escape]" # Recovery: escape out of a dialog
3492
-
3493
- Daemon Agents:
3494
- ./${name}.js daemons start # Start all daemons from .ai/agents/*.md
3495
- ./${name}.js daemons stop # Stop all daemons
3496
- ./${name}.js daemons init <name> # Create new daemon config
3497
- ./${name}.js agents # List all agents (shows TYPE=daemon)`);
3679
+ ${name} "explain this codebase"
3680
+ ${name} "please review the error handling" # Auto custom review
3681
+ ${name} review uncommitted --wait
3682
+ ${name} approve --wait
3683
+ ${name} kill # Kill agents in current project
3684
+ ${name} kill --all # Kill all agents across all projects
3685
+ ${name} kill --session=NAME # Kill specific session
3686
+ ${name} send "1[Enter]" # Recovery: select option 1 and press Enter
3687
+ ${name} send "[Escape][Escape]" # Recovery: escape out of a dialog
3688
+ ${name} summon # Summon all archangels from .ai/agents/*.md
3689
+ ${name} summon reviewer # Summon by name (creates config if new)
3690
+ ${name} recall # Recall all archangels
3691
+ ${name} recall reviewer # Recall one by name
3692
+ ${name} agents # List all agents (shows TYPE=archangel)`);
3498
3693
  }
3499
3694
 
3500
3695
  async function main() {
@@ -3548,7 +3743,7 @@ async function main() {
3548
3743
  }
3549
3744
  session = current;
3550
3745
  } else {
3551
- // Resolve partial names, daemon names, and UUID prefixes
3746
+ // Resolve partial names, archangel names, and UUID prefixes
3552
3747
  session = resolveSessionName(val);
3553
3748
  }
3554
3749
  }
@@ -3598,21 +3793,28 @@ async function main() {
3598
3793
  !a.startsWith("--tool") &&
3599
3794
  !a.startsWith("--tail") &&
3600
3795
  !a.startsWith("--limit") &&
3601
- !a.startsWith("--branch")
3796
+ !a.startsWith("--branch"),
3602
3797
  );
3603
3798
  const cmd = filteredArgs[0];
3604
3799
 
3605
3800
  // Dispatch commands
3606
3801
  if (cmd === "agents") return cmdAgents();
3607
- if (cmd === "daemons") return cmdDaemons(filteredArgs[1], filteredArgs[2]);
3608
- if (cmd === "daemon") return cmdDaemon(filteredArgs[1]);
3802
+ if (cmd === "summon") return cmdSummon(filteredArgs[1]);
3803
+ if (cmd === "recall") return cmdRecall(filteredArgs[1]);
3804
+ if (cmd === "archangel") return cmdArchangel(filteredArgs[1]);
3609
3805
  if (cmd === "kill") return cmdKill(session, { all });
3610
3806
  if (cmd === "attach") return cmdAttach(filteredArgs[1] || session);
3611
3807
  if (cmd === "log") return cmdLog(filteredArgs[1] || session, { tail, reasoning, follow });
3612
3808
  if (cmd === "mailbox") return cmdMailbox({ limit, branch, all });
3613
3809
  if (cmd === "approve") return cmdApprove(agent, session, { wait, timeoutMs });
3614
3810
  if (cmd === "reject") return cmdReject(agent, session, { wait, timeoutMs });
3615
- if (cmd === "review") return cmdReview(agent, session, filteredArgs[1], filteredArgs[2], { wait, yolo, fresh, timeoutMs });
3811
+ if (cmd === "review")
3812
+ return cmdReview(agent, session, filteredArgs[1], filteredArgs[2], {
3813
+ wait,
3814
+ yolo,
3815
+ fresh,
3816
+ timeoutMs,
3817
+ });
3616
3818
  if (cmd === "status") return cmdStatus(agent, session);
3617
3819
  if (cmd === "debug") return cmdDebug(agent, session);
3618
3820
  if (cmd === "output") {
@@ -3620,10 +3822,12 @@ async function main() {
3620
3822
  const index = indexArg?.startsWith("-") ? parseInt(indexArg, 10) : 0;
3621
3823
  return cmdOutput(agent, session, index, { wait, timeoutMs });
3622
3824
  }
3623
- if (cmd === "send" && filteredArgs.length > 1) return cmdSend(session, filteredArgs.slice(1).join(" "));
3825
+ if (cmd === "send" && filteredArgs.length > 1)
3826
+ return cmdSend(session, filteredArgs.slice(1).join(" "));
3624
3827
  if (cmd === "compact") return cmdAsk(agent, session, "/compact", { noWait: true, timeoutMs });
3625
3828
  if (cmd === "reset") return cmdAsk(agent, session, "/new", { noWait: true, timeoutMs });
3626
- if (cmd === "select" && filteredArgs[1]) return cmdSelect(agent, session, filteredArgs[1], { wait, timeoutMs });
3829
+ if (cmd === "select" && filteredArgs[1])
3830
+ return cmdSelect(agent, session, filteredArgs[1], { wait, timeoutMs });
3627
3831
 
3628
3832
  // Default: send message
3629
3833
  let message = filteredArgs.join(" ");
@@ -3640,7 +3844,11 @@ async function main() {
3640
3844
  const reviewMatch = message.match(/^please review\s*(.*)/i);
3641
3845
  if (reviewMatch && agent.reviewOptions) {
3642
3846
  const customInstructions = reviewMatch[1].trim() || null;
3643
- return cmdReview(agent, session, "custom", customInstructions, { wait: !noWait, yolo, timeoutMs });
3847
+ return cmdReview(agent, session, "custom", customInstructions, {
3848
+ wait: !noWait,
3849
+ yolo,
3850
+ timeoutMs,
3851
+ });
3644
3852
  }
3645
3853
 
3646
3854
  return cmdAsk(agent, session, message, { noWait, yolo, timeoutMs });
@@ -3648,13 +3856,15 @@ async function main() {
3648
3856
 
3649
3857
  // Run main() only when executed directly (not when imported for testing)
3650
3858
  // Use realpathSync to handle symlinks (e.g., axclaude, axcodex bin entries)
3651
- const isDirectRun = process.argv[1] && (() => {
3652
- try {
3653
- return realpathSync(process.argv[1]) === __filename;
3654
- } catch {
3655
- return false;
3656
- }
3657
- })();
3859
+ const isDirectRun =
3860
+ process.argv[1] &&
3861
+ (() => {
3862
+ try {
3863
+ return realpathSync(process.argv[1]) === __filename;
3864
+ } catch {
3865
+ return false;
3866
+ }
3867
+ })();
3658
3868
  if (isDirectRun) {
3659
3869
  main().catch((err) => {
3660
3870
  console.log(`ERROR: ${err.message}`);
@@ -3678,4 +3888,3 @@ export {
3678
3888
  detectState,
3679
3889
  State,
3680
3890
  };
3681
-