ax-agents 0.0.1-alpha.8 → 0.1.0

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 +788 -187
  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
  // =============================================================================
@@ -307,6 +355,7 @@ const ARCHANGEL_GIT_CONTEXT_MAX_LINES = 200;
307
355
  const ARCHANGEL_PARENT_CONTEXT_ENTRIES = 10;
308
356
  const ARCHANGEL_PREAMBLE = `## Guidelines
309
357
 
358
+ - If you have nothing to report, you MUST respond with ONLY "EMPTY_RESPONSE".
310
359
  - Investigate before speaking. If uncertain, read more code and trace the logic until you're confident.
311
360
  - Explain WHY something is an issue, not just that it is.
312
361
  - Focus on your area of expertise.
@@ -314,9 +363,23 @@ const ARCHANGEL_PREAMBLE = `## Guidelines
314
363
  - Be clear. Brief is fine, but never sacrifice clarity.
315
364
  - For critical issues, request for them to be added to the todo list.
316
365
  - Don't repeat observations you've already made unless you have more to say or better clarity.
317
- - Make judgment calls - don't ask questions.
318
-
319
- "No issues found." is a valid response when there's nothing significant to report.`;
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".`;
320
383
 
321
384
  /**
322
385
  * @param {string} session
@@ -354,9 +417,9 @@ async function waitFor(session, predicate, timeoutMs = STARTUP_TIMEOUT_MS) {
354
417
  // =============================================================================
355
418
 
356
419
  /**
357
- * @returns {number | null}
420
+ * @returns {{pid: number, agent: 'claude' | 'codex'} | null}
358
421
  */
