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

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 +15 -2
  2. package/ax.js +162 -189
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  A CLI for orchestrating AI coding agents via `tmux`.
4
4
 
5
+ Running agents in tmux sessions makes it easy to monitor multiple agents, review their work, and interact with them when needed.
6
+
5
7
  ## Install
6
8
 
7
9
  ```
@@ -19,9 +21,20 @@ ax --yolo "fix the login bug"
19
21
 
20
22
  Run `ax --help` for all options.
21
23
 
22
- ## Why
24
+ ## Archangels
25
+
26
+ Archangels are background agents that watch your codebase and surface observations to your main coding session.
27
+
28
+ Configure them in `.ai/agents/*.md`, then:
29
+
30
+ ```
31
+ ax summon # summon all archangels
32
+ ax summon reviewer # summon one by name
33
+ ax recall # recall all
34
+ ax recall reviewer # recall one
35
+ ```
23
36
 
24
- Running AI agents in tmux sessions makes it easy to monitor multiple agents, review their work, and interact with them when needed. This tool handles the session management so you can focus on the prompts.
37
+ When you next prompt Claude, any observations from your archangels will be injected automatically.
25
38
 
26
39
  ## License
27
40
 
package/ax.js CHANGED
@@ -32,12 +32,12 @@ const VERSION = packageJson.version;
32
32
  /**
33
33
  * @typedef {Object} ParsedSession
34
34
  * @property {string} tool
35
- * @property {string} [daemonName]
35
+ * @property {string} [archangelName]
36
36
  * @property {string} [uuid]
37
37
  */
38
38
 
39
39
  /**
40
- * @typedef {Object} DaemonConfig
40
+ * @typedef {Object} ArchangelConfig
41
41
  * @property {string} name
42
42
  * @property {ToolName} tool
43
43
  * @property {string[]} watch
@@ -264,9 +264,9 @@ const POLL_MS = parseInt(process.env.AX_POLL_MS || "200", 10);
264
264
  const DEFAULT_TIMEOUT_MS = parseInt(process.env.AX_TIMEOUT_MS || "120000", 10);
265
265
  const REVIEW_TIMEOUT_MS = parseInt(process.env.AX_REVIEW_TIMEOUT_MS || "900000", 10); // 15 minutes
266
266
  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);
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
269
+ const ARCHANGEL_HEALTH_CHECK_MS = parseInt(process.env.AX_ARCHANGEL_HEALTH_CHECK_MS || "30000", 10);
270
270
  const STABLE_MS = parseInt(process.env.AX_STABLE_MS || "1000", 10);
271
271
  const APPROVE_DELAY_MS = parseInt(process.env.AX_APPROVE_DELAY_MS || "100", 10);
272
272
  const MAILBOX_MAX_AGE_MS = parseInt(process.env.AX_MAILBOX_MAX_AGE_MS || "3600000", 10); // 1 hour
@@ -274,9 +274,9 @@ const CLAUDE_CONFIG_DIR = process.env.AX_CLAUDE_CONFIG_DIR || path.join(os.homed
274
274
  const CODEX_CONFIG_DIR = process.env.AX_CODEX_CONFIG_DIR || path.join(os.homedir(), ".codex");
275
275
  const TRUNCATE_USER_LEN = 500;
276
276
  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;
277
+ const ARCHANGEL_GIT_CONTEXT_HOURS = 4;
278
+ const ARCHANGEL_GIT_CONTEXT_MAX_LINES = 200;
279
+ const ARCHANGEL_PARENT_CONTEXT_ENTRIES = 10;
280
280
 
281
281
  /**
282
282
  * @param {string} session
@@ -359,10 +359,10 @@ function parseSessionName(session) {
359
359
  const tool = match[1].toLowerCase();
360
360
  const rest = match[2];
361
361
 
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] };
362
+ // 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);
364
+ if (archangelMatch) {
365
+ return { tool, archangelName: archangelMatch[1], uuid: archangelMatch[2] };
366
366
  }
367
367
 
368
368
  // Partner: {tool}-partner-{uuid}
@@ -567,15 +567,15 @@ function resolveSessionName(partial) {
567
567
  // Exact match
568
568
  if (agentSessions.includes(partial)) return partial;
569
569
 
570
- // Daemon name match (e.g., "reviewer" matches "claude-daemon-reviewer-uuid")
571
- const daemonMatches = agentSessions.filter((s) => {
570
+ // Archangel name match (e.g., "reviewer" matches "claude-archangel-reviewer-uuid")
571
+ const archangelMatches = agentSessions.filter((s) => {
572
572
  const parsed = parseSessionName(s);
573
- return parsed?.daemonName === partial;
573
+ return parsed?.archangelName === partial;
574
574
  });
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}`);
575
+ if (archangelMatches.length === 1) return archangelMatches[0];
576
+ if (archangelMatches.length > 1) {
577
+ console.log("ERROR: ambiguous archangel name. Matches:");
578
+ for (const m of archangelMatches) console.log(` ${m}`);
579
579
  process.exit(1);
580
580
  }
581
581
 
@@ -604,18 +604,18 @@ function resolveSessionName(partial) {
604
604
  }
605
605
 
606
606
  // =============================================================================
607
- // Helpers - daemon agents
607
+ // Helpers - archangels
608
608
  // =============================================================================
609
609
 
610
610
  /**
611
- * @returns {DaemonConfig[]}
611
+ * @returns {ArchangelConfig[]}
612
612
  */
