ax-agents 0.0.1-alpha.5 → 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 +6 -2
  2. package/ax.js +448 -224
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # ax-agents
2
2
 
3
- A CLI for orchestrating AI coding agents via `tmux`.
3
+ <p align="center">
4
+ <img src="assets/luca-giordano-the-fall-of-the-rebel-angels.jpg" alt="The Fall of the Rebel Angels by Luca Giordano" width="250">
5
+ <br><br>
6
+ <strong>A CLI for orchestrating AI coding agents via `tmux`.</strong>
7
+ </p>
4
8
 
5
- Running agents in tmux sessions makes it easy to monitor multiple agents, review their work, and interact with them when needed.
9
+ Running agents in `tmux` sessions makes it easy to monitor multiple agents, review their work, and interact with them when needed.
6
10
 
7
11
  ## Install
8
12
 
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";
@@ -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,8 +282,14 @@ 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 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
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
269
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);
@@ -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);
@@ -360,13 +386,17 @@ function parseSessionName(session) {
360
386
  const rest = match[2];
361
387
 
362
388
  // 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);
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
+ );
364
392
  if (archangelMatch) {
365
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.
@@ -622,7 +665,7 @@ function loadAgentConfigs() {
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
  }
@@ -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
 
@@ -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
  /**
@@ -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);
@@ -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
 
@@ -2063,11 +2169,11 @@ 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
  }
@@ -2114,7 +2220,9 @@ function startArchangel(config, parentSession = null) {
2114
2220
  env,
2115
2221
  });
2116
2222
  child.unref();
2117
- console.log(`Summoning: ${config.name} (pid ${child.pid})${parentSession ? ` [parent: ${parentSession.session}]` : ""}`);
2223
+ console.log(
2224
+ `Summoning: ${config.name} (pid ${child.pid})${parentSession ? ` [parent: ${parentSession.session}]` : ""}`,
2225
+ );
2118
2226
  }
2119
2227
 
2120
2228
  // =============================================================================
@@ -2150,7 +2258,9 @@ async function cmdArchangel(agentName) {
2150
2258
  // Check agent CLI is installed before trying to start
2151
2259
  const cliCheck = spawnSync("which", [agent.name], { encoding: "utf-8" });
2152
2260
  if (cliCheck.status !== 0) {
2153
- console.error(`[archangel:${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
+ );
2154
2264
  process.exit(1);
2155
2265
  }
2156
2266
 
@@ -2212,7 +2322,7 @@ async function cmdArchangel(agentName) {
2212
2322
  isProcessing = true;
2213
2323
 
2214
2324
  const files = [...changedFiles];
2215
- changedFiles = new Set(); // atomic swap to avoid losing changes during processing
2325
+ changedFiles = new Set(); // atomic swap to avoid losing changes during processing
2216
2326
 
2217
2327
  try {
2218
2328
  // Get parent session log path for JSONL extraction
@@ -2221,7 +2331,8 @@ async function cmdArchangel(agentName) {
2221
2331
 
2222
2332
  // Build file-specific context from JSONL
2223
2333
  const fileContexts = [];
2224
- 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
2225
2336
  const ctx = extractFileEditContext(logPath, file);
2226
2337
  if (ctx) {
2227
2338
  fileContexts.push({ file, ...ctx });
@@ -2248,26 +2359,34 @@ async function cmdArchangel(agentName) {
2248
2359
  }
2249
2360
 
2250
2361
  if (ctx.readsBefore.length > 0) {
2251
- const reads = ctx.readsBefore.map(f => f.split("/").pop()).join(", ");
2362
+ const reads = ctx.readsBefore.map((f) => f.split("/").pop()).join(", ");
2252
2363
  prompt += `**Files read before:** ${reads}\n`;
2253
2364
  }
2254
2365
  }
2255
2366
 
2256
2367
  prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
2257
2368
 
2258
- const gitContext = buildGitContext(ARCHANGEL_GIT_CONTEXT_HOURS, ARCHANGEL_GIT_CONTEXT_MAX_LINES);
2369
+ const gitContext = buildGitContext(
2370
+ ARCHANGEL_GIT_CONTEXT_HOURS,
2371
+ ARCHANGEL_GIT_CONTEXT_MAX_LINES,
2372
+ );
2259
2373
  if (gitContext) {
2260
2374
  prompt += "\n\n## Git Context\n\n" + gitContext;
2261
2375
  }
2262
2376
 
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."';
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."';
2264
2379
  } else {
2265
2380
  // Fallback: no JSONL context available, use conversation + git context
2266
2381
  const parentContext = getParentSessionContext(ARCHANGEL_PARENT_CONTEXT_ENTRIES);
2267
- const gitContext = buildGitContext(ARCHANGEL_GIT_CONTEXT_HOURS, ARCHANGEL_GIT_CONTEXT_MAX_LINES);
2382
+ const gitContext = buildGitContext(
2383
+ ARCHANGEL_GIT_CONTEXT_HOURS,
2384
+ ARCHANGEL_GIT_CONTEXT_MAX_LINES,
2385
+ );
2268
2386
 
2269
2387
  if (parentContext) {
2270
- 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;
2271
2390
  }
2272
2391
 
2273
2392
  prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
@@ -2276,10 +2395,10 @@ async function cmdArchangel(agentName) {
2276
2395
  prompt += "\n\n## Git Context\n\n" + gitContext;
2277
2396
  }
2278
2397
 
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."';
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."';
2280
2400
  }
2281
2401
 
2282
-
2283
2402
  // Check session still exists
2284
2403
  if (!tmuxHasSession(sessionName)) {
2285
2404
  console.log(`[archangel:${agentName}] Session gone, exiting`);
@@ -2308,22 +2427,30 @@ async function cmdArchangel(agentName) {
2308
2427
  await sleep(100); // Ensure Enter is processed
2309
2428
 
2310
2429
  // Wait for response
2311
- const { state: endState, screen: afterScreen } = await waitForResponse(agent, sessionName, ARCHANGEL_RESPONSE_TIMEOUT_MS);
2430
+ const { state: endState, screen: afterScreen } = await waitForResponse(
2431
+ agent,
2432
+ sessionName,
2433
+ ARCHANGEL_RESPONSE_TIMEOUT_MS,
2434
+ );
2312
2435
 
2313
2436
  if (endState === State.RATE_LIMITED) {
2314
2437
  console.error(`[archangel:${agentName}] Rate limited - stopping`);
2315
2438
  process.exit(2);
2316
2439
  }
2317
2440
 
2318
-
2319
2441
  const cleanedResponse = agent.getResponse(sessionName, afterScreen) || "";
2320
2442
 
2321
2443
  // Sanity check: skip garbage responses (screen scraping artifacts)
2322
- const isGarbage = cleanedResponse.includes("[Pasted text") ||
2444
+ const isGarbage =
2445
+ cleanedResponse.includes("[Pasted text") ||
2323
2446
  cleanedResponse.match(/^\+\d+ lines\]/) ||
2324
2447
  cleanedResponse.length < 20;
2325
2448
 
2326
- if (cleanedResponse && !isGarbage && !cleanedResponse.toLowerCase().includes("no issues found")) {
2449
+ if (
2450
+ cleanedResponse &&
2451
+ !isGarbage &&
2452
+ !cleanedResponse.toLowerCase().includes("no issues found")
2453
+ ) {
2327
2454
  writeToMailbox({
2328
2455
  agent: /** @type {string} */ (agentName),
2329
2456
  session: sessionName,
@@ -2345,7 +2472,10 @@ async function cmdArchangel(agentName) {
2345
2472
 
2346
2473
  function scheduleProcessChanges() {
2347
2474
  processChanges().catch((err) => {
2348
- console.error(`[archangel:${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
+ );
2349
2479
  });
2350
2480
  }
2351
2481
 
@@ -2499,7 +2629,7 @@ async function cmdRecall(name = null) {
2499
2629
  }
2500
2630
 
2501
2631
  // Version of the hook script template - bump when making changes
2502
- const HOOK_SCRIPT_VERSION = "3";
2632
+ const HOOK_SCRIPT_VERSION = "4";
2503
2633
 
2504
2634
  function ensureMailboxHookScript() {
2505
2635
  const hooksDir = HOOKS_DIR;
@@ -2522,24 +2652,49 @@ ${versionMarker}
2522
2652
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
2523
2653
  import { dirname, join } from "node:path";
2524
2654
  import { fileURLToPath } from "node:url";
2655
+ import { createHash } from "node:crypto";
2525
2656
 
2526
2657
  const __dirname = dirname(fileURLToPath(import.meta.url));
2527
2658
  const AI_DIR = join(__dirname, "..");
2528
2659
  const DEBUG = process.env.AX_DEBUG === "1";
2529
2660
  const MAILBOX = join(AI_DIR, "mailbox.jsonl");
2530
- const LAST_SEEN = join(AI_DIR, "mailbox-last-seen");
2531
2661
  const MAX_AGE_MS = 60 * 60 * 1000;
2532
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
+
2533
2687
  if (!existsSync(MAILBOX)) process.exit(0);
2534
2688
 
2535
- let lastSeen = 0;
2689
+ let lastSeenMap = {};
2536
2690
  try {
2537
- if (existsSync(LAST_SEEN)) {
2538
- 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"));
2539
2693
  }
2540
2694
  } catch (err) {
2541
2695
  if (DEBUG) console.error("[hook] readLastSeen:", err.message);
2542
2696
  }
2697
+ const lastSeen = lastSeenMap[sessionHash] || 0;
2543
2698
 
2544
2699
  const now = Date.now();
2545
2700
  const lines = readFileSync(MAILBOX, "utf-8").trim().split("\\n").filter(Boolean);
@@ -2561,21 +2716,39 @@ for (const line of lines) {
2561
2716
  }
2562
2717
 
2563
2718
  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
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("");
2569
2725
  for (const { agent, sessionPrefix, message } of relevant) {
2570
2726
  if (sessionPrefix) sessionPrefixes.add(sessionPrefix);
2571
- console.log("**[" + agent + "]**");
2572
- console.log("");
2573
- console.log(message);
2574
- console.log("");
2727
+ messageLines.push("**[" + agent + "]**");
2728
+ messageLines.push("");
2729
+ messageLines.push(message);
2730
+ messageLines.push("");
2575
2731
  }
2576
2732
  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());
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));
2579
2752
  }
2580
2753
 
2581
2754
  process.exit(0);
@@ -2590,18 +2763,9 @@ process.exit(0);
2590
2763
  console.log(`\nTo enable manually, add to .claude/settings.json:\n`);
2591
2764
  console.log(`{
2592
2765
  "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
- ]
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 }] }]
2605
2769
  }
2606
2770
  }`);
2607
2771
  }
@@ -2611,6 +2775,7 @@ function ensureClaudeHookConfig() {
2611
2775
  const settingsDir = ".claude";
2612
2776
  const settingsPath = path.join(settingsDir, "settings.json");
2613
2777
  const hookCommand = "node .ai/hooks/mailbox-inject.js";
2778
+ const hookEvents = ["UserPromptSubmit", "PostToolUse", "Stop"];
2614
2779
 
2615
2780
  try {
2616
2781
  /** @type {ClaudeSettings} */
@@ -2629,33 +2794,41 @@ function ensureClaudeHookConfig() {
2629
2794
 
2630
2795
  // Ensure hooks structure exists
2631
2796
  if (!settings.hooks) settings.hooks = {};
2632
- if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
2633
2797
 
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
-
2640
- if (hookExists) {
2641
- 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
+ }
2642
2824
  }
2643
2825
 
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
- });
2826
+ if (anyAdded) {
2827
+ // Write settings
2828
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2829
+ console.log(`Configured hooks in: ${settingsPath}`);
2830
+ }
2655
2831
 
2656
- // Write settings
2657
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2658
- console.log(`Configured hook in: ${settingsPath}`);
2659
2832
  return true;
2660
2833
  } catch {
2661
2834
  // If we can't configure automatically, return false so manual instructions are shown
@@ -2727,7 +2900,9 @@ function cmdAttach(session) {
2727
2900
  }
2728
2901
 
2729
2902
  // Hand over to tmux attach
2730
- const result = spawnSync("tmux", ["attach", "-t", resolved], { stdio: "inherit" });
2903
+ const result = spawnSync("tmux", ["attach", "-t", resolved], {
2904
+ stdio: "inherit",
2905
+ });
2731
2906
  process.exit(result.status || 0);
2732
2907
  }
2733
2908
 
@@ -2786,13 +2961,15 @@ function cmdLog(sessionName, { tail = 50, reasoning = false, follow = false } =
2786
2961
 
2787
2962
  if (newLines.length === 0) return;
2788
2963
 
2789
- const entries = newLines.map((line) => {
2790
- try {
2791
- return JSON.parse(line);
2792
- } catch {
2793
- return null;
2794
- }
2795
- }).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);
2796
2973
 
2797
2974
  const output = [];
2798
2975
  if (isInitial) {
@@ -2805,7 +2982,10 @@ function cmdLog(sessionName, { tail = 50, reasoning = false, follow = false } =
2805
2982
  const ts = entry.timestamp || entry.ts || entry.createdAt;
2806
2983
  if (ts && ts !== lastTimestamp) {
2807
2984
  const date = new Date(ts);
2808
- 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
+ });
2809
2989
  if (formatted.isUserMessage) {
2810
2990
  output.push(`\n### ${timeStr}\n`);
