ax-agents 0.0.1-alpha.11 → 0.0.1-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/ax.js +711 -133
  2. package/package.json +1 -1
package/ax.js CHANGED
@@ -47,6 +47,8 @@ const VERSION = packageJson.version;
47
47
  * @property {string} tool
48
48
  * @property {string} [archangelName]
49
49
  * @property {string} [uuid]
50
+ * @property {string} [permissionHash]
51
+ * @property {boolean} [yolo]
50
52
  */
51
53
 
52
54
  /**
@@ -75,6 +77,10 @@ const VERSION = packageJson.version;
75
77
  * @property {string[]} files
76
78
  * @property {string} [summary]
77
79
  * @property {string} [message]
80
+ * @property {string} [rfpId]
81
+ * @property {string} [prompt]
82
+ * @property {string} [archangel]
83
+ * @property {string} [requestedBy]
78
84
  */
79
85
 
80
86
  /**
@@ -158,6 +164,7 @@ const PROJECT_ROOT = findProjectRoot();
158
164
  const AI_DIR = path.join(PROJECT_ROOT, ".ai");
159
165
  const AGENTS_DIR = path.join(AI_DIR, "agents");
160
166
  const HOOKS_DIR = path.join(AI_DIR, "hooks");
167
+ const RFP_DIR = path.join(AI_DIR, "rfps");
161
168
 
162
169
  // =============================================================================
163
170
  // Helpers - tmux
@@ -254,25 +261,66 @@ function tmuxCurrentSession() {
254
261
  }
255
262
 
256
263
  /**
257
- * Check if a session was started in yolo mode by inspecting the pane's start command.
264
+ * @typedef {Object} SessionPermissions
265
+ * @property {'yolo' | 'custom' | 'safe'} mode
266
+ * @property {string | null} allowedTools
267
+ * @property {string | null} hash
268
+ */
269
+
270
+ const SAFE_PERMISSIONS = /** @type {SessionPermissions} */ ({
271
+ mode: "safe",
272
+ allowedTools: null,
273
+ hash: null,
274
+ });
275
+
276
+ /**
277
+ * Get permission info from a session based on its name.
278
+ * Session name encodes permission mode: -yolo, -p{hash}, or neither (safe).
279
+ * @param {string} session
280
+ * @returns {SessionPermissions}
281
+ */
282
+ function getSessionPermissions(session) {
283
+ const parsed = parseSessionName(session);
284
+ if (parsed?.yolo) {
285
+ return { mode: "yolo", allowedTools: null, hash: null };
286
+ }
287
+ if (parsed?.permissionHash) {
288
+ return { mode: "custom", allowedTools: null, hash: parsed.permissionHash };
289
+ }
290
+ return SAFE_PERMISSIONS;
291
+ }
292
+
293
+ /**
294
+ * Check if a session was started in yolo mode.
258
295
  * @param {string} session
259
296
  * @returns {boolean}
260
297
  */
261
298
  function isYoloSession(session) {
262
- try {
263
- const result = spawnSync(
264
- "tmux",
265
- ["display-message", "-t", session, "-p", "#{pane_start_command}"],
266
- {
267
- encoding: "utf-8",
268
- },
269
- );
270
- if (result.status !== 0) return false;
271
- const cmd = result.stdout.trim();
272
- return cmd.includes("--dangerously-");
273
- } catch {
274
- return false;
275
- }
299
+ return getSessionPermissions(session).mode === "yolo";
300
+ }
301
+
302
+ /**
303
+ * Normalize allowed tools string for consistent hashing.
304
+ * Splits on tool boundaries (e.g., 'Bash("...") Read') while preserving quoted content.
305
+ * @param {string} tools
306
+ * @returns {string}
307
+ */
308
+ function normalizeAllowedTools(tools) {
309
+ // Match tool patterns: ToolName or ToolName("args") or ToolName("args with spaces")
310
+ const toolPattern = /\w+(?:\("[^"]*"\))?/g;
311
+ const matches = tools.match(toolPattern) || [];
312
+ return matches.sort().join(" ");
313
+ }
314
+
315
+ /**
316
+ * Compute a short hash of the allowed tools for session naming.
317
+ * @param {string | null | undefined} allowedTools
318
+ * @returns {string | null}
319
+ */
320
+ function computePermissionHash(allowedTools) {
321
+ if (!allowedTools) return null;
322
+ const normalized = normalizeAllowedTools(allowedTools);
323
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 8);
276
324
  }
277
325
 
278
326
  // =============================================================================
@@ -316,6 +364,22 @@ const ARCHANGEL_PREAMBLE = `## Guidelines
316
364
  - For critical issues, request for them to be added to the todo list.
317
365
  - Don't repeat observations you've already made unless you have more to say or better clarity.
318
366
  - Make judgment calls - don't ask questions.`;
367
+ const RFP_PREAMBLE = `## Guidelines
368
+
369
+ - Your only task is to propose a single idea in response to this RFP. This overrides any other goals or habits.
370
+ - Provide exactly one proposal.
371
+ - Make a persuasive case for why this is a strong idea.
372
+ - Think deeply before you answer; avoid first-impression responses.
373
+ - Aim for 3–4 clear paragraphs.
374
+ - Ground the idea in the actual context you were given; don’t ignore it.
375
+ - If you need context, read the existing project or conversation before proposing.
376
+ - Structure: (1) core insight/value, (2) who benefits & why now, (3) risks/tradeoffs (brief), (4) closing case.
377
+ - Focus on value: what improves, for whom, and why now.
378
+ - Do NOT review code or report bugs.
379
+ - Do NOT describe scope, implementation approach, or plan.
380
+ - You may briefly note tradeoffs, but they are not the focus.
381
+ - Prioritize clarity over brevity.
382
+ - If you have nothing to propose, respond with ONLY "EMPTY_RESPONSE".`;
319
383
 
320
384
  /**
321
385
  * @param {string} session
@@ -353,9 +417,9 @@ async function waitFor(session, predicate, timeoutMs = STARTUP_TIMEOUT_MS) {
353
417
  // =============================================================================
354
418
 
355
419
  /**
356
- * @returns {number | null}
420
+ * @returns {{pid: number, agent: 'claude' | 'codex'} | null}
357
421
  */
