ax-agents 0.0.1-alpha.9 → 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 +731 -155
  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) {
@@ -1980,12 +2155,18 @@ class Agent {
1980
2155
  /**
1981
2156
  * @param {boolean} [yolo]
1982
2157
  * @param {string | null} [sessionName]
2158
+ * @param {string | null} [customAllowedTools]
1983
2159
  * @returns {string}
1984
2160
  */
1985
- getCommand(yolo, sessionName = null) {
2161
+ getCommand(yolo, sessionName = null, customAllowedTools = null) {
1986
2162
  let base;
1987
2163
  if (yolo) {
1988
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}"`;
1989
2170
  } else if (this.safeAllowedTools) {
1990
2171
  // Default: auto-approve safe read-only operations
1991
2172
  base = `${this.startCommand} --allowedTools "${this.safeAllowedTools}"`;
@@ -2002,38 +2183,85 @@ class Agent {
2002
2183
  return base;
2003
2184
  }
2004
2185
 
2005
- getDefaultSession() {
2186
+ /**
2187
+ * @param {{allowedTools?: string | null, yolo?: boolean}} [options]
2188
+ * @returns {string | null}
2189
+ */
2190
+ getDefaultSession({ allowedTools = null, yolo = false } = {}) {
2006
2191
  // Check env var for explicit session
2007
2192
  if (this.envVar && process.env[this.envVar]) {
2008
- return process.env[this.envVar];
2193
+ return process.env[this.envVar] ?? null;
2009
2194
  }
2010
2195
 
2011
2196
  const cwd = process.cwd();
2012
- 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;
2240
+
2241
+ // Stop at git root (don't leak across projects)
2242
+ if (existsSync(path.join(searchDir, ".git"))) break;
2013
2243
 
2014
- // If inside tmux, look for existing agent session in same cwd
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
2015
2251
  const current = tmuxCurrentSession();
2016
2252
  if (current) {
2017
2253
  const sessions = tmuxListSessions();
2018
- const existing = sessions.find((s) => {
2019
- if (!childPattern.test(s)) return false;
2020
- const sessionCwd = getTmuxSessionCwd(s);
2021
- return sessionCwd === cwd;
2022
- });
2254
+ const existing = findSessionInCwdOrParent(sessions);
2023
2255
  if (existing) return existing;
2024
- // 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
2025
2257
  return null;
2026
2258
  }
2027
2259
 
2028
- // Walk up to find claude/codex ancestor and reuse its session (must match cwd)
2029
- const callerPid = findCallerPid();
2030
- if (callerPid) {
2260
+ // Walk up to find claude/codex ancestor and reuse its session
2261
+ const caller = findCallerAgent();
2262
+ if (caller) {
2031
2263
  const sessions = tmuxListSessions();
2032
- const existing = sessions.find((s) => {
2033
- if (!childPattern.test(s)) return false;
2034
- const sessionCwd = getTmuxSessionCwd(s);
2035
- return sessionCwd === cwd;
2036
- });
2264
+ const existing = findSessionInCwdOrParent(sessions);
2037
2265
  if (existing) return existing;
2038
2266
  }
2039
2267
 
@@ -2042,10 +2270,11 @@ class Agent {
2042
2270
  }
2043
2271
 
2044
2272
  /**
2273
+ * @param {{allowedTools?: string | null, yolo?: boolean}} [options]
2045
2274
  * @returns {string}
2046
2275
  */
2047
- generateSession() {
2048
- return generateSessionName(this.name);
2276
+ generateSession(options = {}) {
2277
+ return generateSessionName(this.name, options);
2049
2278
  }
2050
2279
 
2051
2280
  /**
@@ -2371,8 +2600,12 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2371
2600
  const initialScreen = tmuxCapture(session);
2372
2601
  const initialState = agent.getState(initialScreen);
2373
2602
 
2374
- // Already in terminal state
2375
- 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
2376
2609
  initialState === State.RATE_LIMITED ||
2377
2610
  initialState === State.CONFIRMING ||
2378
2611
  initialState === State.READY
@@ -2385,6 +2618,13 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2385
2618
  const screen = tmuxCapture(session);
2386
2619
  const state = agent.getState(screen);
2387
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
+
2388
2628
  if (state === State.RATE_LIMITED || state === State.CONFIRMING || state === State.READY) {
2389
2629
  return { state, screen };
2390
2630
  }
@@ -2425,6 +2665,13 @@ async function pollForResponse(agent, session, timeoutMs, hooks = {}) {
2425
2665
  return { state, screen };
2426
2666
  }
2427
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
+
2428
2675
  if (screen !== lastScreen) {
2429
2676
  lastScreen = screen;
2430
2677
  stableAt = Date.now();
@@ -2534,6 +2781,7 @@ async function autoApproveLoop(agent, session, timeoutMs, waitFn) {
2534
2781
  continue;
2535
2782
  }
2536
2783
 
2784
+ // FEEDBACK_MODAL is handled by the underlying waitFn (pollForResponse)
2537
2785
  debugError("autoApproveLoop", new Error(`unexpected state: ${state}`));
2538
2786
  }
2539
2787
 
@@ -2545,12 +2793,13 @@ async function autoApproveLoop(agent, session, timeoutMs, waitFn) {
2545
2793
  * @param {string | null | undefined} session
2546
2794
  * @param {Object} [options]
2547
2795
  * @param {boolean} [options.yolo]
2796
+ * @param {string | null} [options.allowedTools]
2548
2797
  * @returns {Promise<string>}
2549
2798
  */
2550
- async function cmdStart(agent, session, { yolo = false } = {}) {
2799
+ async function cmdStart(agent, session, { yolo = false, allowedTools = null } = {}) {
2551
2800
  // Generate session name if not provided
2552
2801
  if (!session) {
2553
- session = agent.generateSession();
2802
+ session = agent.generateSession({ allowedTools, yolo });
2554
2803
  }
2555
2804
 
2556
2805
  if (tmuxHasSession(session)) return session;
@@ -2562,7 +2811,7 @@ async function cmdStart(agent, session, { yolo = false } = {}) {
2562
2811
  process.exit(1);
2563
2812
  }
2564
2813
 
2565
- const command = agent.getCommand(yolo, session);
2814
+ const command = agent.getCommand(yolo, session, allowedTools);
2566
2815
  tmuxNewSession(session, command);
2567
2816
 
2568
2817
  const start = Date.now();
@@ -2575,6 +2824,18 @@ async function cmdStart(agent, session, { yolo = false } = {}) {
2575
2824
  continue;
2576
2825
  }
2577
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
+
2578
2839
  if (state === State.READY) return session;
2579
2840
 
2580
2841
  await sleep(POLL_MS);
@@ -2622,6 +2883,7 @@ function cmdAgents() {
2622
2883
  const isDefault =
2623
2884
  (parsed.tool === "claude" && session === claudeDefault) ||
2624
2885
  (parsed.tool === "codex" && session === codexDefault);
2886
+ const perms = getSessionPermissions(session);
2625
2887
 
2626
2888
  // Get session metadata (Claude only)
2627
2889
  const meta = getSessionMeta(session);
@@ -2632,6 +2894,7 @@ function cmdAgents() {
2632
2894
  state: state || "unknown",
2633
2895
  target: isDefault ? "*" : "",
2634
2896
  type,
2897
+ mode: perms.mode,
2635
2898
  plan: meta?.slug || "-",
2636
2899
  branch: meta?.gitBranch || "-",
2637
2900
  };
@@ -2643,14 +2906,15 @@ function cmdAgents() {
2643
2906
  const maxState = Math.max(5, ...agents.map((a) => a.state.length));
2644
2907
  const maxTarget = Math.max(6, ...agents.map((a) => a.target.length));
2645
2908
  const maxType = Math.max(4, ...agents.map((a) => a.type.length));
2909
+ const maxMode = Math.max(4, ...agents.map((a) => a.mode.length));
2646
2910
  const maxPlan = Math.max(4, ...agents.map((a) => a.plan.length));
2647
2911
 
2648
2912
  console.log(
2649
- `${"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`,
2650
2914
  );
2651
2915
  for (const a of agents) {
2652
2916
  console.log(
2653
- `${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}`,
2654
2918
  );
2655
2919
  }
2656
2920
 
@@ -2707,11 +2971,29 @@ function startArchangel(config, parentSession = null) {
2707
2971
  env,
2708
2972
  });
2709
2973
  child.unref();
2974
+ const watchingLabel = parentSession
2975
+ ? parentSession.session || parentSession.uuid?.slice(0, 8)
2976
+ : null;
2710
2977
  console.log(
2711
- `Summoning: ${config.name} (pid ${child.pid})${parentSession ? ` [parent: ${parentSession.session}]` : ""}`,
2978
+ `Summoning: ${config.name} (pid ${child.pid})${watchingLabel ? ` [watching: ${watchingLabel}]` : ""}`,
2712
2979
  );
2713
2980
  }
2714
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
+
2715
2997
  // =============================================================================
2716
2998
  // Command: archangel (runs as the archangel process itself)
2717
2999
  // =============================================================================
@@ -2959,28 +3241,18 @@ async function cmdArchangel(agentName) {
2959
3241
 
2960
3242
  const cleanedResponse = agent.getResponse(sessionName, afterScreen) || "";
2961
3243
 
2962
- // Sanity check: skip garbage responses (screen scraping artifacts)
2963
- const isGarbage =
2964
- cleanedResponse.includes("[Pasted text") ||
2965
- cleanedResponse.match(/^\+\d+ lines\]/) ||
2966
- cleanedResponse.length < 20;
2967
-
2968
- if (
2969
- cleanedResponse &&
2970
- !isGarbage &&
2971
- !cleanedResponse.toLowerCase().includes("no issues found")
2972
- ) {
3244
+ const isSkippable = !cleanedResponse || cleanedResponse.trim() === "EMPTY_RESPONSE";
3245
+
3246
+ if (!isSkippable) {
2973
3247
  writeToMailbox({
2974
3248
  agent: /** @type {string} */ (agentName),
2975
3249
  session: sessionName,
2976
3250
  branch: getCurrentBranch(),
2977
3251
  commit: getCurrentCommit(),
2978
3252
  files,
2979
- message: cleanedResponse.slice(0, 1000),
3253
+ message: cleanedResponse,
2980
3254
  });
2981
3255
  console.log(`[archangel:${agentName}] Wrote observation for ${files.length} file(s)`);
2982
- } else if (isGarbage) {
2983
- console.log(`[archangel:${agentName}] Skipped garbage response`);
2984
3256
  }
2985
3257
  } catch (err) {
2986
3258
  console.error(`[archangel:${agentName}] Error:`, err instanceof Error ? err.message : err);
@@ -3172,6 +3444,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs";
3172
3444
  import { dirname, join } from "node:path";
3173
3445
  import { fileURLToPath } from "node:url";
3174
3446
  import { createHash } from "node:crypto";
3447
+ import { execSync } from "node:child_process";
3175
3448
 
3176
3449
  const __dirname = dirname(fileURLToPath(import.meta.url));
3177
3450
  const AI_DIR = join(__dirname, "..");
@@ -3179,6 +3452,15 @@ const DEBUG = process.env.AX_DEBUG === "1";
3179
3452
  const MAILBOX = join(AI_DIR, "mailbox.jsonl");
3180
3453
  const MAX_AGE_MS = 60 * 60 * 1000;
3181
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
+
3182
3464
  // Read hook input from stdin
3183
3465
  let hookInput = {};
3184
3466
  try {
@@ -3193,8 +3475,9 @@ const hookEvent = hookInput.hook_event_name || "";
3193
3475
 
3194
3476
  if (DEBUG) console.error("[hook] session:", sessionId, "event:", hookEvent);
3195
3477
 
3196
- // NO-OP for archangel or partner sessions
3197
- 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-"))) {
3198
3481
  if (DEBUG) console.error("[hook] skipping non-parent session");
3199
3482
  process.exit(0);
3200
3483
  }
@@ -3385,7 +3668,7 @@ function cmdKill(session, { all = false, orphans = false, force = false } = {})
3385
3668
  // If specific session provided, kill just that one
3386
3669
  if (session) {
3387
3670
  if (!tmuxHasSession(session)) {
3388
- console.log("ERROR: session not found");
3671
+ console.log("ERROR: session not found. Run 'ax agents' to list sessions.");
3389
3672
  process.exit(1);
3390
3673
  }
3391
3674
  tmuxKill(session);
@@ -3436,7 +3719,7 @@ function cmdAttach(session) {
3436
3719
  // Resolve partial session name
3437
3720
  const resolved = resolveSessionName(session);
3438
3721
  if (!resolved || !tmuxHasSession(resolved)) {
3439
- console.log("ERROR: session not found");
3722
+ console.log("ERROR: session not found. Run 'ax agents' to list sessions.");
3440
3723
  process.exit(1);
3441
3724
  }
3442
3725
 
@@ -3460,7 +3743,7 @@ function cmdLog(sessionName, { tail = 50, reasoning = false, follow = false } =
3460
3743
  // Resolve partial session name
3461
3744
  const resolved = resolveSessionName(sessionName);
3462
3745
  if (!resolved) {
3463
- console.log("ERROR: session not found");
3746
+ console.log("ERROR: session not found. Run 'ax agents' to list sessions.");
3464
3747
  process.exit(1);
3465
3748
  }
3466
3749
  const parsed = parseSessionName(resolved);
@@ -3724,7 +4007,13 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
3724
4007
  console.log(`**Branch**: ${p.branch || "?"} @ ${p.commit || "?"}\n`);
3725
4008
  }
3726
4009
 
3727
- 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) {
3728
4017
  console.log(`**Assistant**: ${p.message}\n`);
3729
4018
  }
3730
4019
 
@@ -3738,13 +4027,246 @@ function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
3738
4027
  }
3739
4028
  }
3740
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
+
3741
4258
  /**
3742
4259
  * @param {Agent} agent
3743
4260
  * @param {string | null | undefined} session
3744
4261
  * @param {string} message
3745
- * @param {{noWait?: boolean, yolo?: boolean, timeoutMs?: number}} [options]
4262
+ * @param {{noWait?: boolean, yolo?: boolean, allowedTools?: string | null, timeoutMs?: number}} [options]
3746
4263
  */
3747
- 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
+ ) {
3748
4270
  const sessionExists = session != null && tmuxHasSession(session);
3749
4271
  const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
3750
4272
 
@@ -3758,7 +4280,7 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3758
4280
  /** @type {string} */
3759
4281
  const activeSession = sessionExists
3760
4282
  ? /** @type {string} */ (session)
3761
- : await cmdStart(agent, session, { yolo });
4283
+ : await cmdStart(agent, session, { yolo, allowedTools });
3762
4284
 
3763
4285
  tmuxSendLiteral(activeSession, message);
3764
4286
  await sleep(50);
@@ -3767,12 +4289,26 @@ async function cmdAsk(agent, session, message, { noWait = false, yolo = false, t
3767
4289
  if (noWait) {
3768
4290
  const parsed = parseSessionName(activeSession);
3769
4291
  const shortId = parsed?.uuid?.slice(0, 8) || activeSession;
3770
- const cli = path.basename(process.argv[1], ".js");
3771
- 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 += `
3772
4305
 
3773
4306
  e.g.
3774
- ${cli} status --session=${shortId}
3775
- ${cli} output --session=${shortId}`);
4307
+ TaskCreate({ subject: "ax:${shortId} - ${taskPreview}" })
4308
+ `;
4309
+ }
4310
+
4311
+ console.log(output);
3776
4312
  return;
3777
4313
  }
3778
4314
 
@@ -3964,9 +4500,14 @@ async function cmdReview(
3964
4500
  * @param {Agent} agent
3965
4501
  * @param {string | null | undefined} session
3966
4502
  * @param {number} [index]
3967
- * @param {{wait?: boolean, timeoutMs?: number}} [options]
4503
+ * @param {{wait?: boolean, stale?: boolean, timeoutMs?: number}} [options]
3968
4504
  */
3969
- 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
+ ) {
3970
4511
  if (!session || !tmuxHasSession(session)) {
3971
4512
  console.log("ERROR: no session");
3972
4513
  process.exit(1);
@@ -3993,8 +4534,11 @@ async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs }
3993
4534
  }
3994
4535
 
3995
4536
  if (state === State.THINKING) {
3996
- console.log("THINKING");
3997
- 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
3998
4542
  }
3999
4543
 
4000
4544
  const output = agent.getResponse(session, screen, index);
@@ -4192,7 +4736,12 @@ function resolveAgent({ toolFlag, sessionName } = {}) {
4192
4736
  if (invoked === "axclaude" || invoked === "claude") return { agent: ClaudeAgent };
4193
4737
  if (invoked === "axcodex" || invoked === "codex") return { agent: CodexAgent };
4194
4738
 
4195
- // 4. AX_DEFAULT_TOOL environment variable
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 };
4743
+
4744
+ // 5. AX_DEFAULT_TOOL environment variable
4196
4745
  const defaultTool = process.env.AX_DEFAULT_TOOL;
4197
4746
  if (defaultTool === "claude") return { agent: ClaudeAgent };
4198
4747
  if (defaultTool === "codex" || !defaultTool) return { agent: CodexAgent };
@@ -4208,72 +4757,64 @@ function resolveAgent({ toolFlag, sessionName } = {}) {
4208
4757
  function printHelp(agent, cliName) {
4209
4758
  const name = cliName;
4210
4759
  const backendName = agent.displayName;
4211
- const hasReview = !!agent.reviewOptions;
4212
4760
 
4213
4761
  console.log(`${name} v${VERSION} - agentic assistant CLI (${backendName})
4214
4762
 
4215
4763
  Usage: ${name} [OPTIONS] <command|message> [ARGS...]
4216
4764
 
4217
- Commands:
4218
- 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
4219
4773
  target Show default target session for current tool
4220
4774
  attach [SESSION] Attach to agent session interactively
4221
- log SESSION View conversation log (--tail=N, --follow, --reasoning)
4222
- mailbox View archangel observations (--limit=N, --branch=X, --all)
4775
+ kill Kill sessions (--all, --session=NAME, --orphans [--force])
4776
+
4777
+ Archangels:
4223
4778
  summon [name] Summon archangels (all, or by name)
4224
4779
  recall [name] Recall archangels (all, or by name)
4225
- kill Kill sessions (--all, --session=NAME, --orphans [--force])
4226
- 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
4227
4786
  output [-N] Show response (0=last, -1=prev, -2=older)
4228
- debug Show raw screen output and detected state${
4229
- hasReview
4230
- ? `
4231
- review [TYPE] Review code: pr, uncommitted, commit, custom`
4232
- : ""
4233
- }
4234
- select N Select menu option N
4787
+ debug Show raw screen output and detected state
4235
4788
  approve Approve pending action (send 'y')
4236
4789
  reject Reject pending action (send 'n')
4790
+ select N Select menu option N
4237
4791
  send KEYS Send key sequence (e.g. "1[Enter]", "[Escape]")
4238
- compact Summarize conversation (when context is full)
4239
- reset Start fresh conversation
4240
- <message> Send message to ${name}
4792
+ log [SESSION] View conversation log (--tail=N, --follow, --reasoning)
4241
4793
 
4242
4794
  Flags:
4243
4795
  --tool=NAME Use specific agent (codex, claude)
4244
- --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 *")')
4245
4800
  --wait Wait for response (default for messages; required for approve/reject)
4246
4801
  --no-wait Fire-and-forget: send message, print session ID, exit immediately
4247
4802
  --timeout=N Set timeout in seconds (default: ${DEFAULT_TIMEOUT_MS / 1000}, reviews: ${REVIEW_TIMEOUT_MS / 1000})
4248
- --yolo Skip all confirmations (dangerous)
4249
- --fresh Reset conversation before review
4250
- --orphans Kill orphaned claude/codex processes (PPID=1)
4251
- --force Use SIGKILL instead of SIGTERM (with --orphans)
4252
-
4253
- Environment:
4254
- AX_DEFAULT_TOOL Default agent when using 'ax' (claude or codex, default: codex)
4255
- ${agent.envVar} Override default session name
4256
- AX_CLAUDE_CONFIG_DIR Override Claude config directory (default: ~/.claude)
4257
- AX_CODEX_CONFIG_DIR Override Codex config directory (default: ~/.codex)
4258
- AX_REVIEW_MODE=exec Bypass /review, send instructions directly (codex only)
4259
- AX_DEBUG=1 Enable debug logging
4260
4803
 
4261
4804
  Examples:
4262
4805
  ${name} "explain this codebase"
4263
- ${name} "review the error handling" # Auto custom review (${REVIEW_TIMEOUT_MS / 60000}min timeout)
4264
- ${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
4265
4809
  ${name} review uncommitted --wait
4266
- ${name} approve --wait
4267
- ${name} kill # Kill agents in current project
4268
- ${name} kill --all # Kill all agents across all projects
4269
- ${name} kill --session=NAME # Kill specific session
4270
- ${name} send "1[Enter]" # Recovery: select option 1 and press Enter
4271
- ${name} send "[Escape][Escape]" # Recovery: escape out of a dialog
4272
- ${name} summon # Summon all archangels from .ai/agents/*.md
4273
- ${name} summon reviewer # Summon by name (creates config if new)
4274
- ${name} recall # Recall all archangels
4275
- ${name} recall reviewer # Recall one by name
4276
- ${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)
4277
4818
 
4278
4819
  Note: Reviews and complex tasks may take several minutes.
4279
4820
  Use Bash run_in_background for long operations (not --no-wait).`);
@@ -4300,7 +4841,8 @@ async function main() {
4300
4841
  }
4301
4842
 
4302
4843
  // Extract flags into local variables for convenience
4303
- const { wait, noWait, yolo, fresh, reasoning, follow, all, orphans, force } = flags;
4844
+ const { wait, noWait, yolo, fresh, reasoning, follow, all, orphans, force, stale, autoApprove } =
4845
+ flags;
4304
4846
 
4305
4847
  // Session resolution (must happen before agent resolution so we can infer tool from session name)
4306
4848
  let session = null;
@@ -4319,15 +4861,24 @@ async function main() {
4319
4861
  }
4320
4862
 
4321
4863
  // Agent resolution (considers --tool flag, session name, invocation, and env vars)
4322
- const { agent, error: agentError } = resolveAgent({ toolFlag: flags.tool, sessionName: session });
4864
+ const { agent, error: agentError } = resolveAgent({
4865
+ toolFlag: flags.tool,
4866
+ sessionName: session,
4867
+ });
4323
4868
  if (agentError) {
4324
4869
  console.log(`ERROR: ${agentError}`);
4325
4870
  process.exit(1);
4326
4871
  }
4327
4872
 
4328
- // If no explicit session, use agent's default
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)
4329
4880
  if (!session) {
4330
- session = agent.getDefaultSession();
4881
+ session = agent.getDefaultSession({ allowedTools: autoApprove, yolo });
4331
4882
  }
4332
4883
 
4333
4884
  // Timeout (convert seconds to milliseconds)
@@ -4355,7 +4906,7 @@ async function main() {
4355
4906
  // Dispatch commands
4356
4907
  if (cmd === "agents") return cmdAgents();
4357
4908
  if (cmd === "target") {
4358
- const defaultSession = agent.getDefaultSession();
4909
+ const defaultSession = agent.getDefaultSession({ allowedTools: autoApprove, yolo });
4359
4910
  if (defaultSession) {
4360
4911
  console.log(defaultSession);
4361
4912
  } else {
@@ -4371,20 +4922,39 @@ async function main() {
4371
4922
  if (cmd === "attach") return cmdAttach(positionals[1] || session);
4372
4923
  if (cmd === "log") return cmdLog(positionals[1] || session, { tail, reasoning, follow });
4373
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
+ }
4374
4942
  if (cmd === "approve") return cmdApprove(agent, session, { wait, timeoutMs });
4375
4943
  if (cmd === "reject") return cmdReject(agent, session, { wait, timeoutMs });
4376
- if (cmd === "review")
4377
- 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, {
4378
4947
  wait,
4379
4948
  fresh,
4380
4949
  timeoutMs,
4381
4950
  });
4951
+ }
4382
4952
  if (cmd === "status") return cmdStatus(agent, session);
4383
4953
  if (cmd === "debug") return cmdDebug(agent, session);
4384
4954
  if (cmd === "output") {
4385
4955
  const indexArg = positionals[1];
4386
4956
  const index = indexArg?.startsWith("-") ? parseInt(indexArg, 10) : 0;
4387
- return cmdOutput(agent, session, index, { wait, timeoutMs });
4957
+ return cmdOutput(agent, session, index, { wait, stale, timeoutMs });
4388
4958
  }
4389
4959
  if (cmd === "send" && positionals.length > 1)
4390
4960
  return cmdSend(session, positionals.slice(1).join(" "));
@@ -4394,18 +4964,17 @@ async function main() {
4394
4964
  return cmdSelect(agent, session, positionals[1], { wait, timeoutMs });
4395
4965
 
4396
4966
  // Default: send message
4397
- let message = positionals.join(" ");
4398
- if (!message && hasStdinData()) {
4399
- message = await readStdin();
4400
- }
4967
+ const rawMessage = positionals.join(" ");
4968
+ let message = await readStdinIfNeeded(rawMessage);
4401
4969
 
4402
4970
  if (!message || flags.help) {
4403
4971
  printHelp(agent, cliName);
4404
4972
  process.exit(0);
4405
4973
  }
4974
+ const messageText = message;
4406
4975
 
4407
4976
  // Detect "review ..." or "please review ..." and route to custom review mode
4408
- const reviewMatch = message.match(/^(?:please )?review\s*(.*)/i);
4977
+ const reviewMatch = messageText.match(/^(?:please )?review\s*(.*)/i);
4409
4978
  if (reviewMatch && agent.reviewOptions) {
4410
4979
  const customInstructions = reviewMatch[1].trim() || null;
4411
4980
  return cmdReview(agent, session, "custom", customInstructions, {
@@ -4415,7 +4984,12 @@ async function main() {
4415
4984
  });
4416
4985
  }
4417
4986
 
4418
- return cmdAsk(agent, session, message, { noWait, yolo, timeoutMs });
4987
+ return cmdAsk(agent, session, messageText, {
4988
+ noWait,
4989
+ yolo,
4990
+ allowedTools: autoApprove,
4991
+ timeoutMs,
4992
+ });
4419
4993
  }
4420
4994
 
4421
4995
  // Run main() only when executed directly (not when imported for testing)
@@ -4455,4 +5029,6 @@ export {
4455
5029
  extractThinking,
4456
5030
  detectState,
4457
5031
  State,
5032
+ normalizeAllowedTools,
5033
+ computePermissionHash,
4458
5034
  };