2811
2991
  }
@@ -2855,7 +3035,10 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2855
3035
  if (type === "user" || type === "human") {
2856
3036
  const text = extractTextContent(content);
2857
3037
  if (text) {
2858
- 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
+ };
2859
3042
  }
2860
3043
  }
2861
3044
 
@@ -2871,10 +3054,12 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2871
3054
  // Extract tool calls (compressed)
2872
3055
  const tools = extractToolCalls(content);
2873
3056
  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(", ");
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(", ");
2878
3063
  parts.push(`> ${toolSummary}\n`);
2879
3064
  }
2880
3065
 
@@ -2896,7 +3081,10 @@ function formatLogEntry(entry, { reasoning = false } = {}) {
2896
3081
  const error = entry.error || entry.is_error;
2897
3082
  if (error) {
2898
3083
  const name = entry.tool_name || entry.name || "tool";
2899
- return { text: `> ${name} ✗ (${truncate(String(error), 100)})\n`, isUserMessage: false };
3084
+ return {
3085
+ text: `> ${name} ✗ (${truncate(String(error), 100)})\n`,
3086
+ isUserMessage: false,
3087
+ };
2900
3088
  }
2901
3089
  }
2902
3090
 
@@ -2933,7 +3121,8 @@ function extractToolCalls(content) {
2933
3121
  const name = c.name || c.tool || "tool";
2934
3122
  const input = c.input || c.arguments || {};
2935
3123
  // Extract a reasonable target from the input
2936
- 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 || "";
2937
3126
  const shortTarget = target.split("/").pop() || target.slice(0, 20);
2938
3127
  return { name, target: shortTarget, error: c.error };
2939
3128
  });