613
613
  function loadAgentConfigs() {
614
614
  const agentsDir = AGENTS_DIR;
615
615
  if (!existsSync(agentsDir)) return [];
616
616
 
617
617
  const files = readdirSync(agentsDir).filter((f) => f.endsWith(".md"));
618
- /** @type {DaemonConfig[]} */
618
+ /** @type {ArchangelConfig[]} */
619
619
  const configs = [];
620
620
 
621
621
  for (const file of files) {
@@ -638,7 +638,7 @@ function loadAgentConfigs() {
638
638
  /**
639
639
  * @param {string} filename
640
640
  * @param {string} content
641
- * @returns {DaemonConfig | {error: string} | null}
641
+ * @returns {ArchangelConfig | {error: string} | null}
642
642
  */
643
643
  function parseAgentConfig(filename, content) {
644
644
  const name = filename.replace(/\.md$/, "");
@@ -723,11 +723,11 @@ function parseAgentConfig(filename, content) {
723
723
  }
724
724
 
725
725
  /**
726
- * @param {DaemonConfig} config
726
+ * @param {ArchangelConfig} config
727
727
  * @returns {string}
728
728
  */
729
- function getDaemonSessionPattern(config) {
730
- return `${config.tool}-daemon-${config.name}`;
729
+ function getArchangelSessionPattern(config) {
730
+ return `${config.tool}-archangel-${config.name}`;
731
731
  }
732
732
 
733
733
  // =============================================================================
@@ -950,9 +950,9 @@ function buildGitContext(hoursAgo = 4, maxLinesPerSection = 200) {
950
950
  // Helpers - parent session context
951
951
  // =============================================================================
952
952
 
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";
953
+ // Environment variables used to pass parent session info to archangels
954
+ const AX_ARCHANGEL_PARENT_SESSION_ENV = "AX_ARCHANGEL_PARENT_SESSION";
955
+ const AX_ARCHANGEL_PARENT_UUID_ENV = "AX_ARCHANGEL_PARENT_UUID";
956
956
 
957
957
  /**
958
958
  * @returns {ParentSession | null}
@@ -962,7 +962,7 @@ function findCurrentClaudeSession() {
962
962
  const current = tmuxCurrentSession();
963
963
  if (current) {
964
964
  const parsed = parseSessionName(current);
965
- if (parsed?.tool === "claude" && !parsed.daemonName && parsed.uuid) {
965
+ if (parsed?.tool === "claude" && !parsed.archangelName && parsed.uuid) {
966
966
  return { session: current, uuid: parsed.uuid };
967
967
  }
968
968
  }
@@ -979,7 +979,7 @@ function findCurrentClaudeSession() {
979
979
  for (const session of sessions) {
980
980
  const parsed = parseSessionName(session);
981
981
  if (!parsed || parsed.tool !== "claude") continue;
982
- if (parsed.daemonName) continue;
982
+ if (parsed.archangelName) continue;
983
983
  if (!parsed.uuid) continue;
984
984
 
985
985
  const sessionCwd = getTmuxSessionCwd(session);
@@ -1035,15 +1035,15 @@ function findCurrentClaudeSession() {
1035
1035
  * @returns {ParentSession | null}
1036
1036
  */
1037
1037
  function findParentSession() {
1038
- // First check if parent session was passed via environment (for daemons)
1039
- const envUuid = process.env[AX_DAEMON_PARENT_UUID_ENV];
1038
+ // First check if parent session was passed via environment (for archangels)
1039
+ const envUuid = process.env[AX_ARCHANGEL_PARENT_UUID_ENV];
1040
1040
  if (envUuid) {
1041
1041
  // Session name is optional (may be null for non-tmux sessions)
1042
- const envSession = process.env[AX_DAEMON_PARENT_SESSION_ENV] || null;
1042
+ const envSession = process.env[AX_ARCHANGEL_PARENT_SESSION_ENV] || null;
1043
1043
  return { session: envSession, uuid: envUuid };
1044
1044
  }
1045
1045
 
1046
- // Fallback to detecting current session (shouldn't be needed for daemons)
1046
+ // Fallback to detecting current session (shouldn't be needed for archangels)
1047
1047
  return findCurrentClaudeSession();
1048
1048
  }
1049
1049
 
@@ -2045,7 +2045,7 @@ function cmdAgents() {
2045
2045
  const screen = tmuxCapture(session);
2046
2046
  const state = agent.getState(screen);
2047
2047
  const logPath = agent.findLogPath(session);
2048
- const type = parsed.daemonName ? "daemon" : "-";
2048
+ const type = parsed.archangelName ? "archangel" : "-";
2049
2049
 
2050
2050
  return {
2051
2051
  session,
@@ -2073,87 +2073,84 @@ function cmdAgents() {
2073
2073
  }
2074
2074
 
2075
2075
  // =============================================================================
2076
- // Command: daemons
2076
+ // Command: summon/recall
2077
2077
  // =============================================================================
2078
2078
 
2079
2079
  /**
2080
2080
  * @param {string} pattern
2081
2081
  * @returns {string | undefined}
2082
2082
  */
2083
- function findDaemonSession(pattern) {
2083
+ function findArchangelSession(pattern) {
2084
2084
  const sessions = tmuxListSessions();
2085
2085
  return sessions.find((s) => s.startsWith(pattern));
2086
2086
  }
2087
2087
 
2088
2088
  /**
2089
- * @param {DaemonConfig} config
2089
+ * @param {ArchangelConfig} config
2090
2090
  * @returns {string}
2091
2091
  */
2092
- function generateDaemonSessionName(config) {
2093
- return `${config.tool}-daemon-${config.name}-${randomUUID()}`;
2092
+ function generateArchangelSessionName(config) {
2093
+ return `${config.tool}-archangel-${config.name}-${randomUUID()}`;
2094
2094
  }
2095
2095
 
2096
2096
  /**
2097
- * @param {DaemonConfig} config
2097
+ * @param {ArchangelConfig} config
2098
2098
  * @param {ParentSession | null} [parentSession]
2099
2099
  */
2100
- function startDaemonAgent(config, parentSession = null) {
2101
- // Build environment with parent session info if available
2100
+ function startArchangel(config, parentSession = null) {
2102
2101
  /** @type {NodeJS.ProcessEnv} */
2103
2102
  const env = { ...process.env };
2104
2103
  if (parentSession?.uuid) {
2105
- // Session name may be null for non-tmux sessions, but uuid is required
2106
2104
  if (parentSession.session) {
2107
- env[AX_DAEMON_PARENT_SESSION_ENV] = parentSession.session;
2105
+ env[AX_ARCHANGEL_PARENT_SESSION_ENV] = parentSession.session;
2108
2106
  }
2109
- env[AX_DAEMON_PARENT_UUID_ENV] = parentSession.uuid;
2107
+ env[AX_ARCHANGEL_PARENT_UUID_ENV] = parentSession.uuid;
2110
2108
  }
2111
2109
 
2112
- // Spawn ax.js daemon <name> as a detached background process
2113
- const child = spawn("node", [process.argv[1], "daemon", config.name], {
2110
+ const child = spawn("node", [process.argv[1], "archangel", config.name], {
2114
2111
  detached: true,
2115
2112
  stdio: "ignore",
2116
2113
  cwd: process.cwd(),
2117
2114
  env,
2118
2115
  });
2119
2116
  child.unref();
2120
- console.log(`Starting daemon: ${config.name} (pid ${child.pid})${parentSession ? ` [parent: ${parentSession.session}]` : ""}`);
2117
+ console.log(`Summoning: ${config.name} (pid ${child.pid})${parentSession ? ` [parent: ${parentSession.session}]` : ""}`);
2121
2118
  }
2122
2119
 
2123
2120
  // =============================================================================
2124
- // Command: daemon (runs as the daemon process itself)
2121
+ // Command: archangel (runs as the archangel process itself)
2125
2122
  // =============================================================================
2126
2123
 
2127
2124
  /**
2128
2125
  * @param {string | undefined} agentName
2129
2126
  */
2130
- async function cmdDaemon(agentName) {
2127
+ async function cmdArchangel(agentName) {
2131
2128
  if (!agentName) {
2132
- console.error("Usage: ./ax.js daemon <name>");
2129
+ console.error("Usage: ./ax.js archangel <name>");
2133
2130
  process.exit(1);
2134
2131
  }
2135
2132
  // Load agent config
2136
2133
  const configPath = path.join(AGENTS_DIR, `${agentName}.md`);
2137
2134
  if (!existsSync(configPath)) {
2138
- console.error(`[daemon:${agentName}] Config not found: ${configPath}`);
2135
+ console.error(`[archangel:${agentName}] Config not found: ${configPath}`);
2139
2136
  process.exit(1);
2140
2137
  }
2141
2138
 
2142
2139
  const content = readFileSync(configPath, "utf-8");
2143
2140
  const configResult = parseAgentConfig(`${agentName}.md`, content);
2144
2141
  if (!configResult || "error" in configResult) {
2145
- console.error(`[daemon:${agentName}] Invalid config`);
2142
+ console.error(`[archangel:${agentName}] Invalid config`);
2146
2143
  process.exit(1);
2147
2144
  }
2148
2145
  const config = configResult;
2149
2146
 
2150
2147
  const agent = config.tool === "claude" ? ClaudeAgent : CodexAgent;
2151
- const sessionName = generateDaemonSessionName(config);
2148
+ const sessionName = generateArchangelSessionName(config);
2152
2149
 
2153
2150
  // Check agent CLI is installed before trying to start
2154
2151
  const cliCheck = spawnSync("which", [agent.name], { encoding: "utf-8" });
2155
2152
  if (cliCheck.status !== 0) {
2156
- console.error(`[daemon:${agentName}] ERROR: ${agent.name} CLI is not installed or not in PATH`);
2153
+ console.error(`[archangel:${agentName}] ERROR: ${agent.name} CLI is not installed or not in PATH`);
2157
2154
  process.exit(1);
2158
2155
  }
2159
2156
 
@@ -2163,7 +2160,7 @@ async function cmdDaemon(agentName) {
2163
2160
 
2164
2161
  // Wait for agent to be ready
2165
2162
  const start = Date.now();
2166
- while (Date.now() - start < DAEMON_STARTUP_TIMEOUT_MS) {
2163
+ while (Date.now() - start < ARCHANGEL_STARTUP_TIMEOUT_MS) {
2167
2164
  const screen = tmuxCapture(sessionName);
2168
2165
  const state = agent.getState(screen);
2169
2166
 
@@ -2174,7 +2171,7 @@ async function cmdDaemon(agentName) {
2174
2171
 
2175
2172
  // Handle bypass permissions confirmation dialog (Claude Code shows this for --dangerously-skip-permissions)
2176
2173
  if (screen.includes("Bypass Permissions mode") && screen.includes("Yes, I accept")) {
2177
- console.log(`[daemon:${agentName}] Accepting bypass permissions dialog`);
2174
+ console.log(`[archangel:${agentName}] Accepting bypass permissions dialog`);
2178
2175
  tmuxSend(sessionName, "2"); // Select "Yes, I accept"
2179
2176
  await sleep(300);
2180
2177
  tmuxSend(sessionName, "Enter");
@@ -2183,7 +2180,7 @@ async function cmdDaemon(agentName) {
2183
2180
  }
2184
2181
 
2185
2182
  if (state === State.READY) {
2186
- console.log(`[daemon:${agentName}] Started session: ${sessionName}`);
2183
+ console.log(`[archangel:${agentName}] Started session: ${sessionName}`);
2187
2184
  break;
2188
2185
  }
2189
2186
 
@@ -2258,7 +2255,7 @@ async function cmdDaemon(agentName) {
2258
2255
 
2259
2256
  prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
2260
2257
 
2261
- const gitContext = buildGitContext(DAEMON_GIT_CONTEXT_HOURS, DAEMON_GIT_CONTEXT_MAX_LINES);
2258
+ const gitContext = buildGitContext(ARCHANGEL_GIT_CONTEXT_HOURS, ARCHANGEL_GIT_CONTEXT_MAX_LINES);
2262
2259
  if (gitContext) {
2263
2260
  prompt += "\n\n## Git Context\n\n" + gitContext;
2264
2261
  }
@@ -2266,8 +2263,8 @@ async function cmdDaemon(agentName) {
2266
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."';
2267
2264
  } else {
2268
2265
  // 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);
2266
+ const parentContext = getParentSessionContext(ARCHANGEL_PARENT_CONTEXT_ENTRIES);
2267
+ const gitContext = buildGitContext(ARCHANGEL_GIT_CONTEXT_HOURS, ARCHANGEL_GIT_CONTEXT_MAX_LINES);
2271
2268
 
2272
2269
  if (parentContext) {
2273
2270
  prompt += "\n\n## Main Session Context\n\nThe user is currently working on:\n\n" + parentContext;
@@ -2285,7 +2282,7 @@ async function cmdDaemon(agentName) {
2285
2282
 
2286
2283
  // Check session still exists
2287
2284
  if (!tmuxHasSession(sessionName)) {
2288
- console.log(`[daemon:${agentName}] Session gone, exiting`);
2285
+ console.log(`[archangel:${agentName}] Session gone, exiting`);
2289
2286
  process.exit(0);
2290
2287
  }
2291
2288
 
@@ -2294,12 +2291,12 @@ async function cmdDaemon(agentName) {
2294
2291
  const state = agent.getState(screen);
2295
2292
 
2296
2293
  if (state === State.RATE_LIMITED) {
2297
- console.error(`[daemon:${agentName}] Rate limited - stopping`);
2294
+ console.error(`[archangel:${agentName}] Rate limited - stopping`);
2298
2295
  process.exit(2);
2299
2296
  }
2300
2297
 
2301
2298
  if (state !== State.READY) {
2302
- console.log(`[daemon:${agentName}] Agent not ready (${state}), skipping`);
2299
+ console.log(`[archangel:${agentName}] Agent not ready (${state}), skipping`);
2303
2300
  isProcessing = false;
2304
2301
  return;
2305
2302
  }
@@ -2311,10 +2308,10 @@ async function cmdDaemon(agentName) {
2311
2308
  await sleep(100); // Ensure Enter is processed
2312
2309
 
2313
2310
  // Wait for response
2314
- const { state: endState, screen: afterScreen } = await waitForResponse(agent, sessionName, DAEMON_RESPONSE_TIMEOUT_MS);
2311
+ const { state: endState, screen: afterScreen } = await waitForResponse(agent, sessionName, ARCHANGEL_RESPONSE_TIMEOUT_MS);
2315
2312
 
2316
2313
  if (endState === State.RATE_LIMITED) {
2317
- console.error(`[daemon:${agentName}] Rate limited - stopping`);
2314
+ console.error(`[archangel:${agentName}] Rate limited - stopping`);
2318
2315
  process.exit(2);
2319
2316
  }
2320
2317
 
@@ -2335,12 +2332,12 @@ async function cmdDaemon(agentName) {
2335
2332
  files,
2336
2333
  message: cleanedResponse.slice(0, 1000),
2337
2334
  });
2338
- console.log(`[daemon:${agentName}] Wrote observation for ${files.length} file(s)`);
2335
+ console.log(`[archangel:${agentName}] Wrote observation for ${files.length} file(s)`);
2339
2336
  } else if (isGarbage) {
2340
- console.log(`[daemon:${agentName}] Skipped garbage response`);
2337
+ console.log(`[archangel:${agentName}] Skipped garbage response`);
2341
2338
  }
2342
2339
  } catch (err) {
2343
- console.error(`[daemon:${agentName}] Error:`, err instanceof Error ? err.message : err);
2340
+ console.error(`[archangel:${agentName}] Error:`, err instanceof Error ? err.message : err);
2344
2341
  }
2345
2342
 
2346
2343
  isProcessing = false;
@@ -2348,7 +2345,7 @@ async function cmdDaemon(agentName) {
2348
2345
 
2349
2346
  function scheduleProcessChanges() {
2350
2347
  processChanges().catch((err) => {
2351
- console.error(`[daemon:${agentName}] Unhandled error:`, err instanceof Error ? err.message : err);
2348
+ console.error(`[archangel:${agentName}] Unhandled error:`, err instanceof Error ? err.message : err);
2352
2349
  });
2353
2350
  }
2354
2351
 
@@ -2369,16 +2366,16 @@ async function cmdDaemon(agentName) {
2369
2366
  // Check if session still exists periodically
2370
2367
  const sessionCheck = setInterval(() => {
2371
2368
  if (!tmuxHasSession(sessionName)) {
2372
- console.log(`[daemon:${agentName}] Session gone, exiting`);
2369
+ console.log(`[archangel:${agentName}] Session gone, exiting`);
2373
2370
  stopWatching();
2374
2371
  clearInterval(sessionCheck);
2375
2372
  process.exit(0);
2376
2373
  }
2377
- }, DAEMON_HEALTH_CHECK_MS);
2374
+ }, ARCHANGEL_HEALTH_CHECK_MS);
2378
2375
 
2379
2376
  // Handle graceful shutdown
2380
2377
  process.on("SIGTERM", () => {
2381
- console.log(`[daemon:${agentName}] Received SIGTERM, shutting down`);
2378
+ console.log(`[archangel:${agentName}] Received SIGTERM, shutting down`);
2382
2379
  stopWatching();
2383
2380
  clearInterval(sessionCheck);
2384
2381
  tmuxSend(sessionName, "C-c");
@@ -2389,7 +2386,7 @@ async function cmdDaemon(agentName) {
2389
2386
  });
2390
2387
 
2391
2388
  process.on("SIGINT", () => {
2392
- console.log(`[daemon:${agentName}] Received SIGINT, shutting down`);
2389
+ console.log(`[archangel:${agentName}] Received SIGINT, shutting down`);
2393
2390
  stopWatching();
2394
2391
  clearInterval(sessionCheck);
2395
2392
  tmuxSend(sessionName, "C-c");
@@ -2399,48 +2396,33 @@ async function cmdDaemon(agentName) {
2399
2396
  }, 500);
2400
2397
  });
2401
2398
 
2402
- console.log(`[daemon:${agentName}] Watching: ${config.watch.join(", ")}`);
2399
+ console.log(`[archangel:${agentName}] Watching: ${config.watch.join(", ")}`);
2403
2400
 
2404
2401
  // Keep the process alive
2405
2402
  await new Promise(() => {});
2406
2403
  }
2407
2404
 
2408
2405
  /**
2409
- * @param {string} action
2410
- * @param {string | null} [daemonName]
2406
+ * @param {string | null} [name]
2411
2407
  */
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
- }
2408
+ async function cmdSummon(name = null) {
2409
+ const configs = loadAgentConfigs();
2431
2410
 
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
- }
2411
+ // If name provided but doesn't exist, create it
2412
+ if (name) {
2413
+ const exists = configs.some((c) => c.name === name);
2414
+ if (!exists) {
2415
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
2416
+ console.log("ERROR: Name must contain only letters, numbers, dashes, and underscores");
2417
+ process.exit(1);
2418
+ }
2437
2419
 
2438
- // Create agents directory if needed
2439
- if (!existsSync(AGENTS_DIR)) {
2440
- mkdirSync(AGENTS_DIR, { recursive: true });
2441
- }
2420
+ if (!existsSync(AGENTS_DIR)) {
2421
+ mkdirSync(AGENTS_DIR, { recursive: true });
2422
+ }
2442
2423
 
2443
- const template = `---
2424
+ const agentPath = path.join(AGENTS_DIR, `${name}.md`);
2425
+ const template = `---
2444
2426
  tool: claude
2445
2427
  watch: ["**/*.{ts,tsx,js,jsx,mjs,mts}"]
2446
2428
  interval: 30
@@ -2448,75 +2430,76 @@ interval: 30
2448
2430
 
2449
2431
  Review changed files for bugs, type errors, and edge cases.
2450
2432
  `;
2433
+ writeFileSync(agentPath, template);
2434
+ console.log(`Created: ${agentPath}`);
2435
+ console.log(`Edit the file to customize, then run: ax summon ${name}`);
2436
+ return;
2437
+ }
2438
+ }
2451
2439
 
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}`);
2440
+ if (configs.length === 0) {
2441
+ console.log(`No archangels found in ${AGENTS_DIR}/`);
2455
2442
  return;
2456
2443
  }
2457
2444
 
2445
+ const targetConfigs = name ? configs.filter((c) => c.name === name) : configs;
2446
+
2447
+ ensureMailboxHookScript();
2448
+
2449
+ const parentSession = findCurrentClaudeSession();
2450
+ if (parentSession) {
2451
+ console.log(`Parent session: ${parentSession.session || "(non-tmux)"} [${parentSession.uuid}]`);
2452
+ }
2453
+
2454
+ for (const config of targetConfigs) {
2455
+ const sessionPattern = getArchangelSessionPattern(config);
2456
+ const existing = findArchangelSession(sessionPattern);
2457
+
2458
+ if (!existing) {
2459
+ startArchangel(config, parentSession);
2460
+ } else {
2461
+ console.log(`Already running: ${config.name} (${existing})`);
2462
+ }
2463
+ }
2464
+
2465
+ gcMailbox(24);
2466
+ }
2467
+
2468
+ /**
2469
+ * @param {string | null} [name]
2470
+ */
2471
+ async function cmdRecall(name = null) {
2458
2472
  const configs = loadAgentConfigs();
2459
2473
 
2460
2474
  if (configs.length === 0) {
2461
- console.log(`No agent configs found in ${AGENTS_DIR}/`);
2475
+ console.log(`No archangels found in ${AGENTS_DIR}/`);
2462
2476
  return;
2463
2477
  }
2464
2478
 
2465
- // Filter to specific daemon if name provided
2466
- const targetConfigs = daemonName
2467
- ? configs.filter((c) => c.name === daemonName)
2468
- : configs;
2479
+ const targetConfigs = name ? configs.filter((c) => c.name === name) : configs;
2469
2480
 
2470
- if (daemonName && targetConfigs.length === 0) {
2471
- console.log(`ERROR: daemon '${daemonName}' not found in ${AGENTS_DIR}/`);
2481
+ if (name && targetConfigs.length === 0) {
2482
+ console.log(`ERROR: archangel '${name}' not found in ${AGENTS_DIR}/`);
2472
2483
  process.exit(1);
2473
2484
  }
2474
2485
 
2475
- // Ensure hook script exists on start
2476
- if (action === "start") {
2477
- ensureMailboxHookScript();
2478
- }
2486
+ for (const config of targetConfigs) {
2487
+ const sessionPattern = getArchangelSessionPattern(config);
2488
+ const existing = findArchangelSession(sessionPattern);
2479
2489
 
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}]`);
2490
+ if (existing) {
2491
+ tmuxSend(existing, "C-c");
2492
+ await sleep(300);
2493
+ tmuxKill(existing);
2494
+ console.log(`Recalled: ${config.name} (${existing})`);
2485
2495
  } else {
2486
- console.log("Parent session: null (not running from Claude or no active sessions)");
2496
+ console.log(`Not running: ${config.name}`);
2487
2497
  }
2488
2498
  }
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
- }
2509
- }
2510
- }
2511
-
2512
- // GC mailbox on start
2513
- if (action === "start") {
2514
- gcMailbox(24);
2515
- }
2516
2499
  }
2517
2500
 
2518
2501
  // Version of the hook script template - bump when making changes
2519
- const HOOK_SCRIPT_VERSION = "2";
2502
+ const HOOK_SCRIPT_VERSION = "3";
2520
2503
 
2521
2504
  function ensureMailboxHookScript() {
2522
2505
  const hooksDir = HOOKS_DIR;
@@ -2534,26 +2517,21 @@ function ensureMailboxHookScript() {
2534
2517
  mkdirSync(hooksDir, { recursive: true });
2535
2518
  }
2536
2519
 
2537
- // Inject absolute paths into the generated script
2538
- const mailboxPath = path.join(AI_DIR, "mailbox.jsonl");
2539
- const lastSeenPath = path.join(AI_DIR, "mailbox-last-seen");
2540
-
2541
2520
  const hookCode = `#!/usr/bin/env node
2542
2521
  ${versionMarker}
2543
- // Auto-generated hook script - do not edit manually
2544
-
2545
2522
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
2523
+ import { dirname, join } from "node:path";
2524
+ import { fileURLToPath } from "node:url";
2546
2525
 
2526
+ const __dirname = dirname(fileURLToPath(import.meta.url));
2527
+ const AI_DIR = join(__dirname, "..");
2547
2528
  const DEBUG = process.env.AX_DEBUG === "1";
2548
- const MAILBOX = "${mailboxPath}";
2549
- const LAST_SEEN = "${lastSeenPath}";
2550
- const MAX_AGE_MS = 60 * 60 * 1000; // 1 hour (matches MAILBOX_MAX_AGE_MS)
2529
+ const MAILBOX = join(AI_DIR, "mailbox.jsonl");
2530
+ const LAST_SEEN = join(AI_DIR, "mailbox-last-seen");
2531
+ const MAX_AGE_MS = 60 * 60 * 1000;
2551
2532
 
2552
2533
  if (!existsSync(MAILBOX)) process.exit(0);
2553
2534
 
2554
- // Note: commit filtering removed - age + lastSeen is sufficient
2555
-
2556
- // Read last seen timestamp
2557
2535
  let lastSeen = 0;
2558
2536
  try {
2559
2537
  if (existsSync(LAST_SEEN)) {
@@ -2572,11 +2550,7 @@ for (const line of lines) {
2572
2550
  const entry = JSON.parse(line);
2573
2551
  const ts = new Date(entry.timestamp).getTime();
2574
2552
  const age = now - ts;
2575
-
2576
- // Only show observations within max age and not yet seen
2577
- // (removed commit filter - too strict when HEAD moves during a session)
2578
2553
  if (age < MAX_AGE_MS && ts > lastSeen) {
2579
- // Extract session prefix (without UUID) for shorter log command
2580
2554
  const session = entry.payload.session || "";
2581
2555
  const sessionPrefix = session.replace(/-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "");
2582
2556
  relevant.push({ agent: entry.payload.agent, sessionPrefix, message: entry.payload.message });
@@ -2601,7 +2575,6 @@ if (relevant.length > 0) {
2601
2575
  }
2602
2576
  const sessionList = [...sessionPrefixes].map(s => "\\\`./ax.js log " + s + "\\\`").join(" or ");
2603
2577
  console.log("> For more context: \\\`./ax.js mailbox\\\`" + (sessionList ? " or " + sessionList : ""));
2604
- // Update last seen timestamp
2605
2578
  writeFileSync(LAST_SEEN, now.toString());
2606
2579
  }
2607
2580
 
@@ -2614,7 +2587,6 @@ process.exit(0);
2614
2587
  // Configure the hook in .claude/settings.json at the same time
2615
2588
  const configuredHook = ensureClaudeHookConfig();
2616
2589
  if (!configuredHook) {
2617
- const hookScriptPath = path.join(HOOKS_DIR, "mailbox-inject.js");
2618
2590
  console.log(`\nTo enable manually, add to .claude/settings.json:\n`);
2619
2591
  console.log(`{
2620
2592
  "hooks": {
@@ -2624,7 +2596,7 @@ process.exit(0);
2624
2596
  "hooks": [
2625
2597
  {
2626
2598
  "type": "command",
2627
- "command": "node ${hookScriptPath}",
2599
+ "command": "node .ai/hooks/mailbox-inject.js",
2628
2600
  "timeout": 5
2629
2601
  }
2630
2602
  ]
@@ -2638,8 +2610,7 @@ process.exit(0);
2638
2610
  function ensureClaudeHookConfig() {
2639
2611
  const settingsDir = ".claude";
2640
2612
  const settingsPath = path.join(settingsDir, "settings.json");
2641
- const hookScriptPath = path.join(HOOKS_DIR, "mailbox-inject.js");
2642
- const hookCommand = `node ${hookScriptPath}`;
2613
+ const hookCommand = "node .ai/hooks/mailbox-inject.js";
2643
2614
 
2644
2615
  try {
2645
2616
  /** @type {ClaudeSettings} */
@@ -3458,9 +3429,9 @@ Commands:
3458
3429
  agents List all running agents with state and log paths
3459
3430
  attach [SESSION] Attach to agent session interactively
3460
3431
  log SESSION View conversation log (--tail=N, --follow, --reasoning)
3461
- mailbox View daemon observations (--limit=N, --branch=X, --all)
3462
- daemons start [name] Start daemon agents (all, or by name)
3463
- daemons stop [name] Stop daemon agents (all, or by name)
3432
+ mailbox View archangel observations (--limit=N, --branch=X, --all)
3433
+ summon [name] Summon archangels (all, or by name)
3434
+ recall [name] Recall archangels (all, or by name)
3464
3435
  kill Kill sessions in current project (--all for all, --session=NAME for one)
3465
3436
  status Check state (exit: 0=ready, 2=rate_limited, 3=confirming, 4=thinking)
3466
3437
  output [-N] Show response (0=last, -1=prev, -2=older)
@@ -3476,7 +3447,7 @@ Commands:
3476
3447
 
3477
3448
  Flags:
3478
3449
  --tool=NAME Use specific agent (codex, claude)
3479
- --session=NAME Target session by name, daemon name, or UUID prefix (self = current)
3450
+ --session=NAME Target session by name, archangel name, or UUID prefix (self = current)
3480
3451
  --wait Wait for response (for review, approve, etc)
3481
3452
  --no-wait Don't wait (for messages, which wait by default)
3482
3453
  --timeout=N Set timeout in seconds (default: 120)
@@ -3502,11 +3473,12 @@ Examples:
3502
3473
  ./${name}.js send "1[Enter]" # Recovery: select option 1 and press Enter
3503
3474
  ./${name}.js send "[Escape][Escape]" # Recovery: escape out of a dialog
3504
3475
 
3505
- Daemon Agents:
3506
- ./${name}.js daemons start # Start all daemons from .ai/agents/*.md
3507
- ./${name}.js daemons stop # Stop all daemons
3508
- ./${name}.js daemons init <name> # Create new daemon config
3509
- ./${name}.js agents # List all agents (shows TYPE=daemon)`);
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)`);
3510
3482
  }
3511
3483
 
3512
3484
  async function main() {
@@ -3560,7 +3532,7 @@ async function main() {
3560
3532
  }
3561
3533
  session = current;
3562
3534
  } else {
3563
- // Resolve partial names, daemon names, and UUID prefixes
3535
+ // Resolve partial names, archangel names, and UUID prefixes
3564
3536
  session = resolveSessionName(val);
3565
3537
  }
3566
3538
  }
@@ -3616,8 +3588,9 @@ async function main() {
3616
3588
 
3617
3589
  // Dispatch commands
3618
3590
  if (cmd === "agents") return cmdAgents();
3619
- if (cmd === "daemons") return cmdDaemons(filteredArgs[1], filteredArgs[2]);
3620
- if (cmd === "daemon") return cmdDaemon(filteredArgs[1]);
3591
+ if (cmd === "summon") return cmdSummon(filteredArgs[1]);
3592
+ if (cmd === "recall") return cmdRecall(filteredArgs[1]);
3593
+ if (cmd === "archangel") return cmdArchangel(filteredArgs[1]);
3621
3594
  if (cmd === "kill") return cmdKill(session, { all });
3622
3595
  if (cmd === "attach") return cmdAttach(filteredArgs[1] || session);
3623
3596
  if (cmd === "log") return cmdLog(filteredArgs[1] || session, { tail, reasoning, follow });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ax-agents",
3
- "version": "0.0.1-alpha.3",
3
+ "version": "0.0.1-alpha.5",
4
4
  "description": "A CLI for orchestrating AI coding agents via tmux",
5
5
  "bin": {
6
6
  "ax": "ax.js",