359
- function findCallerPid() {
422
+ function findCallerAgent() {
360
423
  let pid = process.ppid;
361
424
  while (pid > 1) {
362
425
  const result = spawnSync("ps", ["-p", pid.toString(), "-o", "ppid=,comm="], {
@@ -366,9 +429,8 @@ function findCallerPid() {
366
429
  const parts = result.stdout.trim().split(/\s+/);
367
430
  const ppid = parseInt(parts[0], 10);
368
431
  const cmd = parts.slice(1).join(" ");
369
- if (cmd.includes("claude") || cmd.includes("codex")) {
370
- return pid;
371
- }
432
+ if (cmd.includes("claude")) return { pid, agent: "claude" };
433
+ if (cmd.includes("codex")) return { pid, agent: "codex" };
372
434
  pid = ppid;
373
435
  }
374
436
  return null;
@@ -379,7 +441,9 @@ function findCallerPid() {
379
441
  * @returns {{pid: string, command: string}[]}
380
442
  */
381
443
  function findOrphanedProcesses() {
382
- 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
+ });
383
447
 
384
448
  if (result.status !== 0 || !result.stdout.trim()) {
385
449
  return [];
@@ -433,6 +497,17 @@ async function readStdin() {
433
497
  });
434
498
  }
435
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
+
436
511
  // =============================================================================
437
512
  // =============================================================================
438
513
  // Helpers - CLI argument parsing
@@ -453,6 +528,7 @@ async function readStdin() {
453
528
  * @property {boolean} all
454
529
  * @property {boolean} orphans
455
530
  * @property {boolean} force
531
+ * @property {boolean} stale
456
532
  * @property {boolean} version
457
533
  * @property {boolean} help
458
534
  * @property {string} [tool]
@@ -461,6 +537,8 @@ async function readStdin() {
461
537
  * @property {number} [tail]
462
538
  * @property {number} [limit]
463
539
  * @property {string} [branch]
540
+ * @property {string} [archangels]
541
+ * @property {string} [autoApprove]
464
542
  */
465
543
  function parseCliArgs(args) {
466
544
  const { values, positionals } = parseArgs({
@@ -476,15 +554,18 @@ function parseCliArgs(args) {
476
554
  all: { type: "boolean", default: false },
477
555
  orphans: { type: "boolean", default: false },
478
556
  force: { type: "boolean", default: false },
557
+ stale: { type: "boolean", default: false },
479
558
  version: { type: "boolean", short: "V", default: false },
480
559
  help: { type: "boolean", short: "h", default: false },
481
560
  // Value flags
482
561
  tool: { type: "string" },
562
+ "auto-approve": { type: "string" },
483
563
  session: { type: "string" },
484
564
  timeout: { type: "string" },
485
565
  tail: { type: "string" },
486
566
  limit: { type: "string" },
487
567
  branch: { type: "string" },
568
+ archangels: { type: "string" },
488
569
  },
489
570
  allowPositionals: true,
490
571
  strict: false, // Don't error on unknown flags
@@ -501,6 +582,7 @@ function parseCliArgs(args) {
501
582
  all: Boolean(values.all),
502
583
  orphans: Boolean(values.orphans),
503
584
  force: Boolean(values.force),
585
+ stale: Boolean(values.stale),
504
586
  version: Boolean(values.version),
505
587
  help: Boolean(values.help),
506
588
  tool: /** @type {string | undefined} */ (values.tool),
@@ -509,6 +591,8 @@ function parseCliArgs(args) {
509
591
  tail: values.tail !== undefined ? Number(values.tail) : undefined,
510
592
  limit: values.limit !== undefined ? Number(values.limit) : undefined,
511
593
  branch: /** @type {string | undefined} */ (values.branch),
594
+ archangels: /** @type {string | undefined} */ (values.archangels),
595
+ autoApprove: /** @type {string | undefined} */ (values["auto-approve"]),
512
596
  },
513
597
  positionals,
514
598
  };
@@ -517,6 +601,10 @@ function parseCliArgs(args) {
517
601
  // Helpers - session tracking
518
602
  // =============================================================================
519
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
+
520
608
  /**
521
609
  * @param {string} session
522
610
  * @returns {ParsedSession | null}
@@ -529,19 +617,27 @@ function parseSessionName(session) {
529
617
  const rest = match[2];
530
618
 
531
619
  // Archangel: {tool}-archangel-{name}-{uuid}
532
- const archangelMatch = rest.match(
533
- /^archangel-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i,
534
- );
620
+ const archangelPattern = new RegExp(`^archangel-(.+)-(${UUID_PATTERN})$`, "i");
621
+ const archangelMatch = rest.match(archangelPattern);
535
622
  if (archangelMatch) {
536
623
  return { tool, archangelName: archangelMatch[1], uuid: archangelMatch[2] };
537
624
  }
538
625
 
539
- // Partner: {tool}-partner-{uuid}
540
- const partnerMatch = rest.match(
541
- /^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",
542
630
  );
631
+ const partnerMatch = rest.match(partnerPattern);
543
632
  if (partnerMatch) {
544
- 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;
545
641
  }
546
642
 
547
643
  // Anything else
@@ -550,10 +646,19 @@ function parseSessionName(session) {
550
646
 
551
647
  /**
552
648
  * @param {string} tool
649
+ * @param {{allowedTools?: string | null, yolo?: boolean}} [options]
553
650
  * @returns {string}
554
651
  */
555
- function generateSessionName(tool) {
556
- 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}`;
557
662
  }
558
663
 
559
664
  /**
@@ -1151,6 +1256,54 @@ function getArchangelSessionPattern(config) {
1151
1256
  return `${config.tool}-archangel-${config.name}`;
1152
1257
  }
1153
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
+
1154
1307
  // =============================================================================
1155
1308
  // Helpers - mailbox
1156
1309
  // =============================================================================
@@ -1167,15 +1320,25 @@ function ensureMailboxDir() {
1167
1320
  }
1168
1321
  }
1169
1322
 
1323
+ /**
1324
+ * @returns {void}
1325
+ */
1326
+ function ensureRfpDir() {
1327
+ if (!existsSync(RFP_DIR)) {
1328
+ mkdirSync(RFP_DIR, { recursive: true });
1329
+ }
1330
+ }
1331
+
1170
1332
  /**
1171
1333
  * @param {MailboxPayload} payload
1334
+ * @param {string} [type]
1172
1335
  * @returns {void}
1173
1336
  */
1174
- function writeToMailbox(payload) {
1337
+ function writeToMailbox(payload, type = "observation") {
1175
1338
  ensureMailboxDir();
1176
1339
  const entry = {
1177
1340
  timestamp: new Date().toISOString(),
1178
- type: "observation",
1341
+ type,
1179
1342
  payload,
1180
1343
  };
1181
1344
  appendFileSync(MAILBOX_PATH, JSON.stringify(entry) + "\n");
@@ -1397,8 +1560,8 @@ function findCurrentClaudeSession() {
1397
1560
 
1398
1561
  // We might be running from Claude but not inside tmux (e.g., VSCode, Cursor)
1399
1562
  // Find Claude sessions in the same cwd and pick the most recently active one
1400
- const callerPid = findCallerPid();
1401
- if (!callerPid) return null; // Not running from Claude
1563
+ const caller = findCallerAgent();
1564
+ if (!caller) return null;
1402
1565
 
1403
1566
  const cwd = process.cwd();
1404
1567
  const sessions = tmuxListSessions();
@@ -1812,6 +1975,7 @@ const State = {
1812
1975
  THINKING: "thinking",
1813
1976
  CONFIRMING: "confirming",
1814
1977
  RATE_LIMITED: "rate_limited",
1978
+ FEEDBACK_MODAL: "feedback_modal",
1815
1979
  };
1816
1980
 
1817
1981
  /**
@@ -1839,6 +2003,17 @@ function detectState(screen, config) {
1839
2003
  return State.RATE_LIMITED;
1840
2004
  }
1841
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
+
1842
2017
  // Confirming - check before THINKING because "Running…" in tool output matches thinking patterns
1843
2018
  const confirmPatterns = config.confirmPatterns || [];
1844
2019
  for (const pattern of confirmPatterns) {
@@ -1852,7 +2027,22 @@ function detectState(screen, config) {
1852
2027
  }
1853
2028
  }
1854
2029
 
1855
- // Thinking - spinners (check last lines only to avoid false positives from timing messages like "✻ Crunched for 32s")
2030
+ // Ready - check BEFORE thinking to avoid false positives from timing messages like "✻ Worked for 45s"
2031
+ // If the prompt symbol is visible, the agent is ready regardless of spinner characters in timing messages
2032
+ if (lastLines.includes(config.promptSymbol)) {
2033
+ // Check if any line has the prompt followed by pasted content indicator
2034
+ // "[Pasted text" indicates user has pasted content and Claude is still processing
2035
+ const linesArray = lastLines.split("\n");
2036
+ const promptWithPaste = linesArray.some(
2037
+ (l) => l.includes(config.promptSymbol) && l.includes("[Pasted text"),
2038
+ );
2039
+ if (!promptWithPaste) {
2040
+ return State.READY;
2041
+ }
2042
+ // If prompt has pasted content, Claude is still processing - not ready yet
2043
+ }
2044
+
2045
+ // Thinking - spinners (check last lines only)
1856
2046
  const spinners = config.spinners || [];
1857
2047
  if (spinners.some((s) => lastLines.includes(s))) {
1858
2048
  return State.THINKING;
@@ -1877,20 +2067,6 @@ function detectState(screen, config) {
1877
2067
  }
1878
2068
  }
1879
2069
 
1880
- // Ready - only if prompt symbol is visible AND not followed by pasted content
1881
- // "[Pasted text" indicates user has pasted content and Claude is still processing
1882
- if (lastLines.includes(config.promptSymbol)) {
1883
- // Check if any line has the prompt followed by pasted content indicator
1884
- const linesArray = lastLines.split("\n");
1885
- const promptWithPaste = linesArray.some(
1886
- (l) => l.includes(config.promptSymbol) && l.includes("[Pasted text"),
1887
- );
1888
- if (!promptWithPaste) {
1889
- return State.READY;
1890
- }
1891
- // If prompt has pasted content, Claude is still processing - not ready yet
1892
- }
1893
-
1894
2070
  return State.STARTING;
1895
2071
  }
1896
2072
 
@@ -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 for shell: backslashes first, then double quotes
2168
+ const escaped = customAllowedTools.replace(/\\/g, "\\\\").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}"`;
@@ -2001,38 +2183,85 @@ class Agent {
2001
2183
  return base;
2002
2184
  }
2003
2185
 
2004
- getDefaultSession() {
2186
+ /**
2187
+ * @param {{allowedTools?: string | null, yolo?: boolean}} [options]
2188
+ * @returns {string | null}
2189
+ */
2190
+ getDefaultSession({ allowedTools = null, yolo = false } = {}) {
2005
2191
  // Check env var for explicit session
2006
2192
  if (this.envVar && process.env[this.envVar]) {
2007
- return process.env[this.envVar];
2193
+ return process.env[this.envVar] ?? null;
2008
2194
  }
2009
2195
 
2010
2196
  const cwd = process.cwd();
2011
- 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();
2236
+
2237
+ while (searchDir !== homeDir && searchDir !== "/") {
2238
+ const existing = matchingSessions.find((s) => sessionCwds.get(s) === searchDir);
2239
+ if (existing) return existing;
2012
2240
 
2013
- // If inside tmux, look for existing agent session in same cwd
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
2014
2251
  const current = tmuxCurrentSession();
2015
2252
  if (current) {
2016
2253
  const sessions = tmuxListSessions();
2017
- const existing = sessions.find((s) => {
2018
- if (!childPattern.test(s)) return false;
2019
- const sessionCwd = getTmuxSessionCwd(s);
2020
- return sessionCwd === cwd;
2021
- });
2254
+ const existing = findSessionInCwdOrParent(sessions);
2022
2255
  if (existing) return existing;
2023
- // 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
2024
2257
  return null;
2025
2258
  }
2026
2259
 
2027
- // Walk up to find claude/codex ancestor and reuse its session (must match cwd)
2028
- const callerPid = findCallerPid();
2029
- if (callerPid) {
2260
+ // Walk up to find claude/codex ancestor and reuse its session
2261
+ const caller = findCallerAgent();
2262
+ if (caller) {
2030
2263
  const sessions = tmuxListSessions();
2031
- const existing = sessions.find((s) => {
2032
- if (!childPattern.test(s)) return false;
2033
- const sessionCwd = getTmuxSessionCwd(s);
2034
- return sessionCwd === cwd;
2035
- });
2264
+ const existing = findSessionInCwdOrParent(sessions);
2036
2265
  if (existing) return existing;
2037
2266
  }
2038
2267
 
@@ -2041,10 +2270,11 @@ class Agent {
2041
2270
  }
2042
2271
 
2043
2272
  /**
2273
+ * @param {{allowedTools?: string | null, yolo?: boolean}} [options]
2044
2274
  * @returns {string}
2045
2275
  */
2046
- generateSession() {
2047
- return generateSessionName(this.name);
2276
+ generateSession(options = {}) {
2277
+ return generateSessionName(this.name, options);
2048
2278
  }
2049
2279
 
2050
2280
  /**
@@ -2370,8 +2600,12 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2370
2600
  const initialScreen = tmuxCapture(session);
2371
2601
  const initialState = agent.getState(initialScreen);
2372
2602
 
2373
- // Already in terminal state
2374
- 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
2375
2609
  initialState === State.RATE_LIMITED ||
2376
2610
  initialState === State.CONFIRMING ||
2377
2611
  initialState === State.READY
@@ -2384,6 +2618,13 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2384
2618
  const screen = tmuxCapture(session);
2385
2619
  const state = agent.getState(screen);
2386
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
+
2387
2628
  if (state === State.RATE_LIMITED || state === State.CONFIRMING || state === State.READY) {
2388
2629
  return { state, screen };
2389
2630
  }
@@ -2424,6 +2665,13 @@ async function pollForResponse(agent, session, timeoutMs, hooks = {}) {
2424
2665
  return { state, screen };
2425
2666
  }
2426
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
+
2427
2675
  if (screen !== lastScreen) {
2428
2676
  lastScreen = screen;
2429
2677
  stableAt = Date.now();
@@ -2533,6 +2781,7 @@ async function autoApproveLoop(agent, session, timeoutMs, waitFn) {
2533
2781
  continue;
2534
2782
  }
2535
2783
 
2784
+ // FEEDBACK_MODAL is handled by the underlying waitFn (pollForResponse)
2536
2785
  debugError("autoApproveLoop", new Error(`unexpected state: ${state}`));
2537
2786
  }
2538
2787
 
@@ -2544,12 +2793,13 @@ async function autoApproveLoop(agent, session, timeoutMs, waitFn) {
2544
2793
  * @param {string | null | undefined} session
2545
2794
  * @param {Object} [options]
2546
2795
  * @param {boolean} [options.yolo]
2796
+ * @param {string | null} [options.allowedTools]
2547
2797
  * @returns {Promise<string>}
2548
2798
  */
2549
- async function cmdStart(agent, session, { yolo = false } = {}) {
2799
+ async function cmdStart(agent, session, { yolo = false, allowedTools = null } = {}) {
2550
2800
  // Generate session name if not provided
2551
2801
  if (!session) {
2552
- session = agent.generateSession();
2802
+ session = agent.generateSession({ allowedTools, yolo });
2553
2803
  }
2554
2804
 
2555
2805
  if (tmuxHasSession(session)) return session;
@@ -2561,7 +2811,7 @@ async function cmdStart(agent, session, { yolo = false } = {}) {
2561
2811
  process.exit(1);
2562
2812
  }
2563
2813
 
2564
- const command = agent.getCommand(yolo, session);
2814
+ const command = agent.getCommand(yolo, session, allowedTools);
2565
2815
  tmuxNewSession(session, command);
2566
2816
 
2567
2817
  const start = Date.now();
@@ -2574,6 +2824,18 @@ async function cmdStart(agent, session, { yolo = false } = {}) {
2574
2824
  continue;
2575
2825
  }
2576
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
+
2577
2839
  if (state === State.READY) return session;
2578
2840
 
2579
2841
  await sleep(POLL_MS);
@@ -2621,6 +2883,7 @@ function cmdAgents() {
2621
2883
  const isDefault =
2622
2884
  (parsed.tool === "claude" && session === claudeDefault) ||
2623
2885
  (parsed.tool === "codex" && session === codexDefault);
2886
+ const perms = getSessionPermissions(session);
2624
2887
 
2625
2888
  // Get session metadata (Claude only)
2626
2889
  const meta = getSessionMeta(session);
@@ -2631,6 +2894,7 @@ function cmdAgents() {
2631
2894
  state: state || "unknown",
2632
2895
  target: isDefault ? "*" : "",
2633
2896
  type,
2897
+ mode: perms.mode,
2634
2898
  plan: meta?.slug || "-",
2635
2899
  branch: meta?.gitBranch || "-",
2636
2900
  };
@@ -2642,14 +2906,15 @@ function cmdAgents() {
2642
2906
  const maxState = Math.max(5, ...agents.map((a) => a.state.length));
2643
2907
  const maxTarget = Math.max(6, ...agents.map((a) => a.target.length));
2644
2908
  const maxType = Math.max(4, ...agents.map((a) => a.type.length));
2909
+ const maxMode = Math.max(4, ...agents.map((a) => a.mode.length));
2645
2910
  const maxPlan = Math.max(4, ...agents.map((a) => a.plan.length));
2646
2911
 
2647
2912
  console.log(
2648
- `${"SESSION".padEnd(maxSession)} ${"TOOL".padEnd(maxTool)} ${"STATE".padEnd(maxState)} ${"TARGET".padEnd(maxTarget)} ${"TYPE".padEnd(maxType)} ${"PLAN".padEnd(maxPlan)} BRANCH`,
2913
+ `${"SESSION".padEnd(maxSession)} ${"TOOL".padEnd(maxTool)} ${"STATE".padEnd(maxState)} ${"TARGET".padEnd(maxTarget)} ${"TYPE".padEnd(maxType)} ${"MODE".padEnd(maxMode)} ${"PLAN".padEnd(maxPlan)} BRANCH`,
2649
2914
  );
2650
2915
  for (const a of agents) {
2651
2916
  console.log(
2652
- `${a.session.padEnd(maxSession)} ${a.tool.padEnd(maxTool)} ${a.state.padEnd(maxState)} ${a.target.padEnd(maxTarget)} ${a.type.padEnd(maxType)} ${a.plan.padEnd(maxPlan)} ${a.branch}`,
2917
+ `${a.session.padEnd(maxSession)} ${a.tool.padEnd(maxTool)} ${a.state.padEnd(maxState)} ${a.target.padEnd(maxTarget)} ${a.type.padEnd(maxType)} ${a.mode.padEnd(maxMode)} ${a.plan.padEnd(maxPlan)} ${a.branch}`,
2653
2918
  );
2654
2919
  }
2655
2920
 
@@ -2706,11 +2971,29 @@ function startArchangel(config, parentSession = null) {
2706
2971
  env,
2707
2972
  });
2708
2973
  child.unref();
2974
+ const watchingLabel = parentSession
2975
+ ? parentSession.session || parentSession.uuid?.slice(0, 8)
2976
+ : null;
2709
2977
  console.log(
2710
- `Summoning: ${config.name} (pid ${child.pid})${parentSession ? ` [parent: ${parentSession.session}]` : ""}`,
2978
+ `Summoning: ${config.name} (pid ${child.pid})${watchingLabel ? ` [watching: ${watchingLabel}]` : ""}`,
2711
2979
  );
2712
2980
  }
2713
2981
 
2982
+ /**
2983
+ * @param {string} pattern
2984
+ * @param {number} [timeoutMs]
2985
+ * @returns {Promise<string | undefined>}
2986
+ */
2987
+ async function waitForArchangelSession(pattern, timeoutMs = ARCHANGEL_STARTUP_TIMEOUT_MS) {
2988
+ const start = Date.now();
2989
+ while (Date.now() - start < timeoutMs) {
2990
+ const session = findArchangelSession(pattern);
2991
+ if (session) return session;
2992
+ await sleep(200);
2993
+ }
2994
+ return undefined;
2995
+ }
2996
+
2714
2997
  // =============================================================================
2715
2998
  // Command: archangel (runs as the archangel process itself)
2716
2999
  // =============================================================================
@@ -2958,28 +3241,18 @@ async function cmdArchangel(agentName) {
2958
3241
 
2959
3242
  const cleanedResponse = agent.getResponse(sessionName, afterScreen) || "";
2960
3243
 
2961
- // Sanity check: skip garbage responses (screen scraping artifacts)
2962
- const isGarbage =
2963
- cleanedResponse.includes("[Pasted text") ||
2964
- cleanedResponse.match(/^\+\d+ lines\]/) ||
2965
- cleanedResponse.length < 20;
2966
-
2967
- if (
2968
- cleanedResponse &&
2969
- !isGarbage &&
2970
- !cleanedResponse.toLowerCase().includes("no issues found")
2971
- ) {
3244
+ const isSkippable = !cleanedResponse || cleanedResponse.trim() === "EMPTY_RESPONSE";
3245
+
3246
+ if (!isSkippable) {
2972
3247
  writeToMailbox({
2973
3248
  agent: /** @type {string} */ (agentName),
2974
3249
  session: sessionName,
2975
3250
  branch: getCurrentBranch(),
2976
3251
  commit: getCurrentCommit(),
2977
3252
  files,
2978
- message: cleanedResponse.slice(0, 1000),
3253
+ message: cleanedResponse,
2979
3254
  });
2980
3255
  console.log(`[archangel:${agentName}] Wrote observation for ${files.length} file(s)`);
2981
- } else if (isGarbage) {
2982
- console.log(`[archangel:${agentName}] Skipped garbage response`);
2983
3256
  }
2984
3257
  } catch (err) {
2985
3258
  console.error(`[archangel:${agentName}] Error:`, err instanceof Error ? err.message : err);
@@ -3171,6 +3444,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs";
3171
3444
  import { dirname, join } from "node:path";
3172
3445
  import { fileURLToPath } from "node:url";
3173
3446
  import { createHash } from "node:crypto";
3447
+ import { execSync } from "node:child_process";
3174
3448
 
3175
3449
  const __dirname = dirname(fileURLToPath(import.meta.url));
3176
3450
  const AI_DIR = join(__dirname, "..");
@@ -3178,6 +3452,15 @@ const DEBUG = process.env.AX_DEBUG === "1";
3178
3452
  const MAILBOX = join(AI_DIR, "mailbox.jsonl");
3179
3453
  const MAX_AGE_MS = 60 * 60 * 1000;
3180
3454
 
3455
+ function getTmuxSessionName() {
3456
+ if (!process.env.TMUX) return null;
3457
+ try {
3458
+ return execSync("tmux display-message -p '#S'", { encoding: "utf-8" }).trim();
3459
+ } catch {
3460
+ return null;
3461
+ }
3462
+ }
3463
+
3181
3464
  // Read hook input from stdin
3182
3465
  let hookInput = {};
3183
3466
  try {
@@ -3192,8 +3475,9 @@ const hookEvent = hookInput.hook_event_name || "";
3192
3475
 
3193
3476
  if (DEBUG) console.error("[hook] session:", sessionId, "event:", hookEvent);
3194
3477
 
3195
- // NO-OP for archangel or partner sessions
3196
- if (sessionId.includes("-archangel-") || sessionId.includes("-partner-")) {
3478
+ const tmuxSession = getTmuxSessionName();
3479
+ if (DEBUG) console.error("[hook] tmux session:", tmuxSession);
3480
+ if (tmuxSession && (tmuxSession.includes("-archangel-") || tmuxSession.includes("-partner-"))) {
3197
3481
  if (DEBUG) console.error("[hook] skipping non-parent session");
3198
3482
  process.exit(0);
3199
3483
  }
@@ -3384,7 +3668,7 @@ function cmdKill(session, { all = false, orphans = false, force = false } = {})
3384
3668
  // If specific session provided, kill just that one
3385
3669
  if (session) {
3386
3670
  if (!tmuxHasSession(session)) {
3387
- console.log("ERROR: session not found");
3671
+ console.log("ERROR: session not found. Run 'ax agents' to list sessions.");
3388
3672
  process.exit(1);
3389
3673
  }
3390
3674
  tmuxKill(session);
@@ -3435,7 +3719,7 @@ function cmdAttach(session) {
3435
3719
  // Resolve partial session name
3436
3720
  const resolved = resolveSessionName(session);
3437
3721
  if (!resolved || !tmuxHasSession(resolved)) {
3438
- console.log("ERROR: session not found");
3722
+ console.log("ERROR: session not found. Run 'ax agents' to list sessions.");
3439
3723
  process.exit(1);
3440
3724
  }
3441
3725
 
@@ -3459,7 +3743,7 @@ function cmdLog(sessionName, { tail = 50, reasoning = false, follow = false } =
3459
3743
  // Resolve partial session name
3460
3744
  const resolved = resolveSessionName(sessionName);
3461
3745
  if (!resolved) {
3462
- console.log("ERROR: session not found");
3746
+ console.log("ERROR: session not found. Run 'ax agents' to list sessions.");
3463
3747
  process.exit(1);
3464
3748
  }
3465
3749
  const parsed = parseSessionName(resolved);
@@ -3723,7 +4007,13 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
3723
4007
  console.log(`**Branch**: ${p.branch || "?"} @ ${p.commit || "?"}\n`);
3724
4008
  }
3725
4009
 
3726
- if (p.message) {
4010
+ if (p.rfpId) {
4011
+ console.log(`**RFP**: ${p.rfpId}\n`);
4012
+ }
4013
+
4014
+ if (entry.type === "proposal") {
4015
+ console.log(`**Proposal**: ${p.message || ""}\n`);
4016
+ } else if (p.message) {
3727
4017
  console.log(`**Assistant**: ${p.message}\n`);
3728
4018
  }
3729
4019
 
@@ -3737,13 +4027,246 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
3737
4027
  }
3738
4028
  }
3739
4029
 
4030
+ /**
4031
+ * @param {string} rfpId
4032
+ * @param {string} archangel
4033
+ * @returns {string | null}
4034
+ */
4035
+ function getProposalFromMailbox(rfpId, archangel) {
4036
+ if (!existsSync(MAILBOX_PATH)) return null;
4037
+ let result = null;
4038
+ try {
4039
+ const lines = readFileSync(MAILBOX_PATH, "utf-8").trim().split("\n").filter(Boolean);
4040
+ for (const line of lines) {
4041
+ try {
4042
+ const entry = JSON.parse(line);
4043
+ if (entry?.type !== "proposal") continue;
4044
+ const p = entry.payload || {};
4045
+ if (p.rfpId === rfpId && p.archangel === archangel) {
4046
+ result = p.message || "";
4047
+ }
4048
+ } catch {
4049
+ // Skip malformed lines
4050
+ }
4051
+ }
4052
+ } catch (err) {
4053
+ debugError("getProposalFromMailbox", err);
4054
+ }
4055
+ return result;
4056
+ }
4057
+
4058
+ /**
4059
+ * @param {string} prompt
4060
+ * @param {{archangels?: string, fresh?: boolean, noWait?: boolean}} [options]
4061
+ */
4062
+ async function cmdRfp(prompt, { archangels, fresh = false, noWait = false } = {}) {
4063
+ const configs = loadAgentConfigs();
4064
+ if (configs.length === 0) {
4065
+ console.log(`No archangels found in ${AGENTS_DIR}/`);
4066
+ process.exit(1);
4067
+ }
4068
+
4069
+ const requested = archangels
4070
+ ? archangels
4071
+ .split(",")
4072
+ .map((s) => s.trim())
4073
+ .filter(Boolean)
4074
+ : configs.map((c) => c.name);
4075
+
4076
+ if (requested.length === 0) {
4077
+ console.log("ERROR: no archangels specified");
4078
+ process.exit(1);
4079
+ }
4080
+
4081
+ const missing = requested.filter((name) => !configs.some((c) => c.name === name));
4082
+ if (missing.length > 0) {
4083
+ console.log(`ERROR: unknown archangel(s): ${missing.join(", ")}`);
4084
+ process.exit(1);
4085
+ }
4086
+
4087
+ const parent = findParentSession();
4088
+ const rfpId = generateRfpId(parent);
4089
+
4090
+ for (const name of requested) {
4091
+ const config = configs.find((c) => c.name === name);
4092
+ if (!config) continue;
4093
+
4094
+ const pattern = getArchangelSessionPattern(config);
4095
+ let session = findArchangelSession(pattern);
4096
+ if (!session) {
4097
+ startArchangel(config, parent);
4098
+ session = await waitForArchangelSession(pattern);
4099
+ }
4100
+
4101
+ if (!session) {
4102
+ console.log(`ERROR: failed to start archangel '${name}'`);
4103
+ continue;
4104
+ }
4105
+
4106
+ const { agent } = resolveAgent({ sessionName: session });
4107
+
4108
+ if (fresh) {
4109
+ tmuxSendLiteral(session, "/new");
4110
+ await sleep(50);
4111
+ tmuxSend(session, "Enter");
4112
+ }
4113
+
4114
+ const ready = await waitUntilReady(agent, session, ARCHANGEL_STARTUP_TIMEOUT_MS);
4115
+ if (ready.state !== State.READY) {
4116
+ console.log(`[rfp] ${name} not ready (${ready.state}), skipping`);
4117
+ continue;
4118
+ }
4119
+
4120
+ const rfpPrompt = `## RFP ${rfpId}\n\n${RFP_PREAMBLE}\n\n${prompt}\n\nReturn exactly one proposal.`;
4121
+ tmuxSendLiteral(session, rfpPrompt);
4122
+ await sleep(200);
4123
+ tmuxSend(session, "Enter");
4124
+ }
4125
+
4126
+ writeRfpRecord(rfpId, prompt);
4127
+ const archangelList = requested.join(",");
4128
+ const base = rfpId.split("-")[1];
4129
+ const shortId = `rfp-${base}`;
4130
+ console.log(`rfp: ${rfpId} (${archangelList})`);
4131
+ if (noWait) {
4132
+ // Truncate prompt for display (first line, max 60 chars)
4133
+ const firstLine = prompt.split("\n")[0];
4134
+ const taskPreview = firstLine.length > 60 ? firstLine.slice(0, 57) + "..." : firstLine;
4135
+
4136
+ let output = `Task: ${taskPreview}
4137
+
4138
+ Add this to your task list, then continue with your work.`;
4139
+
4140
+ // Show TaskCreate example if caller is Claude
4141
+ const caller = findCallerAgent();
4142
+ if (caller?.agent === "claude") {
4143
+ output += `
4144
+
4145
+ e.g.
4146
+ TaskCreate({ subject: "ax:${shortId} - ${taskPreview}" })
4147
+ `;
4148
+ }
4149
+
4150
+ console.log(output);
4151
+ }
4152
+ }
4153
+
4154
+ /**
4155
+ * @param {string} rfpId
4156
+ * @param {{archangels?: string, timeoutMs?: number}} [options]
4157
+ */
4158
+ async function cmdRfpWait(rfpId, { archangels, timeoutMs = ARCHANGEL_RESPONSE_TIMEOUT_MS } = {}) {
4159
+ const resolvedRfpId = resolveRfpId(rfpId);
4160
+ const configs = loadAgentConfigs();
4161
+ if (configs.length === 0) {
4162
+ console.log(`No archangels found in ${AGENTS_DIR}/`);
4163
+ process.exit(1);
4164
+ }
4165
+
4166
+ const requested = archangels
4167
+ ? archangels
4168
+ .split(",")
4169
+ .map((s) => s.trim())
4170
+ .filter(Boolean)
4171
+ : configs.map((c) => c.name);
4172
+
4173
+ if (requested.length === 0) {
4174
+ console.log("ERROR: no archangels specified");
4175
+ process.exit(1);
4176
+ }
4177
+
4178
+ const missing = requested.filter((name) => !configs.some((c) => c.name === name));
4179
+ if (missing.length > 0) {
4180
+ console.log(`ERROR: unknown archangel(s): ${missing.join(", ")}`);
4181
+ process.exit(1);
4182
+ }
4183
+
4184
+ let wroteAny = false;
4185
+ let printedAny = false;
4186
+
4187
+ for (const name of requested) {
4188
+ const config = configs.find((c) => c.name === name);
4189
+ if (!config) continue;
4190
+
4191
+ const pattern = getArchangelSessionPattern(config);
4192
+ const session = findArchangelSession(pattern);
4193
+ if (!session) {
4194
+ console.log(`[rfp] ${name} session not found, skipping`);
4195
+ continue;
4196
+ }
4197
+
4198
+ const existing = getProposalFromMailbox(resolvedRfpId, name);
4199
+ if (existing !== null) {
4200
+ if (printedAny) console.log("");
4201
+ console.log(`[${name}]`);
4202
+ console.log(existing);
4203
+ wroteAny = true;
4204
+ printedAny = true;
4205
+ continue;
4206
+ }
4207
+
4208
+ const { agent } = resolveAgent({ sessionName: session });
4209
+ let result;
4210
+ try {
4211
+ result = await waitUntilReady(agent, session, timeoutMs);
4212
+ } catch (err) {
4213
+ if (err instanceof TimeoutError) {
4214
+ console.log(`[rfp] ${name} timed out`);
4215
+ } else {
4216
+ console.log(`[rfp] ${name} error: ${err instanceof Error ? err.message : err}`);
4217
+ }
4218
+ continue;
4219
+ }
4220
+
4221
+ if (result.state === State.RATE_LIMITED) {
4222
+ console.log(`[rfp] ${name} rate limited`);
4223
+ continue;
4224
+ }
4225
+ if (result.state === State.CONFIRMING) {
4226
+ console.log(`[rfp] ${name} awaiting confirmation`);
4227
+ continue;
4228
+ }
4229
+
4230
+ const response = agent.getResponse(session, result.screen) || "";
4231
+ if (!response || response.trim() === "EMPTY_RESPONSE") {
4232
+ continue;
4233
+ }
4234
+
4235
+ writeToMailbox(
4236
+ {
4237
+ agent: name,
4238
+ session,
4239
+ branch: getCurrentBranch(),
4240
+ commit: getCurrentCommit(),
4241
+ files: [],
4242
+ message: response,
4243
+ rfpId: resolvedRfpId,
4244
+ archangel: name,
4245
+ },
4246
+ "proposal",
4247
+ );
4248
+ if (printedAny) console.log("");
4249
+ console.log(`[${name}]`);
4250
+ console.log(response);
4251
+ wroteAny = true;
4252
+ printedAny = true;
4253
+ }
4254
+
4255
+ if (!wroteAny) process.exit(1);
4256
+ }
4257
+
3740
4258
  /**
3741
4259
  * @param {Agent} agent
3742
4260
  * @param {string | null | undefined} session
3743
4261
  * @param {string} message
3744
- * @param {{noWait?: boolean, yolo?: boolean, timeoutMs?: number}} [options]
4262
+ * @param {{noWait?: boolean, yolo?: boolean, allowedTools?: string | null, timeoutMs?: number}} [options]
3745
4263
  */
3746
- async function cmdAsk(agent, session, message, { noWait = false, yolo = false, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
4264
+ async function cmdAsk(
4265
+ agent,
4266
+ session,
4267
+ message,
4268
+ { noWait = false, yolo = false, allowedTools = null, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
4269
+ ) {
3747
4270
  const sessionExists = session != null && tmuxHasSession(session);
3748
4271
  const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
3749
4272
 
@@ -3757,7 +4280,7 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3757
4280
  /** @type {string} */
3758
4281
  const activeSession = sessionExists
3759
4282
  ? /** @type {string} */ (session)
3760
- : await cmdStart(agent, session, { yolo });
4283
+ : await cmdStart(agent, session, { yolo, allowedTools });
3761
4284
 
3762
4285
  tmuxSendLiteral(activeSession, message);
3763
4286
  await sleep(50);
@@ -3766,12 +4289,26 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3766
4289
  if (noWait) {
3767
4290
  const parsed = parseSessionName(activeSession);
3768
4291
  const shortId = parsed?.uuid?.slice(0, 8) || activeSession;
3769
- const cli = path.basename(process.argv[1], ".js");
3770
- console.log(`Sent to: ${shortId}
4292
+ // Truncate message for display (first line, max 60 chars)
4293
+ const firstLine = message.split("\n")[0];
4294
+ const taskPreview = firstLine.length > 60 ? firstLine.slice(0, 57) + "..." : firstLine;
4295
+
4296
+ let output = `Sent to: ${shortId}
4297
+ Task: ${taskPreview}
4298
+
4299
+ Add this to your task list, then continue with your work.`;
4300
+
4301
+ // Show TaskCreate example if caller is Claude
4302
+ const caller = findCallerAgent();
4303
+ if (caller?.agent === "claude") {
4304
+ output += `
3771
4305
 
3772
4306
  e.g.
3773
- ${cli} status --session=${shortId}
3774
- ${cli} output --session=${shortId}`);
4307
+ TaskCreate({ subject: "ax:${shortId} - ${taskPreview}" })
4308
+ `;
4309
+ }
4310
+
4311
+ console.log(output);
3775
4312
  return;
3776
4313
  }
3777
4314
 
@@ -3963,9 +4500,14 @@ async function cmdReview(
3963
4500
  * @param {Agent} agent
3964
4501
  * @param {string | null | undefined} session
3965
4502
  * @param {number} [index]
3966
- * @param {{wait?: boolean, timeoutMs?: number}} [options]
4503
+ * @param {{wait?: boolean, stale?: boolean, timeoutMs?: number}} [options]
3967
4504
  */
3968
- async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs } = {}) {
4505
+ async function cmdOutput(
4506
+ agent,
4507
+ session,
4508
+ index = 0,
4509
+ { wait = false, stale = false, timeoutMs } = {},
4510
+ ) {
3969
4511
  if (!session || !tmuxHasSession(session)) {
3970
4512
  console.log("ERROR: no session");
3971
4513
  process.exit(1);
@@ -3992,8 +4534,11 @@ async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs }
3992
4534
  }
3993
4535
 
3994
4536
  if (state === State.THINKING) {
3995
- console.log("THINKING");
3996
- process.exit(4);
4537
+ if (!stale) {
4538
+ console.log("THINKING: Use --wait to block, or --stale for old response.");
4539
+ process.exit(1);
4540
+ }
4541
+ // --stale: fall through to show previous response
3997
4542
  }
3998
4543
 
3999
4544
  const output = agent.getResponse(session, screen, index);
@@ -4161,20 +4706,48 @@ async function cmdSelect(agent, session, n, { wait = false, timeoutMs } = {}) {
4161
4706
  // =============================================================================
4162
4707
 
4163
4708
  /**
4164
- * @returns {Agent}
4709
+ * Resolve the agent to use based on (in priority order):
4710
+ * 1. Explicit --tool flag
4711
+ * 2. Session name (e.g., "claude-archangel-..." → ClaudeAgent)
4712
+ * 3. CLI invocation name (axclaude, axcodex)
4713
+ * 4. AX_DEFAULT_TOOL environment variable
4714
+ * 5. Default to CodexAgent
4715
+ *
4716
+ * @param {{toolFlag?: string, sessionName?: string | null}} options
4717
+ * @returns {{agent: Agent, error?: string}}
4165
4718
  */
4166
- function getAgentFromInvocation() {
4719
+ function resolveAgent({ toolFlag, sessionName } = {}) {
4720
+ // 1. Explicit --tool flag takes highest priority
4721
+ if (toolFlag) {
4722
+ if (toolFlag === "claude") return { agent: ClaudeAgent };
4723
+ if (toolFlag === "codex") return { agent: CodexAgent };
4724
+ return { agent: CodexAgent, error: `unknown tool '${toolFlag}'` };
4725
+ }
4726
+
4727
+ // 2. Infer from session name (e.g., "claude-archangel-..." or "codex-partner-...")
4728
+ if (sessionName) {
4729
+ const parsed = parseSessionName(sessionName);
4730
+ if (parsed?.tool === "claude") return { agent: ClaudeAgent };
4731
+ if (parsed?.tool === "codex") return { agent: CodexAgent };
4732
+ }
4733
+
4734
+ // 3. CLI invocation name
4167
4735
  const invoked = path.basename(process.argv[1], ".js");
4168
- if (invoked === "axclaude" || invoked === "claude") return ClaudeAgent;
4169
- if (invoked === "axcodex" || invoked === "codex") return CodexAgent;
4736
+ if (invoked === "axclaude" || invoked === "claude") return { agent: ClaudeAgent };
4737
+ if (invoked === "axcodex" || invoked === "codex") return { agent: CodexAgent };
4738
+
4739
+ // 4. Infer from parent process (running from within claude/codex)
4740
+ const caller = findCallerAgent();
4741
+ if (caller?.agent === "claude") return { agent: ClaudeAgent };
4742
+ if (caller?.agent === "codex") return { agent: CodexAgent };
4170
4743
 
4171
- // Default based on AX_DEFAULT_TOOL env var, or codex if not set
4744
+ // 5. AX_DEFAULT_TOOL environment variable
4172
4745
  const defaultTool = process.env.AX_DEFAULT_TOOL;
4173
- if (defaultTool === "claude") return ClaudeAgent;
4174
- if (defaultTool === "codex" || !defaultTool) return CodexAgent;
4746
+ if (defaultTool === "claude") return { agent: ClaudeAgent };
4747
+ if (defaultTool === "codex" || !defaultTool) return { agent: CodexAgent };
4175
4748
 
4176
4749
  console.error(`WARNING: invalid AX_DEFAULT_TOOL="${defaultTool}", using codex`);
4177
- return CodexAgent;
4750
+ return { agent: CodexAgent };
4178
4751
  }
4179
4752
 
4180
4753
  /**
@@ -4184,72 +4757,64 @@ function getAgentFromInvocation() {
4184
4757
  function printHelp(agent, cliName) {
4185
4758
  const name = cliName;
4186
4759
  const backendName = agent.displayName;
4187
- const hasReview = !!agent.reviewOptions;
4188
4760
 
4189
4761
  console.log(`${name} v${VERSION} - agentic assistant CLI (${backendName})
4190
4762
 
4191
4763
  Usage: ${name} [OPTIONS] <command|message> [ARGS...]
4192
4764
 
4193
- Commands:
4194
- agents List all running agents with state and log paths
4765
+ Messaging:
4766
+ <message> Send message to ${name}
4767
+ review [TYPE] Review code: pr, uncommitted, commit, custom
4768
+
4769
+ Sessions:
4770
+ compact Summarise session to shrink context size
4771
+ reset Start fresh conversation
4772
+ agents List all running agents
4195
4773
  target Show default target session for current tool
4196
4774
  attach [SESSION] Attach to agent session interactively
4197
- log SESSION View conversation log (--tail=N, --follow, --reasoning)
4198
- mailbox View archangel observations (--limit=N, --branch=X, --all)
4775
+ kill Kill sessions (--all, --session=NAME, --orphans [--force])
4776
+
4777
+ Archangels:
4199
4778
  summon [name] Summon archangels (all, or by name)
4200
4779
  recall [name] Recall archangels (all, or by name)
4201
- kill Kill sessions (--all, --session=NAME, --orphans [--force])
4202
- status Check state (exit: 0=ready, 2=rate_limited, 3=confirming, 4=thinking)
4780
+ mailbox Archangel notes (filters: --branch=git, --all)
4781
+ rfp <prompt> Request proposals (--archangels=a,b)
4782
+ rfp wait <id> Wait for proposals (--archangels=a,b)
4783
+
4784
+ Recovery/State:
4785
+ status Exit code: ready=0 rate_limit=2 confirm=3 thinking=4
4203
4786
  output [-N] Show response (0=last, -1=prev, -2=older)
4204
- debug Show raw screen output and detected state${
4205
- hasReview
4206
- ? `
4207
- review [TYPE] Review code: pr, uncommitted, commit, custom`
4208
- : ""
4209
- }
4210
- select N Select menu option N
4787
+ debug Show raw screen output and detected state
4211
4788
  approve Approve pending action (send 'y')
4212
4789
  reject Reject pending action (send 'n')
4790
+ select N Select menu option N
4213
4791
  send KEYS Send key sequence (e.g. "1[Enter]", "[Escape]")
4214
- compact Summarize conversation (when context is full)
4215
- reset Start fresh conversation
4216
- <message> Send message to ${name}
4792
+ log [SESSION] View conversation log (--tail=N, --follow, --reasoning)
4217
4793
 
4218
4794
  Flags:
4219
4795
  --tool=NAME Use specific agent (codex, claude)
4220
- --session=NAME Target session by name, archangel name, or UUID prefix (self = current)
4796
+ --session=ID name | archangel | uuid-prefix | self
4797
+ --fresh Reset conversation before review
4798
+ --yolo Skip all confirmations (dangerous)
4799
+ --auto-approve=TOOLS Auto-approve specific tools (e.g. 'Bash("cargo *")')
4221
4800
  --wait Wait for response (default for messages; required for approve/reject)
4222
4801
  --no-wait Fire-and-forget: send message, print session ID, exit immediately
4223
4802
  --timeout=N Set timeout in seconds (default: ${DEFAULT_TIMEOUT_MS / 1000}, reviews: ${REVIEW_TIMEOUT_MS / 1000})
4224
- --yolo Skip all confirmations (dangerous)
4225
- --fresh Reset conversation before review
4226
- --orphans Kill orphaned claude/codex processes (PPID=1)
4227
- --force Use SIGKILL instead of SIGTERM (with --orphans)
4228
-
4229
- Environment:
4230
- AX_DEFAULT_TOOL Default agent when using 'ax' (claude or codex, default: codex)
4231
- ${agent.envVar} Override default session name
4232
- AX_CLAUDE_CONFIG_DIR Override Claude config directory (default: ~/.claude)
4233
- AX_CODEX_CONFIG_DIR Override Codex config directory (default: ~/.codex)
4234
- AX_REVIEW_MODE=exec Bypass /review, send instructions directly (codex only)
4235
- AX_DEBUG=1 Enable debug logging
4236
4803
 
4237
4804
  Examples:
4238
4805
  ${name} "explain this codebase"
4239
- ${name} "review the error handling" # Auto custom review (${REVIEW_TIMEOUT_MS / 60000}min timeout)
4240
- ${name} "FYI: auth was refactored" --no-wait # Send context to a working session (no response needed)
4806
+ ${name} "review the error handling" # Auto custom review (${REVIEW_TIMEOUT_MS / 60000}min timeout)
4807
+ ${name} "FYI: auth was refactored" --no-wait # Send context to a working session (no response needed)
4808
+ ${name} --auto-approve='Bash("cargo *")' "run tests" # Session with specific permissions
4241
4809
  ${name} review uncommitted --wait
4242
- ${name} approve --wait
4243
- ${name} kill # Kill agents in current project
4244
- ${name} kill --all # Kill all agents across all projects
4245
- ${name} kill --session=NAME # Kill specific session
4246
- ${name} send "1[Enter]" # Recovery: select option 1 and press Enter
4247
- ${name} send "[Escape][Escape]" # Recovery: escape out of a dialog
4248
- ${name} summon # Summon all archangels from .ai/agents/*.md
4249
- ${name} summon reviewer # Summon by name (creates config if new)
4250
- ${name} recall # Recall all archangels
4251
- ${name} recall reviewer # Recall one by name
4252
- ${name} agents # List all agents (shows TYPE=archangel)
4810
+ ${name} kill # Kill agents in current project
4811
+ ${name} kill --all # Kill all agents across all projects
4812
+ ${name} kill --session=NAME # Kill specific session
4813
+ ${name} summon # Summon all archangels from .ai/agents/*.md
4814
+ ${name} summon reviewer # Summon by name (creates config if new)
4815
+ ${name} recall # Recall all archangels
4816
+ ${name} recall reviewer # Recall one by name
4817
+ ${name} agents # List all agents (shows TYPE=archangel)
4253
4818
 
4254
4819
  Note: Reviews and complex tasks may take several minutes.
4255
4820
  Use Bash run_in_background for long operations (not --no-wait).`);
@@ -4276,21 +4841,11 @@ async function main() {
4276
4841
  }
4277
4842
 
4278
4843
  // Extract flags into local variables for convenience
4279
- const { wait, noWait, yolo, fresh, reasoning, follow, all, orphans, force } = flags;
4280
-
4281
- // Agent selection
4282
- let agent = getAgentFromInvocation();
4283
- if (flags.tool) {
4284
- if (flags.tool === "claude") agent = ClaudeAgent;
4285
- else if (flags.tool === "codex") agent = CodexAgent;
4286
- else {
4287
- console.log(`ERROR: unknown tool '${flags.tool}'`);
4288
- process.exit(1);
4289
- }
4290
- }
4844
+ const { wait, noWait, yolo, fresh, reasoning, follow, all, orphans, force, stale, autoApprove } =
4845
+ flags;
4291
4846
 
4292
- // Session resolution
4293
- let session = agent.getDefaultSession();
4847
+ // Session resolution (must happen before agent resolution so we can infer tool from session name)
4848
+ let session = null;
4294
4849
  if (flags.session) {
4295
4850
  if (flags.session === "self") {
4296
4851
  const current = tmuxCurrentSession();
@@ -4305,6 +4860,27 @@ async function main() {
4305
4860
  }
4306
4861
  }
4307
4862
 
4863
+ // Agent resolution (considers --tool flag, session name, invocation, and env vars)
4864
+ const { agent, error: agentError } = resolveAgent({
4865
+ toolFlag: flags.tool,
4866
+ sessionName: session,
4867
+ });
4868
+ if (agentError) {
4869
+ console.log(`ERROR: ${agentError}`);
4870
+ process.exit(1);
4871
+ }
4872
+
4873
+ // Validate --auto-approve is only used with Claude (Codex doesn't support --allowedTools)
4874
+ if (autoApprove && agent.name === "codex") {
4875
+ console.log("ERROR: --auto-approve is not supported by Codex. Use --yolo instead.");
4876
+ process.exit(1);
4877
+ }
4878
+
4879
+ // If no explicit session, use agent's default (with permission filtering)
4880
+ if (!session) {
4881
+ session = agent.getDefaultSession({ allowedTools: autoApprove, yolo });
4882
+ }
4883
+
4308
4884
  // Timeout (convert seconds to milliseconds)
4309
4885
  let timeoutMs = DEFAULT_TIMEOUT_MS;
4310
4886
  if (flags.timeout !== undefined) {
@@ -4330,7 +4906,7 @@ async function main() {
4330
4906
  // Dispatch commands
4331
4907
  if (cmd === "agents") return cmdAgents();
4332
4908
  if (cmd === "target") {
4333
- const defaultSession = agent.getDefaultSession();
4909
+ const defaultSession = agent.getDefaultSession({ allowedTools: autoApprove, yolo });
4334
4910
  if (defaultSession) {
4335
4911
  console.log(defaultSession);
4336
4912
  } else {
@@ -4346,20 +4922,39 @@ async function main() {
4346
4922
  if (cmd === "attach") return cmdAttach(positionals[1] || session);
4347
4923
  if (cmd === "log") return cmdLog(positionals[1] || session, { tail, reasoning, follow });
4348
4924
  if (cmd === "mailbox") return cmdMailbox({ limit, branch, all });
4925
+ if (cmd === "rfp") {
4926
+ if (positionals[1] === "wait") {
4927
+ const rfpId = positionals[2];
4928
+ if (!rfpId) {
4929
+ console.log("ERROR: missing rfp id");
4930
+ process.exit(1);
4931
+ }
4932
+ return cmdRfpWait(rfpId, { archangels: flags.archangels, timeoutMs });
4933
+ }
4934
+ const rawPrompt = positionals.slice(1).join(" ");
4935
+ const prompt = await readStdinIfNeeded(rawPrompt);
4936
+ if (!prompt) {
4937
+ console.log("ERROR: missing prompt for rfp");
4938
+ process.exit(1);
4939
+ }
4940
+ return cmdRfp(prompt, { archangels: flags.archangels, fresh, noWait });
4941
+ }
4349
4942
  if (cmd === "approve") return cmdApprove(agent, session, { wait, timeoutMs });
4350
4943
  if (cmd === "reject") return cmdReject(agent, session, { wait, timeoutMs });
4351
- if (cmd === "review")
4352
- return cmdReview(agent, session, positionals[1], positionals[2], {
4944
+ if (cmd === "review") {
4945
+ const customInstructions = await readStdinIfNeeded(positionals[2]);
4946
+ return cmdReview(agent, session, positionals[1], customInstructions ?? undefined, {
4353
4947
  wait,
4354
4948
  fresh,
4355
4949
  timeoutMs,
4356
4950
  });
4951
+ }
4357
4952
  if (cmd === "status") return cmdStatus(agent, session);
4358
4953
  if (cmd === "debug") return cmdDebug(agent, session);
4359
4954
  if (cmd === "output") {
4360
4955
  const indexArg = positionals[1];
4361
4956
  const index = indexArg?.startsWith("-") ? parseInt(indexArg, 10) : 0;
4362
- return cmdOutput(agent, session, index, { wait, timeoutMs });
4957
+ return cmdOutput(agent, session, index, { wait, stale, timeoutMs });
4363
4958
  }
4364
4959
  if (cmd === "send" && positionals.length > 1)
4365
4960
  return cmdSend(session, positionals.slice(1).join(" "));
@@ -4369,18 +4964,17 @@ async function main() {
4369
4964
  return cmdSelect(agent, session, positionals[1], { wait, timeoutMs });
4370
4965
 
4371
4966
  // Default: send message
4372
- let message = positionals.join(" ");
4373
- if (!message && hasStdinData()) {
4374
- message = await readStdin();
4375
- }
4967
+ const rawMessage = positionals.join(" ");
4968
+ let message = await readStdinIfNeeded(rawMessage);
4376
4969
 
4377
4970
  if (!message || flags.help) {
4378
4971
  printHelp(agent, cliName);
4379
4972
  process.exit(0);
4380
4973
  }
4974
+ const messageText = message;
4381
4975
 
4382
4976
  // Detect "review ..." or "please review ..." and route to custom review mode
4383
- const reviewMatch = message.match(/^(?:please )?review\s*(.*)/i);
4977
+ const reviewMatch = messageText.match(/^(?:please )?review\s*(.*)/i);
4384
4978
  if (reviewMatch && agent.reviewOptions) {
4385
4979
  const customInstructions = reviewMatch[1].trim() || null;
4386
4980
  return cmdReview(agent, session, "custom", customInstructions, {
@@ -4390,7 +4984,12 @@ async function main() {
4390
4984
  });
4391
4985
  }
4392
4986
 
4393
- return cmdAsk(agent, session, message, { noWait, yolo, timeoutMs });
4987
+ return cmdAsk(agent, session, messageText, {
4988
+ noWait,
4989
+ yolo,
4990
+ allowedTools: autoApprove,
4991
+ timeoutMs,
4992
+ });
4394
4993
  }
4395
4994
 
4396
4995
  // Run main() only when executed directly (not when imported for testing)
@@ -4430,4 +5029,6 @@ export {
4430
5029
  extractThinking,
4431
5030
  detectState,
4432
5031
  State,
5032
+ normalizeAllowedTools,
5033
+ computePermissionHash,
4433
5034
  };