@@ -2978,8 +3167,14 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
2978
3167
 
2979
3168
  for (const entry of entries) {
2980
3169
  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" });
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
+ });
2983
3178
  const p = entry.payload || {};
2984
3179
 
2985
3180
  console.log(`### [${p.agent || "unknown"}] ${dateStr} ${timeStr}\n`);
@@ -3020,7 +3215,9 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3020
3215
  }
3021
3216
 
3022
3217
  /** @type {string} */
3023
- 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 });
3024
3221
 
3025
3222
  tmuxSendLiteral(activeSession, message);
3026
3223
  await sleep(50);
@@ -3121,7 +3318,13 @@ async function cmdReject(agent, session, { wait = false, timeoutMs } = {}) {
3121
3318
  * @param {string | null | undefined} customInstructions
3122
3319
  * @param {{wait?: boolean, yolo?: boolean, fresh?: boolean, timeoutMs?: number}} [options]
3123
3320
  */
3124
- 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
+ ) {
3125
3328
  const sessionExists = session != null && tmuxHasSession(session);
3126
3329
 
3127
3330
  // Reset conversation if --fresh and session exists
@@ -3147,7 +3350,11 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3147
3350
 
3148
3351
  // AX_REVIEW_MODE=exec: bypass /review command, send instructions directly
3149
3352
  if (process.env.AX_REVIEW_MODE === "exec" && option === "custom" && customInstructions) {
3150
- return cmdAsk(agent, session, customInstructions, { noWait: !wait, yolo, timeoutMs });
3353
+ return cmdAsk(agent, session, customInstructions, {
3354
+ noWait: !wait,
3355
+ yolo,
3356
+ timeoutMs,
3357
+ });
3151
3358
  }
3152
3359
  const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
3153
3360
 
@@ -3159,7 +3366,9 @@ async function cmdReview(agent, session, option, customInstructions, { wait = tr
3159
3366
  }
3160
3367
 
3161
3368
  /** @type {string} */
3162
- 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 });
3163
3372
 