358
- function findCallerPid() {
422
+ function findCallerAgent() {
359
423
  let pid = process.ppid;
360
424
  while (pid > 1) {
361
425
  const result = spawnSync("ps", ["-p", pid.toString(), "-o", "ppid=,comm="], {
@@ -365,9 +429,8 @@ function findCallerPid() {
365
429
  const parts = result.stdout.trim().split(/\s+/);
366
430
  const ppid = parseInt(parts[0], 10);
367
431
  const cmd = parts.slice(1).join(" ");
368
- if (cmd.includes("claude") || cmd.includes("codex")) {
369
- return pid;
370
- }
432
+ if (cmd.includes("claude")) return { pid, agent: "claude" };
433
+ if (cmd.includes("codex")) return { pid, agent: "codex" };
371
434
  pid = ppid;
372
435
  }
373
436
  return null;
@@ -378,7 +441,9 @@ function findCallerPid() {
378
441
  * @returns {{pid: string, command: string}[]}
379
442
  */
380
443
  function findOrphanedProcesses() {
381
- const result = spawnSync("ps", ["-eo", "pid=,ppid=,args="], { encoding: "utf-8" });
444
+ const result = spawnSync("ps", ["-eo", "pid=,ppid=,args="], {
445
+ encoding: "utf-8",
446
+ });
382
447
 
383
448
  if (result.status !== 0 || !result.stdout.trim()) {
384
449
  return [];
@@ -432,6 +497,17 @@ async function readStdin() {
432
497
  });
433
498
  }
434
499
 
500
+ /**
501
+ * @param {string | null | undefined} value
502
+ * @returns {Promise<string | undefined>}
503
+ */
504
+ async function readStdinIfNeeded(value) {
505
+ if (value && value !== "-") return value;
506
+ if (!hasStdinData()) return undefined;
507
+ const stdinText = await readStdin();
508
+ return stdinText || undefined;
509
+ }
510
+
435
511
  // =============================================================================
436
512
  // =============================================================================
437
513
  // Helpers - CLI argument parsing
@@ -452,6 +528,7 @@ async function readStdin() {
452
528
  * @property {boolean} all
453
529
  * @property {boolean} orphans
454
530
  * @property {boolean} force
531
+ * @property {boolean} stale
455
532
  * @property {boolean} version
456
533
  * @property {boolean} help
457
534
  * @property {string} [tool]
@@ -460,6 +537,8 @@ async function readStdin() {
460
537
  * @property {number} [tail]
461
538
  * @property {number} [limit]
462
539
  * @property {string} [branch]
540
+ * @property {string} [archangels]
541
+ * @property {string} [autoApprove]
463
542
  */
464
543
  function parseCliArgs(args) {
465
544
  const { values, positionals } = parseArgs({
@@ -475,15 +554,18 @@ function parseCliArgs(args) {
475
554
  all: { type: "boolean", default: false },
476
555
  orphans: { type: "boolean", default: false },
477
556
  force: { type: "boolean", default: false },
557
+ stale: { type: "boolean", default: false },
478
558
  version: { type: "boolean", short: "V", default: false },
479
559
  help: { type: "boolean", short: "h", default: false },
480
560
  // Value flags
481
561
  tool: { type: "string" },
562
+ "auto-approve": { type: "string" },
482
563
  session: { type: "string" },
483
564
  timeout: { type: "string" },
484
565
  tail: { type: "string" },
485
566
  limit: { type: "string" },
486
567
  branch: { type: "string" },
568
+ archangels: { type: "string" },
487
569
  },
488
570
  allowPositionals: true,
489
571
  strict: false, // Don't error on unknown flags
@@ -500,6 +582,7 @@ function parseCliArgs(args) {
500
582
  all: Boolean(values.all),
501
583
  orphans: Boolean(values.orphans),
502
584
  force: Boolean(values.force),
585
+ stale: Boolean(values.stale),
503
586
  version: Boolean(values.version),
504
587
  help: Boolean(values.help),
505
588
  tool: /** @type {string | undefined} */ (values.tool),
@@ -508,6 +591,8 @@ function parseCliArgs(args) {
508
591
  tail: values.tail !== undefined ? Number(values.tail) : undefined,
509
592
  limit: values.limit !== undefined ? Number(values.limit) : undefined,
510
593
  branch: /** @type {string | undefined} */ (values.branch),
594
+ archangels: /** @type {string | undefined} */ (values.archangels),
595
+ autoApprove: /** @type {string | undefined} */ (values["auto-approve"]),
511
596
  },
512
597
  positionals,
513
598
  };
@@ -516,6 +601,10 @@ function parseCliArgs(args) {
516
601
  // Helpers - session tracking
517
602
  // =============================================================================
518
603
 
604
+ // Regex pattern strings for session name parsing
605
+ const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
606
+ const PERM_HASH_PATTERN = "[0-9a-f]{8}";
607
+
519
608
  /**
520
609
  * @param {string} session
521
610
  * @returns {ParsedSession | null}
@@ -528,19 +617,27 @@ function parseSessionName(session) {
528
617
  const rest = match[2];
529
618
 
530
619
  // Archangel: {tool}-archangel-{name}-{uuid}
531
- const archangelMatch = rest.match(
532
- /^archangel-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i,
533
- );
620
+ const archangelPattern = new RegExp(`^archangel-(.+)-(${UUID_PATTERN})$`, "i");
621
+ const archangelMatch = rest.match(archangelPattern);
534
622
  if (archangelMatch) {
535
623
  return { tool, archangelName: archangelMatch[1], uuid: archangelMatch[2] };
536
624
  }
537
625
 
538
- // Partner: {tool}-partner-{uuid}
539
- const partnerMatch = rest.match(
540
- /^partner-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i,
626
+ // Partner: {tool}-partner-{uuid}[-p{hash}|-yolo]
627
+ const partnerPattern = new RegExp(
628
+ `^partner-(${UUID_PATTERN})(?:-p(${PERM_HASH_PATTERN})|-(yolo))?$`,
629
+ "i",
541
630
  );
631
+ const partnerMatch = rest.match(partnerPattern);
542
632
  if (partnerMatch) {
543
- return { tool, uuid: partnerMatch[1] };
633
+ const result = { tool, uuid: partnerMatch[1] };
634
+ if (partnerMatch[2]) {
635
+ return { ...result, permissionHash: partnerMatch[2] };
636
+ }
637
+ if (partnerMatch[3]) {
638
+ return { ...result, yolo: true };
639
+ }
640
+ return result;
544
641
  }
545
642
 
546
643
  // Anything else
@@ -549,10 +646,19 @@ function parseSessionName(session) {
549
646
 
550
647
  /**
551
648
  * @param {string} tool
649
+ * @param {{allowedTools?: string | null, yolo?: boolean}} [options]
552
650
  * @returns {string}
553
651
  */
554
- function generateSessionName(tool) {
555
- return `${tool}-partner-${randomUUID()}`;
652
+ function generateSessionName(tool, { allowedTools = null, yolo = false } = {}) {
653
+ const uuid = randomUUID();
654
+ if (yolo) {
655
+ return `${tool}-partner-${uuid}-yolo`;
656
+ }
657
+ const hash = computePermissionHash(allowedTools);
658
+ if (hash) {
659
+ return `${tool}-partner-${uuid}-p${hash}`;
660
+ }
661
+ return `${tool}-partner-${uuid}`;
556
662
  }
557
663
 
558
664
  /**
@@ -1150,6 +1256,54 @@ function getArchangelSessionPattern(config) {
1150
1256
  return `${config.tool}-archangel-${config.name}`;
1151
1257
  }
1152
1258
 
1259
+ /**
1260
+ * @param {string} rfpId
1261
+ * @param {string} prompt
1262
+ */
1263
+ function writeRfpRecord(rfpId, prompt) {
1264
+ ensureRfpDir();
1265
+ const p = path.join(RFP_DIR, `${rfpId}.md`);
1266
+ const block = [`### ${rfpId}`, "", prompt.trim(), ""].join("\n");
1267
+ writeFileSync(p, block, "utf-8");
1268
+ }
1269
+
1270
+ /**
1271
+ * @param {string} input
1272
+ * @returns {string}
1273
+ */
1274
+ function resolveRfpId(input) {
1275
+ ensureRfpDir();
1276
+ if (!existsSync(RFP_DIR)) return input;
1277
+ const files = readdirSync(RFP_DIR).filter((f) => f.endsWith(".md"));
1278
+ const ids = files.map((f) => f.replace(/\.md$/, ""));
1279
+ const matches = ids.filter((id) => id.startsWith(input));
1280
+ if (matches.length === 1) return matches[0];
1281
+ if (matches.length > 1) {
1282
+ console.log("ERROR: ambiguous rfp id. Matches:");
1283
+ for (const m of matches) console.log(` ${m}`);
1284
+ process.exit(1);
1285
+ }
1286
+ return input;
1287
+ }
1288
+
1289
+ /**
1290
+ * @param {ParentSession | null} parent
1291
+ * @returns {string}
1292
+ */
1293
+ function generateRfpId(parent) {
1294
+ const now = new Date();
1295
+ const y = now.getFullYear();
1296
+ const mo = String(now.getMonth() + 1).padStart(2, "0");
1297
+ const d = String(now.getDate()).padStart(2, "0");
1298
+ const h = String(now.getHours()).padStart(2, "0");
1299
+ const mi = String(now.getMinutes()).padStart(2, "0");
1300
+ const s = String(now.getSeconds()).padStart(2, "0");
1301
+ const ts = `${y}-${mo}-${d}-${h}-${mi}-${s}`;
1302
+ const base = parent?.uuid ? parent.uuid.split("-")[0] : randomUUID().split("-")[0];
1303
+ const suffix = randomUUID().split("-")[0].slice(0, 4);
1304
+ return `rfp-${base}-${ts}-${suffix}`.toLowerCase();
1305
+ }
1306
+
1153
1307
  // =============================================================================
1154
1308
  // Helpers - mailbox
1155
1309
  // =============================================================================
@@ -1166,15 +1320,25 @@ function ensureMailboxDir() {
1166
1320
  }
1167
1321
  }
1168
1322
 
1323
+ /**
1324
+ * @returns {void}
1325
+ */
1326
+ function ensureRfpDir() {
1327
+ if (!existsSync(RFP_DIR)) {
1328
+ mkdirSync(RFP_DIR, { recursive: true });
1329
+ }
1330
+ }
1331
+
1169
1332
  /**
1170
1333
  * @param {MailboxPayload} payload
1334
+ * @param {string} [type]
1171
1335
  * @returns {void}
1172
1336
  */
1173
- function writeToMailbox(payload) {
1337
+ function writeToMailbox(payload, type = "observation") {
1174
1338
  ensureMailboxDir();
1175
1339
  const entry = {
1176
1340
  timestamp: new Date().toISOString(),
1177
- type: "observation",
1341
+ type,
1178
1342
  payload,
1179
1343
  };
1180
1344
  appendFileSync(MAILBOX_PATH, JSON.stringify(entry) + "\n");
@@ -1396,8 +1560,8 @@ function findCurrentClaudeSession() {
1396
1560
 
1397
1561
  // We might be running from Claude but not inside tmux (e.g., VSCode, Cursor)
1398
1562
  // Find Claude sessions in the same cwd and pick the most recently active one
1399
- const callerPid = findCallerPid();
1400
- if (!callerPid) return null; // Not running from Claude
1563
+ const caller = findCallerAgent();
1564
+ if (!caller) return null;
1401
1565
 
1402
1566
  const cwd = process.cwd();
1403
1567
  const sessions = tmuxListSessions();
@@ -1811,6 +1975,7 @@ const State = {
1811
1975
  THINKING: "thinking",
1812
1976
  CONFIRMING: "confirming",
1813
1977
  RATE_LIMITED: "rate_limited",
1978
+ FEEDBACK_MODAL: "feedback_modal",
1814
1979
  };
1815
1980
 
1816
1981
  /**
@@ -1838,6 +2003,17 @@ function detectState(screen, config) {
1838
2003
  return State.RATE_LIMITED;
1839
2004
  }
1840
2005
 
2006
+ // Feedback modal - Claude CLI's "How is Claude doing this session?" prompt
2007
+ // Match the numbered options pattern (flexible on whitespace)
2008
+ if (
2009
+ /1:\s*Bad/i.test(recentLines) &&
2010
+ /2:\s*Fine/i.test(recentLines) &&
2011
+ /3:\s*Good/i.test(recentLines) &&
2012
+ /0:\s*Dismiss/i.test(recentLines)
2013
+ ) {
2014
+ return State.FEEDBACK_MODAL;
2015
+ }
2016
+
1841
2017
  // Confirming - check before THINKING because "Running…" in tool output matches thinking patterns
1842
2018
  const confirmPatterns = config.confirmPatterns || [];
1843
2019
  for (const pattern of confirmPatterns) {
@@ -1979,12 +2155,18 @@ class Agent {
1979
2155
  /**
1980
2156
  * @param {boolean} [yolo]
1981
2157
  * @param {string | null} [sessionName]
2158
+ * @param {string | null} [customAllowedTools]
1982
2159
  * @returns {string}
1983
2160
  */
1984
- getCommand(yolo, sessionName = null) {
2161
+ getCommand(yolo, sessionName = null, customAllowedTools = null) {
1985
2162
  let base;
1986
2163
  if (yolo) {
1987
2164
  base = this.yoloCommand;
2165
+ } else if (customAllowedTools) {
2166
+ // Custom permissions from --auto-approve flag
2167
+ // Escape quotes for shell since tmux runs the command through a shell
2168
+ const escaped = customAllowedTools.replace(/"/g, '\\"');
2169
+ base = `${this.startCommand} --allowedTools "${escaped}"`;
1988
2170
  } else if (this.safeAllowedTools) {
1989
2171
  // Default: auto-approve safe read-only operations
1990
2172
  base = `${this.startCommand} --allowedTools "${this.safeAllowedTools}"`;
@@ -1993,43 +2175,93 @@ class Agent {
1993
2175
  }
1994
2176
  // Some agents support session ID flags for deterministic session tracking
1995
2177
  if (this.sessionIdFlag && sessionName) {
1996
- return `${base} ${this.sessionIdFlag} ${sessionName}`;
2178
+ const parsed = parseSessionName(sessionName);
2179
+ if (parsed?.uuid) {
2180
+ return `${base} ${this.sessionIdFlag} ${parsed.uuid}`;
2181
+ }
1997
2182
  }
1998
2183
  return base;
1999
2184
  }
2000
2185
 
2001
- getDefaultSession() {
2186
+ /**
2187
+ * @param {{allowedTools?: string | null, yolo?: boolean}} [options]
2188
+ * @returns {string | null}
2189
+ */
2190
+ getDefaultSession({ allowedTools = null, yolo = false } = {}) {
2002
2191
  // Check env var for explicit session
2003
2192
  if (this.envVar && process.env[this.envVar]) {
2004
- return process.env[this.envVar];
2193
+ return process.env[this.envVar] ?? null;
2005
2194
  }
2006
2195
 
2007
2196
  const cwd = process.cwd();
2008
- const childPattern = new RegExp(`^${this.name}-(partner-)?[0-9a-f-]{36}$`, "i");
2197
+ // Match sessions: {tool}-(partner-)?{uuid}[-p{hash}|-yolo]?
2198
+ const childPattern = new RegExp(
2199
+ `^${this.name}-(partner-)?${UUID_PATTERN}(-p${PERM_HASH_PATTERN}|-yolo)?$`,
2200
+ "i",
2201
+ );
2202
+ const requestedHash = computePermissionHash(allowedTools);
2203
+
2204
+ /**
2205
+ * Find a matching session by walking up the directory tree.
2206
+ * Checks exact cwd first, then parent directories up to git root or home.
2207
+ * @param {string[]} sessions
2208
+ * @returns {string | null}
2209
+ */
2210
+ const findSessionInCwdOrParent = (sessions) => {
2211
+ const matchingSessions = sessions.filter((s) => {
2212
+ if (!childPattern.test(s)) return false;
2213
+
2214
+ const perms = getSessionPermissions(s);
2215
+
2216
+ // If yolo requested, only match yolo sessions
2217
+ if (yolo) {
2218
+ return perms.mode === "yolo";
2219
+ }
2220
+
2221
+ // If custom permissions requested, match yolo (superset) or same hash
2222
+ if (requestedHash) {
2223
+ return perms.mode === "yolo" || perms.hash === requestedHash;
2224
+ }
2225
+
2226
+ // If no special permissions, match safe sessions only
2227
+ return perms.mode === "safe";
2228
+ });
2229
+ if (matchingSessions.length === 0) return null;
2230
+
2231
+ // Cache session cwds to avoid repeated tmux calls
2232
+ const sessionCwds = new Map(matchingSessions.map((s) => [s, getTmuxSessionCwd(s)]));
2233
+
2234
+ let searchDir = cwd;
2235
+ const homeDir = os.homedir();
2009
2236
 
2010
- // If inside tmux, look for existing agent session in same cwd
2237
+ while (searchDir !== homeDir && searchDir !== "/") {
2238
+ const existing = matchingSessions.find((s) => sessionCwds.get(s) === searchDir);
2239
+ if (existing) return existing;
2240
+
2241
+ // Stop at git root (don't leak across projects)
2242
+ if (existsSync(path.join(searchDir, ".git"))) break;
2243
+
2244
+ searchDir = path.dirname(searchDir);
2245
+ }
2246
+
2247
+ return null;
2248
+ };
2249
+
2250
+ // If inside tmux, look for existing agent session in cwd or parent
2011
2251
  const current = tmuxCurrentSession();
2012
2252
  if (current) {
2013
2253
  const sessions = tmuxListSessions();
2014
- const existing = sessions.find((s) => {
2015
- if (!childPattern.test(s)) return false;
2016
- const sessionCwd = getTmuxSessionCwd(s);
2017
- return sessionCwd === cwd;
2018
- });
2254
+ const existing = findSessionInCwdOrParent(sessions);
2019
2255
  if (existing) return existing;
2020
- // No existing session in this cwd - will generate new one in cmdStart
2256
+ // No existing session in this cwd or parent - will generate new one in cmdStart
2021
2257
  return null;
2022
2258
  }
2023
2259
 
2024
- // Walk up to find claude/codex ancestor and reuse its session (must match cwd)
2025
- const callerPid = findCallerPid();
2026
- if (callerPid) {
2260
+ // Walk up to find claude/codex ancestor and reuse its session
2261
+ const caller = findCallerAgent();
2262
+ if (caller) {
2027
2263
  const sessions = tmuxListSessions();
2028
- const existing = sessions.find((s) => {
2029
- if (!childPattern.test(s)) return false;
2030
- const sessionCwd = getTmuxSessionCwd(s);
2031
- return sessionCwd === cwd;
2032
- });
2264
+ const existing = findSessionInCwdOrParent(sessions);
2033
2265
  if (existing) return existing;
2034
2266
  }
2035
2267
 
@@ -2038,10 +2270,11 @@ class Agent {
2038
2270
  }
2039
2271
 
2040
2272
  /**
2273
+ * @param {{allowedTools?: string | null, yolo?: boolean}} [options]
2041
2274
  * @returns {string}
2042
2275
  */
2043
- generateSession() {
2044
- return generateSessionName(this.name);
2276
+ generateSession(options = {}) {
2277
+ return generateSessionName(this.name, options);
2045
2278
  }
2046
2279
 
2047
2280
  /**
@@ -2367,8 +2600,12 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2367
2600
  const initialScreen = tmuxCapture(session);
2368
2601
  const initialState = agent.getState(initialScreen);
2369
2602
 
2370
- // Already in terminal state
2371
- if (
2603
+ // Dismiss feedback modal if present
2604
+ if (initialState === State.FEEDBACK_MODAL) {
2605
+ tmuxSend(session, "0");
2606
+ await sleep(200);
2607
+ } else if (
2608
+ // Already in terminal state
2372
2609
  initialState === State.RATE_LIMITED ||
2373
2610
  initialState === State.CONFIRMING ||
2374
2611
  initialState === State.READY
@@ -2381,6 +2618,13 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2381
2618
  const screen = tmuxCapture(session);
2382
2619
  const state = agent.getState(screen);
2383
2620
 
2621
+ // Dismiss feedback modal if it appears
2622
+ if (state === State.FEEDBACK_MODAL) {
2623
+ tmuxSend(session, "0");
2624
+ await sleep(200);
2625
+ continue;
2626
+ }
2627
+
2384
2628
  if (state === State.RATE_LIMITED || state === State.CONFIRMING || state === State.READY) {
2385
2629
  return { state, screen };
2386
2630
  }
@@ -2421,6 +2665,13 @@ async function pollForResponse(agent, session, timeoutMs, hooks = {}) {
2421
2665
  return { state, screen };
2422
2666
  }
2423
2667
 
2668
+ // Dismiss feedback modal if it appears
2669
+ if (state === State.FEEDBACK_MODAL) {
2670
+ tmuxSend(session, "0");
2671
+ await sleep(200);
2672
+ continue;
2673
+ }
2674
+
2424
2675
  if (screen !== lastScreen) {
2425
2676
  lastScreen = screen;
2426
2677
  stableAt = Date.now();
@@ -2530,6 +2781,7 @@ async function autoApproveLoop(agent, session, timeoutMs, waitFn) {
2530
2781
  continue;
2531
2782
  }
2532
2783
 
2784
+ // FEEDBACK_MODAL is handled by the underlying waitFn (pollForResponse)
2533
2785
  debugError("autoApproveLoop", new Error(`unexpected state: ${state}`));
2534
2786
  }
2535
2787
 
@@ -2541,12 +2793,13 @@ async function autoApproveLoop(agent, session, timeoutMs, waitFn) {
2541
2793
  * @param {string | null | undefined} session
2542
2794
  * @param {Object} [options]
2543
2795
  * @param {boolean} [options.yolo]
2796
+ * @param {string | null} [options.allowedTools]
2544
2797
  * @returns {Promise<string>}
2545
2798
  */
2546
- async function cmdStart(agent, session, { yolo = false } = {}) {
2799
+ async function cmdStart(agent, session, { yolo = false, allowedTools = null } = {}) {
2547
2800
  // Generate session name if not provided
2548
2801
  if (!session) {
2549
- session = agent.generateSession();
2802
+ session = agent.generateSession({ allowedTools, yolo });
2550
2803
  }
2551
2804
 
2552
2805
  if (tmuxHasSession(session)) return session;
@@ -2558,7 +2811,7 @@ async function cmdStart(agent, session, { yolo = false } = {}) {
2558
2811
  process.exit(1);
2559
2812
  }
2560
2813
 
2561
- const command = agent.getCommand(yolo, session);
2814
+ const command = agent.getCommand(yolo, session, allowedTools);
2562
2815
  tmuxNewSession(session, command);
2563
2816
 
2564
2817
  const start = Date.now();
@@ -2571,6 +2824,18 @@ async function cmdStart(agent, session, { yolo = false } = {}) {
2571
2824
  continue;
2572
2825
  }
2573
2826
 
2827
+ if (state === State.FEEDBACK_MODAL) {
2828
+ tmuxSend(session, "0");
2829
+ await sleep(200);
2830
+ continue;
2831
+ }
2832
+
2833
+ if (state === State.CONFIRMING) {
2834
+ tmuxSend(session, agent.approveKey);
2835
+ await sleep(APPROVE_DELAY_MS);
2836
+ continue;
2837
+ }
2838
+
2574
2839
  if (state === State.READY) return session;
2575
2840
 
2576
2841
  await sleep(POLL_MS);
@@ -2711,6 +2976,21 @@ function startArchangel(config, parentSession = null) {
2711
2976
  );
2712
2977
  }
2713
2978
 
2979
+ /**
2980
+ * @param {string} pattern
2981
+ * @param {number} [timeoutMs]
2982
+ * @returns {Promise<string | undefined>}
2983
+ */
2984
+ async function waitForArchangelSession(pattern, timeoutMs = ARCHANGEL_STARTUP_TIMEOUT_MS) {
2985
+ const start = Date.now();
2986
+ while (Date.now() - start < timeoutMs) {
2987
+ const session = findArchangelSession(pattern);
2988
+ if (session) return session;
2989
+ await sleep(200);
2990
+ }
2991
+ return undefined;
2992
+ }
2993
+
2714
2994
  // =============================================================================
2715
2995
  // Command: archangel (runs as the archangel process itself)
2716
2996
  // =============================================================================
@@ -3161,6 +3441,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs";
3161
3441
  import { dirname, join } from "node:path";
3162
3442
  import { fileURLToPath } from "node:url";
3163
3443
  import { createHash } from "node:crypto";
3444
+ import { execSync } from "node:child_process";
3164
3445
 
3165
3446
  const __dirname = dirname(fileURLToPath(import.meta.url));
3166
3447
  const AI_DIR = join(__dirname, "..");
@@ -3168,6 +3449,15 @@ const DEBUG = process.env.AX_DEBUG === "1";
3168
3449
  const MAILBOX = join(AI_DIR, "mailbox.jsonl");
3169
3450
  const MAX_AGE_MS = 60 * 60 * 1000;
3170
3451
 
3452
+ function getTmuxSessionName() {
3453
+ if (!process.env.TMUX) return null;
3454
+ try {
3455
+ return execSync("tmux display-message -p '#S'", { encoding: "utf-8" }).trim();
3456
+ } catch {
3457
+ return null;
3458
+ }
3459
+ }
3460
+
3171
3461
  // Read hook input from stdin
3172
3462
  let hookInput = {};
3173
3463
  try {
@@ -3182,8 +3472,9 @@ const hookEvent = hookInput.hook_event_name || "";
3182
3472
 
3183
3473
  if (DEBUG) console.error("[hook] session:", sessionId, "event:", hookEvent);
3184
3474
 
3185
- // NO-OP for archangel or partner sessions
3186
- if (sessionId.includes("-archangel-") || sessionId.includes("-partner-")) {
3475
+ const tmuxSession = getTmuxSessionName();
3476
+ if (DEBUG) console.error("[hook] tmux session:", tmuxSession);
3477
+ if (tmuxSession && (tmuxSession.includes("-archangel-") || tmuxSession.includes("-partner-"))) {
3187
3478
  if (DEBUG) console.error("[hook] skipping non-parent session");
3188
3479
  process.exit(0);
3189
3480
  }
@@ -3713,7 +4004,13 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
3713
4004
  console.log(`**Branch**: ${p.branch || "?"} @ ${p.commit || "?"}\n`);
3714
4005
  }
3715
4006
 
3716
- if (p.message) {
4007
+ if (p.rfpId) {
4008
+ console.log(`**RFP**: ${p.rfpId}\n`);
4009
+ }
4010
+
4011
+ if (entry.type === "proposal") {
4012
+ console.log(`**Proposal**: ${p.message || ""}\n`);
4013
+ } else if (p.message) {
3717
4014
  console.log(`**Assistant**: ${p.message}\n`);
3718
4015
  }
3719
4016
 
@@ -3727,13 +4024,246 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
3727
4024
  }
3728
4025
  }
3729
4026
 
4027
+ /**
4028
+ * @param {string} rfpId
4029
+ * @param {string} archangel
4030
+ * @returns {string | null}
4031
+ */
4032
+ function getProposalFromMailbox(rfpId, archangel) {
4033
+ if (!existsSync(MAILBOX_PATH)) return null;
4034
+ let result = null;
4035
+ try {
4036
+ const lines = readFileSync(MAILBOX_PATH, "utf-8").trim().split("\n").filter(Boolean);
4037
+ for (const line of lines) {
4038
+ try {
4039
+ const entry = JSON.parse(line);
4040
+ if (entry?.type !== "proposal") continue;
4041
+ const p = entry.payload || {};
4042
+ if (p.rfpId === rfpId && p.archangel === archangel) {
4043
+ result = p.message || "";
4044
+ }
4045
+ } catch {
4046
+ // Skip malformed lines
4047
+ }
4048
+ }
4049
+ } catch (err) {
4050
+ debugError("getProposalFromMailbox", err);
4051
+ }
4052
+ return result;
4053
+ }
4054
+
4055
+ /**
4056
+ * @param {string} prompt
4057
+ * @param {{archangels?: string, fresh?: boolean, noWait?: boolean}} [options]
4058
+ */
4059
+ async function cmdRfp(prompt, { archangels, fresh = false, noWait = false } = {}) {
4060
+ const configs = loadAgentConfigs();
4061
+ if (configs.length === 0) {
4062
+ console.log(`No archangels found in ${AGENTS_DIR}/`);
4063
+ process.exit(1);
4064
+ }
4065
+
4066
+ const requested = archangels
4067
+ ? archangels
4068
+ .split(",")
4069
+ .map((s) => s.trim())
4070
+ .filter(Boolean)
4071
+ : configs.map((c) => c.name);
4072
+
4073
+ if (requested.length === 0) {
4074
+ console.log("ERROR: no archangels specified");
4075
+ process.exit(1);
4076
+ }
4077
+
4078
+ const missing = requested.filter((name) => !configs.some((c) => c.name === name));
4079
+ if (missing.length > 0) {
4080
+ console.log(`ERROR: unknown archangel(s): ${missing.join(", ")}`);
4081
+ process.exit(1);
4082
+ }
4083
+
4084
+ const parent = findParentSession();
4085
+ const rfpId = generateRfpId(parent);
4086
+
4087
+ for (const name of requested) {
4088
+ const config = configs.find((c) => c.name === name);
4089
+ if (!config) continue;
4090
+
4091
+ const pattern = getArchangelSessionPattern(config);
4092
+ let session = findArchangelSession(pattern);
4093
+ if (!session) {
4094
+ startArchangel(config, parent);
4095
+ session = await waitForArchangelSession(pattern);
4096
+ }
4097
+
4098
+ if (!session) {
4099
+ console.log(`ERROR: failed to start archangel '${name}'`);
4100
+ continue;
4101
+ }
4102
+
4103
+ const { agent } = resolveAgent({ sessionName: session });
4104
+
4105
+ if (fresh) {
4106
+ tmuxSendLiteral(session, "/new");
4107
+ await sleep(50);
4108
+ tmuxSend(session, "Enter");
4109
+ }
4110
+
4111
+ const ready = await waitUntilReady(agent, session, ARCHANGEL_STARTUP_TIMEOUT_MS);
4112
+ if (ready.state !== State.READY) {
4113
+ console.log(`[rfp] ${name} not ready (${ready.state}), skipping`);
4114
+ continue;
4115
+ }
4116
+
4117
+ const rfpPrompt = `## RFP ${rfpId}\n\n${RFP_PREAMBLE}\n\n${prompt}\n\nReturn exactly one proposal.`;
4118
+ tmuxSendLiteral(session, rfpPrompt);
4119
+ await sleep(200);
4120
+ tmuxSend(session, "Enter");
4121
+ }
4122
+
4123
+ writeRfpRecord(rfpId, prompt);
4124
+ const archangelList = requested.join(",");
4125
+ const base = rfpId.split("-")[1];
4126
+ const shortId = `rfp-${base}`;
4127
+ console.log(`rfp: ${rfpId} (${archangelList})`);
4128
+ if (noWait) {
4129
+ // Truncate prompt for display (first line, max 60 chars)
4130
+ const firstLine = prompt.split("\n")[0];
4131
+ const taskPreview = firstLine.length > 60 ? firstLine.slice(0, 57) + "..." : firstLine;
4132
+
4133
+ let output = `Task: ${taskPreview}
4134
+
4135
+ Add this to your task list, then continue with your work.`;
4136
+
4137
+ // Show TaskCreate example if caller is Claude
4138
+ const caller = findCallerAgent();
4139
+ if (caller?.agent === "claude") {
4140
+ output += `
4141
+
4142
+ e.g.
4143
+ TaskCreate({ subject: "ax:${shortId} - ${taskPreview}" })
4144
+ `;
4145
+ }
4146
+
4147
+ console.log(output);
4148
+ }
4149
+ }
4150
+
4151
+ /**
4152
+ * @param {string} rfpId
4153
+ * @param {{archangels?: string, timeoutMs?: number}} [options]
4154
+ */
4155
+ async function cmdRfpWait(rfpId, { archangels, timeoutMs = ARCHANGEL_RESPONSE_TIMEOUT_MS } = {}) {
4156
+ const resolvedRfpId = resolveRfpId(rfpId);
4157
+ const configs = loadAgentConfigs();
4158
+ if (configs.length === 0) {
4159
+ console.log(`No archangels found in ${AGENTS_DIR}/`);
4160
+ process.exit(1);
4161
+ }
4162
+
4163
+ const requested = archangels
4164
+ ? archangels
4165
+ .split(",")
4166
+ .map((s) => s.trim())
4167
+ .filter(Boolean)
4168
+ : configs.map((c) => c.name);
4169
+
4170
+ if (requested.length === 0) {
4171
+ console.log("ERROR: no archangels specified");
4172
+ process.exit(1);
4173
+ }
4174
+
4175
+ const missing = requested.filter((name) => !configs.some((c) => c.name === name));
4176
+ if (missing.length > 0) {
4177
+ console.log(`ERROR: unknown archangel(s): ${missing.join(", ")}`);
4178
+ process.exit(1);
4179
+ }
4180
+
4181
+ let wroteAny = false;
4182
+ let printedAny = false;
4183
+
4184
+ for (const name of requested) {
4185
+ const config = configs.find((c) => c.name === name);
4186
+ if (!config) continue;
4187
+
4188
+ const pattern = getArchangelSessionPattern(config);
4189
+ const session = findArchangelSession(pattern);
4190
+ if (!session) {
4191
+ console.log(`[rfp] ${name} session not found, skipping`);
4192
+ continue;
4193
+ }
4194
+
4195
+ const existing = getProposalFromMailbox(resolvedRfpId, name);
4196
+ if (existing !== null) {
4197
+ if (printedAny) console.log("");
4198
+ console.log(`[${name}]`);
4199
+ console.log(existing);
4200
+ wroteAny = true;
4201
+ printedAny = true;
4202
+ continue;
4203
+ }
4204
+
4205
+ const { agent } = resolveAgent({ sessionName: session });
4206
+ let result;
4207
+ try {
4208
+ result = await waitUntilReady(agent, session, timeoutMs);
4209
+ } catch (err) {
4210
+ if (err instanceof TimeoutError) {
4211
+ console.log(`[rfp] ${name} timed out`);
4212
+ } else {
4213
+ console.log(`[rfp] ${name} error: ${err instanceof Error ? err.message : err}`);
4214
+ }
4215
+ continue;
4216
+ }
4217
+
4218
+ if (result.state === State.RATE_LIMITED) {
4219
+ console.log(`[rfp] ${name} rate limited`);
4220
+ continue;
4221
+ }
4222
+ if (result.state === State.CONFIRMING) {
4223
+ console.log(`[rfp] ${name} awaiting confirmation`);
4224
+ continue;
4225
+ }
4226
+
4227
+ const response = agent.getResponse(session, result.screen) || "";
4228
+ if (!response || response.trim() === "EMPTY_RESPONSE") {
4229
+ continue;
4230
+ }
4231
+
4232
+ writeToMailbox(
4233
+ {
4234
+ agent: name,
4235
+ session,
4236
+ branch: getCurrentBranch(),
4237
+ commit: getCurrentCommit(),
4238
+ files: [],
4239
+ message: response,
4240
+ rfpId: resolvedRfpId,
4241
+ archangel: name,
4242
+ },
4243
+ "proposal",
4244
+ );
4245
+ if (printedAny) console.log("");
4246
+ console.log(`[${name}]`);
4247
+ console.log(response);
4248
+ wroteAny = true;
4249
+ printedAny = true;
4250
+ }
4251
+
4252
+ if (!wroteAny) process.exit(1);
4253
+ }
4254
+
3730
4255
  /**
3731
4256
  * @param {Agent} agent
3732
4257
  * @param {string | null | undefined} session
3733
4258
  * @param {string} message
3734
- * @param {{noWait?: boolean, yolo?: boolean, timeoutMs?: number}} [options]
4259
+ * @param {{noWait?: boolean, yolo?: boolean, allowedTools?: string | null, timeoutMs?: number}} [options]
3735
4260
  */
3736
- async function cmdAsk(agent, session, message, { noWait = false, yolo = false, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
4261
+ async function cmdAsk(
4262
+ agent,
4263
+ session,
4264
+ message,
4265
+ { noWait = false, yolo = false, allowedTools = null, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
4266
+ ) {
3737
4267
  const sessionExists = session != null && tmuxHasSession(session);
3738
4268
  const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
3739
4269
 
@@ -3747,7 +4277,7 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3747
4277
  /** @type {string} */
3748
4278
  const activeSession = sessionExists
3749
4279
  ? /** @type {string} */ (session)
3750
- : await cmdStart(agent, session, { yolo });
4280
+ : await cmdStart(agent, session, { yolo, allowedTools });
3751
4281
 
3752
4282
  tmuxSendLiteral(activeSession, message);
3753
4283
  await sleep(50);
@@ -3756,12 +4286,26 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3756
4286
  if (noWait) {
3757
4287
  const parsed = parseSessionName(activeSession);
3758
4288
  const shortId = parsed?.uuid?.slice(0, 8) || activeSession;
3759
- const cli = path.basename(process.argv[1], ".js");
3760
- console.log(`Sent to: ${shortId}
4289
+ // Truncate message for display (first line, max 60 chars)
4290
+ const firstLine = message.split("\n")[0];
4291
+ const taskPreview = firstLine.length > 60 ? firstLine.slice(0, 57) + "..." : firstLine;
4292
+
4293
+ let output = `Sent to: ${shortId}
4294
+ Task: ${taskPreview}
4295
+
4296
+ Add this to your task list, then continue with your work.`;
4297
+
4298
+ // Show TaskCreate example if caller is Claude
4299
+ const caller = findCallerAgent();
4300
+ if (caller?.agent === "claude") {
4301
+ output += `
3761
4302
 
3762
4303
  e.g.
3763
- ${cli} status --session=${shortId}
3764
- ${cli} output --session=${shortId}`);
4304
+ TaskCreate({ subject: "ax:${shortId} - ${taskPreview}" })
4305
+ `;
4306
+ }
4307
+
4308
+ console.log(output);
3765
4309
  return;
3766
4310
  }
3767
4311
 
@@ -3953,9 +4497,14 @@ async function cmdReview(
3953
4497
  * @param {Agent} agent
3954
4498
  * @param {string | null | undefined} session
3955
4499
  * @param {number} [index]
3956
- * @param {{wait?: boolean, timeoutMs?: number}} [options]
4500
+ * @param {{wait?: boolean, stale?: boolean, timeoutMs?: number}} [options]
3957
4501
  */
3958
- async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs } = {}) {
4502
+ async function cmdOutput(
4503
+ agent,
4504
+ session,
4505
+ index = 0,
4506
+ { wait = false, stale = false, timeoutMs } = {},
4507
+ ) {
3959
4508
  if (!session || !tmuxHasSession(session)) {
3960
4509
  console.log("ERROR: no session");
3961
4510
  process.exit(1);
@@ -3982,8 +4531,11 @@ async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs }
3982
4531
  }
3983
4532
 
3984
4533
  if (state === State.THINKING) {
3985
- console.log("THINKING");
3986
- process.exit(4);
4534
+ if (!stale) {
4535
+ console.log("THINKING: Use --wait to block, or --stale for old response.");
4536
+ process.exit(1);
4537
+ }
4538
+ // --stale: fall through to show previous response
3987
4539
  }
3988
4540
 
3989
4541
  const output = agent.getResponse(session, screen, index);
@@ -4181,7 +4733,12 @@ function resolveAgent({ toolFlag, sessionName } = {}) {
4181
4733
  if (invoked === "axclaude" || invoked === "claude") return { agent: ClaudeAgent };
4182
4734
  if (invoked === "axcodex" || invoked === "codex") return { agent: CodexAgent };
4183
4735
 
4184
- // 4. AX_DEFAULT_TOOL environment variable
4736
+ // 4. Infer from parent process (running from within claude/codex)
4737
+ const caller = findCallerAgent();
4738
+ if (caller?.agent === "claude") return { agent: ClaudeAgent };
4739
+ if (caller?.agent === "codex") return { agent: CodexAgent };
4740
+
4741
+ // 5. AX_DEFAULT_TOOL environment variable
4185
4742
  const defaultTool = process.env.AX_DEFAULT_TOOL;
4186
4743
  if (defaultTool === "claude") return { agent: ClaudeAgent };
4187
4744
  if (defaultTool === "codex" || !defaultTool) return { agent: CodexAgent };
@@ -4197,72 +4754,64 @@ function resolveAgent({ toolFlag, sessionName } = {}) {
4197
4754
  function printHelp(agent, cliName) {
4198
4755
  const name = cliName;
4199
4756
  const backendName = agent.displayName;
4200
- const hasReview = !!agent.reviewOptions;
4201
4757
 
4202
4758
  console.log(`${name} v${VERSION} - agentic assistant CLI (${backendName})
4203
4759
 
4204
4760
  Usage: ${name} [OPTIONS] <command|message> [ARGS...]
4205
4761
 
4206
- Commands:
4207
- agents List all running agents with state and log paths
4762
+ Messaging:
4763
+ <message> Send message to ${name}
4764
+ review [TYPE] Review code: pr, uncommitted, commit, custom
4765
+
4766
+ Sessions:
4767
+ compact Summarise session to shrink context size
4768
+ reset Start fresh conversation
4769
+ agents List all running agents
4208
4770
  target Show default target session for current tool
4209
4771
  attach [SESSION] Attach to agent session interactively
4210
- log SESSION View conversation log (--tail=N, --follow, --reasoning)
4211
- mailbox View archangel observations (--limit=N, --branch=X, --all)
4772
+ kill Kill sessions (--all, --session=NAME, --orphans [--force])
4773
+
4774
+ Archangels:
4212
4775
  summon [name] Summon archangels (all, or by name)
4213
4776
  recall [name] Recall archangels (all, or by name)
4214
- kill Kill sessions (--all, --session=NAME, --orphans [--force])
4215
- status Check state (exit: 0=ready, 2=rate_limited, 3=confirming, 4=thinking)
4777
+ mailbox Archangel notes (filters: --branch=git, --all)
4778
+ rfp <prompt> Request proposals (--archangels=a,b)
4779
+ rfp wait <id> Wait for proposals (--archangels=a,b)
4780
+
4781
+ Recovery/State:
4782
+ status Exit code: ready=0 rate_limit=2 confirm=3 thinking=4
4216
4783
  output [-N] Show response (0=last, -1=prev, -2=older)
4217
- debug Show raw screen output and detected state${
4218
- hasReview
4219
- ? `
4220
- review [TYPE] Review code: pr, uncommitted, commit, custom`
4221
- : ""
4222
- }
4223
- select N Select menu option N
4784
+ debug Show raw screen output and detected state
4224
4785
  approve Approve pending action (send 'y')
4225
4786
  reject Reject pending action (send 'n')
4787
+ select N Select menu option N
4226
4788
  send KEYS Send key sequence (e.g. "1[Enter]", "[Escape]")
4227
- compact Summarize conversation (when context is full)
4228
- reset Start fresh conversation
4229
- <message> Send message to ${name}
4789
+ log [SESSION] View conversation log (--tail=N, --follow, --reasoning)
4230
4790
 
4231
4791
  Flags:
4232
4792
  --tool=NAME Use specific agent (codex, claude)
4233
- --session=NAME Target session by name, archangel name, or UUID prefix (self = current)
4793
+ --session=ID name | archangel | uuid-prefix | self
4794
+ --fresh Reset conversation before review
4795
+ --yolo Skip all confirmations (dangerous)
4796
+ --auto-approve=TOOLS Auto-approve specific tools (e.g. 'Bash("cargo *")')
4234
4797
  --wait Wait for response (default for messages; required for approve/reject)
4235
4798
  --no-wait Fire-and-forget: send message, print session ID, exit immediately
4236
4799
  --timeout=N Set timeout in seconds (default: ${DEFAULT_TIMEOUT_MS / 1000}, reviews: ${REVIEW_TIMEOUT_MS / 1000})
4237
- --yolo Skip all confirmations (dangerous)
4238
- --fresh Reset conversation before review
4239
- --orphans Kill orphaned claude/codex processes (PPID=1)
4240
- --force Use SIGKILL instead of SIGTERM (with --orphans)
4241
-
4242
- Environment:
4243
- AX_DEFAULT_TOOL Default agent when using 'ax' (claude or codex, default: codex)
4244
- ${agent.envVar} Override default session name
4245
- AX_CLAUDE_CONFIG_DIR Override Claude config directory (default: ~/.claude)
4246
- AX_CODEX_CONFIG_DIR Override Codex config directory (default: ~/.codex)
4247
- AX_REVIEW_MODE=exec Bypass /review, send instructions directly (codex only)
4248
- AX_DEBUG=1 Enable debug logging
4249
4800
 
4250
4801
  Examples:
4251
4802
  ${name} "explain this codebase"
4252
- ${name} "review the error handling" # Auto custom review (${REVIEW_TIMEOUT_MS / 60000}min timeout)
4253
- ${name} "FYI: auth was refactored" --no-wait # Send context to a working session (no response needed)
4803
+ ${name} "review the error handling" # Auto custom review (${REVIEW_TIMEOUT_MS / 60000}min timeout)
4804
+ ${name} "FYI: auth was refactored" --no-wait # Send context to a working session (no response needed)
4805
+ ${name} --auto-approve='Bash("cargo *")' "run tests" # Session with specific permissions
4254
4806
  ${name} review uncommitted --wait
4255
- ${name} approve --wait
4256
- ${name} kill # Kill agents in current project
4257
- ${name} kill --all # Kill all agents across all projects
4258
- ${name} kill --session=NAME # Kill specific session
4259
- ${name} send "1[Enter]" # Recovery: select option 1 and press Enter
4260
- ${name} send "[Escape][Escape]" # Recovery: escape out of a dialog
4261
- ${name} summon # Summon all archangels from .ai/agents/*.md
4262
- ${name} summon reviewer # Summon by name (creates config if new)
4263
- ${name} recall # Recall all archangels
4264
- ${name} recall reviewer # Recall one by name
4265
- ${name} agents # List all agents (shows TYPE=archangel)
4807
+ ${name} kill # Kill agents in current project
4808
+ ${name} kill --all # Kill all agents across all projects
4809
+ ${name} kill --session=NAME # Kill specific session
4810
+ ${name} summon # Summon all archangels from .ai/agents/*.md
4811
+ ${name} summon reviewer # Summon by name (creates config if new)
4812
+ ${name} recall # Recall all archangels
4813
+ ${name} recall reviewer # Recall one by name
4814
+ ${name} agents # List all agents (shows TYPE=archangel)
4266
4815
 
4267
4816
  Note: Reviews and complex tasks may take several minutes.
4268
4817
  Use Bash run_in_background for long operations (not --no-wait).`);
@@ -4289,7 +4838,8 @@ async function main() {
4289
4838
  }
4290
4839
 
4291
4840
  // Extract flags into local variables for convenience
4292
- const { wait, noWait, yolo, fresh, reasoning, follow, all, orphans, force } = flags;
4841
+ const { wait, noWait, yolo, fresh, reasoning, follow, all, orphans, force, stale, autoApprove } =
4842
+ flags;
4293
4843
 
4294
4844
  // Session resolution (must happen before agent resolution so we can infer tool from session name)
4295
4845
  let session = null;
@@ -4308,15 +4858,18 @@ async function main() {
4308
4858
  }
4309
4859
 
4310
4860
  // Agent resolution (considers --tool flag, session name, invocation, and env vars)
4311
- const { agent, error: agentError } = resolveAgent({ toolFlag: flags.tool, sessionName: session });
4861
+ const { agent, error: agentError } = resolveAgent({
4862
+ toolFlag: flags.tool,
4863
+ sessionName: session,
4864
+ });
4312
4865
  if (agentError) {
4313
4866
  console.log(`ERROR: ${agentError}`);
4314
4867
  process.exit(1);
4315
4868
  }
4316
4869
 
4317
- // If no explicit session, use agent's default
4870
+ // If no explicit session, use agent's default (with permission filtering)
4318
4871
  if (!session) {
4319
- session = agent.getDefaultSession();
4872
+ session = agent.getDefaultSession({ allowedTools: autoApprove, yolo });
4320
4873
  }
4321
4874
 
4322
4875
  // Timeout (convert seconds to milliseconds)
@@ -4344,7 +4897,7 @@ async function main() {
4344
4897
  // Dispatch commands
4345
4898
  if (cmd === "agents") return cmdAgents();
4346
4899
  if (cmd === "target") {
4347
- const defaultSession = agent.getDefaultSession();
4900
+ const defaultSession = agent.getDefaultSession({ allowedTools: autoApprove, yolo });
4348
4901
  if (defaultSession) {
4349
4902
  console.log(defaultSession);
4350
4903
  } else {
@@ -4360,20 +4913,39 @@ async function main() {
4360
4913
  if (cmd === "attach") return cmdAttach(positionals[1] || session);
4361
4914
  if (cmd === "log") return cmdLog(positionals[1] || session, { tail, reasoning, follow });
4362
4915
  if (cmd === "mailbox") return cmdMailbox({ limit, branch, all });
4916
+ if (cmd === "rfp") {
4917
+ if (positionals[1] === "wait") {
4918
+ const rfpId = positionals[2];
4919
+ if (!rfpId) {
4920
+ console.log("ERROR: missing rfp id");
4921
+ process.exit(1);
4922
+ }
4923
+ return cmdRfpWait(rfpId, { archangels: flags.archangels, timeoutMs });
4924
+ }
4925
+ const rawPrompt = positionals.slice(1).join(" ");
4926
+ const prompt = await readStdinIfNeeded(rawPrompt);
4927
+ if (!prompt) {
4928
+ console.log("ERROR: missing prompt for rfp");
4929
+ process.exit(1);
4930
+ }
4931
+ return cmdRfp(prompt, { archangels: flags.archangels, fresh, noWait });
4932
+ }
4363
4933
  if (cmd === "approve") return cmdApprove(agent, session, { wait, timeoutMs });
4364
4934
  if (cmd === "reject") return cmdReject(agent, session, { wait, timeoutMs });
4365
- if (cmd === "review")
4366
- return cmdReview(agent, session, positionals[1], positionals[2], {
4935
+ if (cmd === "review") {
4936
+ const customInstructions = await readStdinIfNeeded(positionals[2]);
4937
+ return cmdReview(agent, session, positionals[1], customInstructions ?? undefined, {
4367
4938
  wait,
4368
4939
  fresh,
4369
4940
  timeoutMs,
4370
4941
  });
4942
+ }
4371
4943
  if (cmd === "status") return cmdStatus(agent, session);
4372
4944
  if (cmd === "debug") return cmdDebug(agent, session);
4373
4945
  if (cmd === "output") {
4374
4946
  const indexArg = positionals[1];
4375
4947
  const index = indexArg?.startsWith("-") ? parseInt(indexArg, 10) : 0;
4376
- return cmdOutput(agent, session, index, { wait, timeoutMs });
4948
+ return cmdOutput(agent, session, index, { wait, stale, timeoutMs });
4377
4949
  }
4378
4950
  if (cmd === "send" && positionals.length > 1)
4379
4951
  return cmdSend(session, positionals.slice(1).join(" "));
@@ -4383,18 +4955,17 @@ async function main() {
4383
4955
  return cmdSelect(agent, session, positionals[1], { wait, timeoutMs });
4384
4956
 
4385
4957
  // Default: send message
4386
- let message = positionals.join(" ");
4387
- if (!message && hasStdinData()) {
4388
- message = await readStdin();
4389
- }
4958
+ const rawMessage = positionals.join(" ");
4959
+ let message = await readStdinIfNeeded(rawMessage);
4390
4960
 
4391
4961
  if (!message || flags.help) {
4392
4962
  printHelp(agent, cliName);
4393
4963
  process.exit(0);
4394
4964
  }
4965
+ const messageText = message;
4395
4966
 
4396
4967
  // Detect "review ..." or "please review ..." and route to custom review mode
4397
- const reviewMatch = message.match(/^(?:please )?review\s*(.*)/i);
4968
+ const reviewMatch = messageText.match(/^(?:please )?review\s*(.*)/i);
4398
4969
  if (reviewMatch && agent.reviewOptions) {
4399
4970
  const customInstructions = reviewMatch[1].trim() || null;
4400
4971
  return cmdReview(agent, session, "custom", customInstructions, {
@@ -4404,7 +4975,12 @@ async function main() {
4404
4975
  });
4405
4976
  }
4406
4977
 
4407
- return cmdAsk(agent, session, message, { noWait, yolo, timeoutMs });
4978
+ return cmdAsk(agent, session, messageText, {
4979
+ noWait,
4980
+ yolo,
4981
+ allowedTools: autoApprove,
4982
+ timeoutMs,
4983
+ });
4408
4984
  }
4409
4985
 
4410
4986
  // Run main() only when executed directly (not when imported for testing)
@@ -4444,4 +5020,6 @@ export {
4444
5020
  extractThinking,
4445
5021
  detectState,
4446
5022
  State,
5023
+ normalizeAllowedTools,
5024
+ computePermissionHash,
4447
5025
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ax-agents",
3
- "version": "0.0.1-alpha.11",
3
+ "version": "0.0.1-alpha.13",
4
4
  "description": "A CLI for orchestrating AI coding agents via tmux",
5
5
  "bin": {
6
6
  "ax": "ax.js",