3164
3373
  tmuxSendLiteral(activeSession, "/review");
3165
3374
  await sleep(50);
@@ -3420,7 +3629,7 @@ function getAgentFromInvocation() {
3420
3629
  */
3421
3630
  function printHelp(agent, cliName) {
3422
3631
  const name = cliName;
3423
- const backendName = agent.name === "codex" ? "OpenAI Codex" : "Claude";
3632
+ const backendName = agent.name === "codex" ? "Codex" : "Claude";
3424
3633
  const hasReview = !!agent.reviewOptions;
3425
3634
 
3426
3635
  console.log(`${name} v${VERSION} - agentic assistant CLI (${backendName})
@@ -3435,8 +3644,12 @@ Commands:
3435
3644
  kill Kill sessions in current project (--all for all, --session=NAME for one)
3436
3645
  status Check state (exit: 0=ready, 2=rate_limited, 3=confirming, 4=thinking)
3437
3646
  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` : ""}
3647
+ debug Show raw screen output and detected state${
3648
+ hasReview
3649
+ ? `
3650
+ review [TYPE] Review code: pr, uncommitted, commit, custom`
3651
+ : ""
3652
+ }
3440
3653
  select N Select menu option N
3441
3654
  approve Approve pending action (send 'y')
3442
3655
  reject Reject pending action (send 'n')
@@ -3455,30 +3668,28 @@ Flags:
3455
3668
  --fresh Reset conversation before review
3456
3669
 
3457
3670
  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
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
3464
3677
 
3465
3678
  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)`);
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)`);
3482
3693
  }
3483
3694
 
3484
3695
  async function main() {
@@ -3582,7 +3793,7 @@ async function main() {
3582
3793
  !a.startsWith("--tool") &&
3583
3794
  !a.startsWith("--tail") &&
3584
3795
  !a.startsWith("--limit") &&
3585
- !a.startsWith("--branch")
3796
+ !a.startsWith("--branch"),
3586
3797
  );
3587
3798
  const cmd = filteredArgs[0];
3588
3799
 
@@ -3597,7 +3808,13 @@ async function main() {
3597
3808
  if (cmd === "mailbox") return cmdMailbox({ limit, branch, all });
3598
3809
  if (cmd === "approve") return cmdApprove(agent, session, { wait, timeoutMs });
3599
3810
  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 });
3811
+ if (cmd === "review")
3812
+ return cmdReview(agent, session, filteredArgs[1], filteredArgs[2], {
3813
+ wait,
3814
+ yolo,
3815
+ fresh,
3816
+ timeoutMs,
3817
+ });
3601
3818
  if (cmd === "status") return cmdStatus(agent, session);
3602
3819
  if (cmd === "debug") return cmdDebug(agent, session);
3603
3820
  if (cmd === "output") {
@@ -3605,10 +3822,12 @@ async function main() {
3605
3822
  const index = indexArg?.startsWith("-") ? parseInt(indexArg, 10) : 0;
3606
3823
  return cmdOutput(agent, session, index, { wait, timeoutMs });
3607
3824
  }
3608
- 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(" "));
3609
3827
  if (cmd === "compact") return cmdAsk(agent, session, "/compact", { noWait: true, timeoutMs });
3610
3828
  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 });
3829
+ if (cmd === "select" && filteredArgs[1])
3830
+ return cmdSelect(agent, session, filteredArgs[1], { wait, timeoutMs });
3612
3831
 
3613
3832
  // Default: send message
3614
3833
  let message = filteredArgs.join(" ");
@@ -3625,7 +3844,11 @@ async function main() {
3625
3844
  const reviewMatch = message.match(/^please review\s*(.*)/i);
3626
3845
  if (reviewMatch && agent.reviewOptions) {
3627
3846
  const customInstructions = reviewMatch[1].trim() || null;
3628
- 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
+ });
3629
3852
  }
3630
3853
 
3631
3854
  return cmdAsk(agent, session, message, { noWait, yolo, timeoutMs });
@@ -3633,13 +3856,15 @@ async function main() {
3633
3856
 
3634
3857
  // Run main() only when executed directly (not when imported for testing)
3635
3858
  // 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
- })();
3859
+ const isDirectRun =
3860
+ process.argv[1] &&
3861
+ (() => {
3862
+ try {
3863
+ return realpathSync(process.argv[1]) === __filename;
3864
+ } catch {
3865
+ return false;
3866
+ }
3867
+ })();
3643
3868
  if (isDirectRun) {
3644
3869
  main().catch((err) => {
3645
3870
  console.log(`ERROR: ${err.message}`);
@@ -3663,4 +3888,3 @@ export {
3663
3888
  detectState,
3664
3889
  State,
3665
3890
  };
3666
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ax-agents",
3
- "version": "0.0.1-alpha.5",
3
+ "version": "0.0.1-alpha.6",
4
4
  "description": "A CLI for orchestrating AI coding agents via tmux",
5
5
  "bin": {
6
6
  "ax": "ax.js",