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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/ax.js +334 -99
  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
  /**
@@ -259,25 +261,66 @@ function tmuxCurrentSession() {
259
261
  }
260
262
 
261
263
  /**
262
- * 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.
263
295
  * @param {string} session
264
296
  * @returns {boolean}
265
297
  */
266
298
  function isYoloSession(session) {
267
- try {
268
- const result = spawnSync(
269
- "tmux",
270
- ["display-message", "-t", session, "-p", "#{pane_start_command}"],
271
- {
272
- encoding: "utf-8",
273
- },
274
- );
275
- if (result.status !== 0) return false;
276
- const cmd = result.stdout.trim();
277
- return cmd.includes("--dangerously-");
278
- } catch {
279
- return false;
280
- }
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);
281
324
  }
282
325
 
283
326
  // =============================================================================
@@ -374,9 +417,9 @@ async function waitFor(session, predicate, timeoutMs = STARTUP_TIMEOUT_MS) {
374
417
  // =============================================================================
375
418
 
376
419
  /**
377
- * @returns {number | null}
420
+ * @returns {{pid: number, agent: 'claude' | 'codex'} | null}
378
421
  */
379
- function findCallerPid() {
422
+ function findCallerAgent() {
380
423
  let pid = process.ppid;
381
424
  while (pid > 1) {
382
425
  const result = spawnSync("ps", ["-p", pid.toString(), "-o", "ppid=,comm="], {
@@ -386,9 +429,8 @@ function findCallerPid() {
386
429
  const parts = result.stdout.trim().split(/\s+/);
387
430
  const ppid = parseInt(parts[0], 10);
388
431
  const cmd = parts.slice(1).join(" ");
389
- if (cmd.includes("claude") || cmd.includes("codex")) {
390
- return pid;
391
- }
432
+ if (cmd.includes("claude")) return { pid, agent: "claude" };
433
+ if (cmd.includes("codex")) return { pid, agent: "codex" };
392
434
  pid = ppid;
393
435
  }
394
436
  return null;
@@ -486,6 +528,7 @@ async function readStdinIfNeeded(value) {
486
528
  * @property {boolean} all
487
529
  * @property {boolean} orphans
488
530
  * @property {boolean} force
531
+ * @property {boolean} stale
489
532
  * @property {boolean} version
490
533
  * @property {boolean} help
491
534
  * @property {string} [tool]
@@ -495,6 +538,7 @@ async function readStdinIfNeeded(value) {
495
538
  * @property {number} [limit]
496
539
  * @property {string} [branch]
497
540
  * @property {string} [archangels]
541
+ * @property {string} [autoApprove]
498
542
  */
499
543
  function parseCliArgs(args) {
500
544
  const { values, positionals } = parseArgs({
@@ -510,10 +554,12 @@ function parseCliArgs(args) {
510
554
  all: { type: "boolean", default: false },
511
555
  orphans: { type: "boolean", default: false },
512
556
  force: { type: "boolean", default: false },
557
+ stale: { type: "boolean", default: false },
513
558
  version: { type: "boolean", short: "V", default: false },
514
559
  help: { type: "boolean", short: "h", default: false },
515
560
  // Value flags
516
561
  tool: { type: "string" },
562
+ "auto-approve": { type: "string" },
517
563
  session: { type: "string" },
518
564
  timeout: { type: "string" },
519
565
  tail: { type: "string" },
@@ -536,6 +582,7 @@ function parseCliArgs(args) {
536
582
  all: Boolean(values.all),
537
583
  orphans: Boolean(values.orphans),
538
584
  force: Boolean(values.force),
585
+ stale: Boolean(values.stale),
539
586
  version: Boolean(values.version),
540
587
  help: Boolean(values.help),
541
588
  tool: /** @type {string | undefined} */ (values.tool),
@@ -545,6 +592,7 @@ function parseCliArgs(args) {
545
592
  limit: values.limit !== undefined ? Number(values.limit) : undefined,
546
593
  branch: /** @type {string | undefined} */ (values.branch),
547
594
  archangels: /** @type {string | undefined} */ (values.archangels),
595
+ autoApprove: /** @type {string | undefined} */ (values["auto-approve"]),
548
596
  },
549
597
  positionals,
550
598
  };
@@ -553,6 +601,10 @@ function parseCliArgs(args) {
553
601
  // Helpers - session tracking
554
602
  // =============================================================================
555
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
+
556
608
  /**
557
609
  * @param {string} session
558
610
  * @returns {ParsedSession | null}
@@ -565,19 +617,27 @@ function parseSessionName(session) {
565
617
  const rest = match[2];
566
618
 
567
619
  // Archangel: {tool}-archangel-{name}-{uuid}
568
- const archangelMatch = rest.match(
569
- /^archangel-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i,
570
- );
620
+ const archangelPattern = new RegExp(`^archangel-(.+)-(${UUID_PATTERN})$`, "i");
621
+ const archangelMatch = rest.match(archangelPattern);
571
622
  if (archangelMatch) {
572
623
  return { tool, archangelName: archangelMatch[1], uuid: archangelMatch[2] };
573
624
  }
574
625
 
575
- // Partner: {tool}-partner-{uuid}
576
- const partnerMatch = rest.match(
577
- /^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",
578
630
  );
631
+ const partnerMatch = rest.match(partnerPattern);
579
632
  if (partnerMatch) {
580
- 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;
581
641
  }
582
642
 
583
643
  // Anything else
@@ -586,10 +646,19 @@ function parseSessionName(session) {
586
646
 
587
647
  /**
588
648
  * @param {string} tool
649
+ * @param {{allowedTools?: string | null, yolo?: boolean}} [options]
589
650
  * @returns {string}
590
651
  */
591
- function generateSessionName(tool) {
592
- 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}`;
593
662
  }
594
663
 
595
664
  /**
@@ -1491,8 +1560,8 @@ function findCurrentClaudeSession() {
1491
1560
 
1492
1561
  // We might be running from Claude but not inside tmux (e.g., VSCode, Cursor)
1493
1562
  // Find Claude sessions in the same cwd and pick the most recently active one
1494
- const callerPid = findCallerPid();
1495
- if (!callerPid) return null; // Not running from Claude
1563
+ const caller = findCallerAgent();
1564
+ if (!caller) return null;
1496
1565
 
1497
1566
  const cwd = process.cwd();
1498
1567
  const sessions = tmuxListSessions();
@@ -1906,6 +1975,7 @@ const State = {
1906
1975
  THINKING: "thinking",
1907
1976
  CONFIRMING: "confirming",
1908
1977
  RATE_LIMITED: "rate_limited",
1978
+ FEEDBACK_MODAL: "feedback_modal",
1909
1979
  };
1910
1980
 
1911
1981
  /**
@@ -1933,6 +2003,17 @@ function detectState(screen, config) {
1933
2003
  return State.RATE_LIMITED;
1934
2004
  }
1935
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
+
1936
2017
  // Confirming - check before THINKING because "Running…" in tool output matches thinking patterns
1937
2018
  const confirmPatterns = config.confirmPatterns || [];
1938
2019
  for (const pattern of confirmPatterns) {
@@ -2074,12 +2155,18 @@ class Agent {
2074
2155
  /**
2075
2156
  * @param {boolean} [yolo]
2076
2157
  * @param {string | null} [sessionName]
2158
+ * @param {string | null} [customAllowedTools]
2077
2159
  * @returns {string}
2078
2160
  */
2079
- getCommand(yolo, sessionName = null) {
2161
+ getCommand(yolo, sessionName = null, customAllowedTools = null) {
2080
2162
  let base;
2081
2163
  if (yolo) {
2082
2164
  base = this.yoloCommand;
2165
+ } else if (customAllowedTools) {
2166
+ // Custom permissions from --auto-approve flag
2167
+ // Escape quotes for shell since tmux runs the command through a shell
2168
+ const escaped = customAllowedTools.replace(/"/g, '\\"');
2169
+ base = `${this.startCommand} --allowedTools "${escaped}"`;
2083
2170
  } else if (this.safeAllowedTools) {
2084
2171
  // Default: auto-approve safe read-only operations
2085
2172
  base = `${this.startCommand} --allowedTools "${this.safeAllowedTools}"`;
@@ -2088,43 +2175,93 @@ class Agent {
2088
2175
  }
2089
2176
  // Some agents support session ID flags for deterministic session tracking
2090
2177
  if (this.sessionIdFlag && sessionName) {
2091
- return `${base} ${this.sessionIdFlag} ${sessionName}`;
2178
+ const parsed = parseSessionName(sessionName);
2179
+ if (parsed?.uuid) {
2180
+ return `${base} ${this.sessionIdFlag} ${parsed.uuid}`;
2181
+ }
2092
2182
  }
2093
2183
  return base;
2094
2184
  }
2095
2185
 
2096
- getDefaultSession() {
2186
+ /**
2187
+ * @param {{allowedTools?: string | null, yolo?: boolean}} [options]
2188
+ * @returns {string | null}
2189
+ */
2190
+ getDefaultSession({ allowedTools = null, yolo = false } = {}) {
2097
2191
  // Check env var for explicit session
2098
2192
  if (this.envVar && process.env[this.envVar]) {
2099
- return process.env[this.envVar];
2193
+ return process.env[this.envVar] ?? null;
2100
2194
  }
2101
2195
 
2102
2196
  const cwd = process.cwd();
2103
- 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;
2104
2230
 
2105
- // If inside tmux, look for existing agent session in same cwd
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;
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
2106
2251
  const current = tmuxCurrentSession();
2107
2252
  if (current) {
2108
2253
  const sessions = tmuxListSessions();
2109
- const existing = sessions.find((s) => {
2110
- if (!childPattern.test(s)) return false;
2111
- const sessionCwd = getTmuxSessionCwd(s);
2112
- return sessionCwd === cwd;
2113
- });
2254
+ const existing = findSessionInCwdOrParent(sessions);
2114
2255
  if (existing) return existing;
2115
- // 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
2116
2257
  return null;
2117
2258
  }
2118
2259
 
2119
- // Walk up to find claude/codex ancestor and reuse its session (must match cwd)
2120
- const callerPid = findCallerPid();
2121
- if (callerPid) {
2260
+ // Walk up to find claude/codex ancestor and reuse its session
2261
+ const caller = findCallerAgent();
2262
+ if (caller) {
2122
2263
  const sessions = tmuxListSessions();
2123
- const existing = sessions.find((s) => {
2124
- if (!childPattern.test(s)) return false;
2125
- const sessionCwd = getTmuxSessionCwd(s);
2126
- return sessionCwd === cwd;
2127
- });
2264
+ const existing = findSessionInCwdOrParent(sessions);
2128
2265
  if (existing) return existing;
2129
2266
  }
2130
2267
 
@@ -2133,10 +2270,11 @@ class Agent {
2133
2270
  }
2134
2271
 
2135
2272
  /**
2273
+ * @param {{allowedTools?: string | null, yolo?: boolean}} [options]
2136
2274
  * @returns {string}
2137
2275
  */
2138
- generateSession() {
2139
- return generateSessionName(this.name);
2276
+ generateSession(options = {}) {
2277
+ return generateSessionName(this.name, options);
2140
2278
  }
2141
2279
 
2142
2280
  /**
@@ -2462,8 +2600,12 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2462
2600
  const initialScreen = tmuxCapture(session);
2463
2601
  const initialState = agent.getState(initialScreen);
2464
2602
 
2465
- // Already in terminal state
2466
- 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
2467
2609
  initialState === State.RATE_LIMITED ||
2468
2610
  initialState === State.CONFIRMING ||
2469
2611
  initialState === State.READY
@@ -2476,6 +2618,13 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2476
2618
  const screen = tmuxCapture(session);
2477
2619
  const state = agent.getState(screen);
2478
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
+
2479
2628
  if (state === State.RATE_LIMITED || state === State.CONFIRMING || state === State.READY) {
2480
2629
  return { state, screen };
2481
2630
  }
@@ -2516,6 +2665,13 @@ async function pollForResponse(agent, session, timeoutMs, hooks = {}) {
2516
2665
  return { state, screen };
2517
2666
  }
2518
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
+
2519
2675
  if (screen !== lastScreen) {
2520
2676
  lastScreen = screen;
2521
2677
  stableAt = Date.now();
@@ -2625,6 +2781,7 @@ async function autoApproveLoop(agent, session, timeoutMs, waitFn) {
2625
2781
  continue;
2626
2782
  }
2627
2783
 
2784
+ // FEEDBACK_MODAL is handled by the underlying waitFn (pollForResponse)
2628
2785
  debugError("autoApproveLoop", new Error(`unexpected state: ${state}`));
2629
2786
  }
2630
2787
 
@@ -2636,12 +2793,13 @@ async function autoApproveLoop(agent, session, timeoutMs, waitFn) {
2636
2793
  * @param {string | null | undefined} session
2637
2794
  * @param {Object} [options]
2638
2795
  * @param {boolean} [options.yolo]
2796
+ * @param {string | null} [options.allowedTools]
2639
2797
  * @returns {Promise<string>}
2640
2798
  */
2641
- async function cmdStart(agent, session, { yolo = false } = {}) {
2799
+ async function cmdStart(agent, session, { yolo = false, allowedTools = null } = {}) {
2642
2800
  // Generate session name if not provided
2643
2801
  if (!session) {
2644
- session = agent.generateSession();
2802
+ session = agent.generateSession({ allowedTools, yolo });
2645
2803
  }
2646
2804
 
2647
2805
  if (tmuxHasSession(session)) return session;
@@ -2653,7 +2811,7 @@ async function cmdStart(agent, session, { yolo = false } = {}) {
2653
2811
  process.exit(1);
2654
2812
  }
2655
2813
 
2656
- const command = agent.getCommand(yolo, session);
2814
+ const command = agent.getCommand(yolo, session, allowedTools);
2657
2815
  tmuxNewSession(session, command);
2658
2816
 
2659
2817
  const start = Date.now();
@@ -2666,6 +2824,18 @@ async function cmdStart(agent, session, { yolo = false } = {}) {
2666
2824
  continue;
2667
2825
  }
2668
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
+
2669
2839
  if (state === State.READY) return session;
2670
2840
 
2671
2841
  await sleep(POLL_MS);
@@ -3271,6 +3441,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs";
3271
3441
  import { dirname, join } from "node:path";
3272
3442
  import { fileURLToPath } from "node:url";
3273
3443
  import { createHash } from "node:crypto";
3444
+ import { execSync } from "node:child_process";
3274
3445
 
3275
3446
  const __dirname = dirname(fileURLToPath(import.meta.url));
3276
3447
  const AI_DIR = join(__dirname, "..");
@@ -3278,6 +3449,15 @@ const DEBUG = process.env.AX_DEBUG === "1";
3278
3449
  const MAILBOX = join(AI_DIR, "mailbox.jsonl");
3279
3450
  const MAX_AGE_MS = 60 * 60 * 1000;
3280
3451
 
3452
+ function getTmuxSessionName() {
3453
+ if (!process.env.TMUX) return null;
3454
+ try {
3455
+ return execSync("tmux display-message -p '#S'", { encoding: "utf-8" }).trim();
3456
+ } catch {
3457
+ return null;
3458
+ }
3459
+ }
3460
+
3281
3461
  // Read hook input from stdin
3282
3462
  let hookInput = {};
3283
3463
  try {
@@ -3292,8 +3472,9 @@ const hookEvent = hookInput.hook_event_name || "";
3292
3472
 
3293
3473
  if (DEBUG) console.error("[hook] session:", sessionId, "event:", hookEvent);
3294
3474
 
3295
- // NO-OP for archangel or partner sessions
3296
- if (sessionId.includes("-archangel-") || sessionId.includes("-partner-")) {
3475
+ const tmuxSession = getTmuxSessionName();
3476
+ if (DEBUG) console.error("[hook] tmux session:", tmuxSession);
3477
+ if (tmuxSession && (tmuxSession.includes("-archangel-") || tmuxSession.includes("-partner-"))) {
3297
3478
  if (DEBUG) console.error("[hook] skipping non-parent session");
3298
3479
  process.exit(0);
3299
3480
  }
@@ -3941,12 +4122,29 @@ async function cmdRfp(prompt, { archangels, fresh = false, noWait = false } = {}
3941
4122
 
3942
4123
  writeRfpRecord(rfpId, prompt);
3943
4124
  const archangelList = requested.join(",");
4125
+ const base = rfpId.split("-")[1];
4126
+ const shortId = `rfp-${base}`;
3944
4127
  console.log(`rfp: ${rfpId} (${archangelList})`);
3945
4128
  if (noWait) {
3946
- const cli = path.basename(process.argv[1], ".js");
3947
- const base = rfpId.split("-")[1];
3948
- const shortId = `rfp-${base}`;
3949
- console.log(`e.g.\n ${cli} rfp wait ${shortId} --archangels=${archangelList}`);
4129
+ // Truncate prompt for display (first line, max 60 chars)
4130
+ const firstLine = prompt.split("\n")[0];
4131
+ const taskPreview = firstLine.length > 60 ? firstLine.slice(0, 57) + "..." : firstLine;
4132
+
4133
+ let output = `Task: ${taskPreview}
4134
+
4135
+ Add this to your task list, then continue with your work.`;
4136
+
4137
+ // Show TaskCreate example if caller is Claude
4138
+ const caller = findCallerAgent();
4139
+ if (caller?.agent === "claude") {
4140
+ output += `
4141
+
4142
+ e.g.
4143
+ TaskCreate({ subject: "ax:${shortId} - ${taskPreview}" })
4144
+ `;
4145
+ }
4146
+
4147
+ console.log(output);
3950
4148
  }
3951
4149
  }
3952
4150
 
@@ -4058,13 +4256,13 @@ async function cmdRfpWait(rfpId, { archangels, timeoutMs = ARCHANGEL_RESPONSE_TI
4058
4256
  * @param {Agent} agent
4059
4257
  * @param {string | null | undefined} session
4060
4258
  * @param {string} message
4061
- * @param {{noWait?: boolean, yolo?: boolean, timeoutMs?: number}} [options]
4259
+ * @param {{noWait?: boolean, yolo?: boolean, allowedTools?: string | null, timeoutMs?: number}} [options]
4062
4260
  */
4063
4261
  async function cmdAsk(
4064
4262
  agent,
4065
4263
  session,
4066
4264
  message,
4067
- { noWait = false, yolo = false, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
4265
+ { noWait = false, yolo = false, allowedTools = null, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
4068
4266
  ) {
4069
4267
  const sessionExists = session != null && tmuxHasSession(session);
4070
4268
  const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
@@ -4079,7 +4277,7 @@ async function cmdAsk(
4079
4277
  /** @type {string} */
4080
4278
  const activeSession = sessionExists
4081
4279
  ? /** @type {string} */ (session)
4082
- : await cmdStart(agent, session, { yolo });
4280
+ : await cmdStart(agent, session, { yolo, allowedTools });
4083
4281
 
4084
4282
  tmuxSendLiteral(activeSession, message);
4085
4283
  await sleep(50);
@@ -4088,12 +4286,26 @@ async function cmdAsk(
4088
4286
  if (noWait) {
4089
4287
  const parsed = parseSessionName(activeSession);
4090
4288
  const shortId = parsed?.uuid?.slice(0, 8) || activeSession;
4091
- const cli = path.basename(process.argv[1], ".js");
4092
- console.log(`Sent to: ${shortId}
4289
+ // Truncate message for display (first line, max 60 chars)
4290
+ const firstLine = message.split("\n")[0];
4291
+ const taskPreview = firstLine.length > 60 ? firstLine.slice(0, 57) + "..." : firstLine;
4292
+
4293
+ let output = `Sent to: ${shortId}
4294
+ Task: ${taskPreview}
4295
+
4296
+ Add this to your task list, then continue with your work.`;
4297
+
4298
+ // Show TaskCreate example if caller is Claude
4299
+ const caller = findCallerAgent();
4300
+ if (caller?.agent === "claude") {
4301
+ output += `
4093
4302
 
4094
4303
  e.g.
4095
- ${cli} status --session=${shortId}
4096
- ${cli} output --session=${shortId}`);
4304
+ TaskCreate({ subject: "ax:${shortId} - ${taskPreview}" })
4305
+ `;
4306
+ }
4307
+
4308
+ console.log(output);
4097
4309
  return;
4098
4310
  }
4099
4311
 
@@ -4285,9 +4497,14 @@ async function cmdReview(
4285
4497
  * @param {Agent} agent
4286
4498
  * @param {string | null | undefined} session
4287
4499
  * @param {number} [index]
4288
- * @param {{wait?: boolean, timeoutMs?: number}} [options]
4500
+ * @param {{wait?: boolean, stale?: boolean, timeoutMs?: number}} [options]
4289
4501
  */
4290
- async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs } = {}) {
4502
+ async function cmdOutput(
4503
+ agent,
4504
+ session,
4505
+ index = 0,
4506
+ { wait = false, stale = false, timeoutMs } = {},
4507
+ ) {
4291
4508
  if (!session || !tmuxHasSession(session)) {
4292
4509
  console.log("ERROR: no session");
4293
4510
  process.exit(1);
@@ -4314,8 +4531,11 @@ async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs }
4314
4531
  }
4315
4532
 
4316
4533
  if (state === State.THINKING) {
4317
- console.log("THINKING");
4318
- process.exit(4);
4534
+ if (!stale) {
4535
+ console.log("THINKING: Use --wait to block, or --stale for old response.");
4536
+ process.exit(1);
4537
+ }
4538
+ // --stale: fall through to show previous response
4319
4539
  }
4320
4540
 
4321
4541
  const output = agent.getResponse(session, screen, index);
@@ -4513,7 +4733,12 @@ function resolveAgent({ toolFlag, sessionName } = {}) {
4513
4733
  if (invoked === "axclaude" || invoked === "claude") return { agent: ClaudeAgent };
4514
4734
  if (invoked === "axcodex" || invoked === "codex") return { agent: CodexAgent };
4515
4735
 
4516
- // 4. AX_DEFAULT_TOOL environment variable
4736
+ // 4. Infer from parent process (running from within claude/codex)
4737
+ const caller = findCallerAgent();
4738
+ if (caller?.agent === "claude") return { agent: ClaudeAgent };
4739
+ if (caller?.agent === "codex") return { agent: CodexAgent };
4740
+
4741
+ // 5. AX_DEFAULT_TOOL environment variable
4517
4742
  const defaultTool = process.env.AX_DEFAULT_TOOL;
4518
4743
  if (defaultTool === "claude") return { agent: ClaudeAgent };
4519
4744
  if (defaultTool === "codex" || !defaultTool) return { agent: CodexAgent };
@@ -4534,15 +4759,13 @@ function printHelp(agent, cliName) {
4534
4759
 
4535
4760
  Usage: ${name} [OPTIONS] <command|message> [ARGS...]
4536
4761
 
4537
- Messaging/State:
4762
+ Messaging:
4538
4763
  <message> Send message to ${name}
4539
4764
  review [TYPE] Review code: pr, uncommitted, commit, custom
4540
- status Exit code: ready=0 rate_limit=2 confirm=3 thinking=4
4541
- output [-N] Show response (0=last, -1=prev, -2=older)
4542
- compact Summarise session to shrink context size
4543
- reset Start fresh conversation
4544
4765
 
4545
4766
  Sessions:
4767
+ compact Summarise session to shrink context size
4768
+ reset Start fresh conversation
4546
4769
  agents List all running agents
4547
4770
  target Show default target session for current tool
4548
4771
  attach [SESSION] Attach to agent session interactively
@@ -4555,36 +4778,40 @@ Archangels:
4555
4778
  rfp <prompt> Request proposals (--archangels=a,b)
4556
4779
  rfp wait <id> Wait for proposals (--archangels=a,b)
4557
4780
 
4558
- Recovery:
4781
+ Recovery/State:
4782
+ status Exit code: ready=0 rate_limit=2 confirm=3 thinking=4
4783
+ output [-N] Show response (0=last, -1=prev, -2=older)
4559
4784
  debug Show raw screen output and detected state
4560
4785
  approve Approve pending action (send 'y')
4561
4786
  reject Reject pending action (send 'n')
4562
4787
  select N Select menu option N
4563
4788
  send KEYS Send key sequence (e.g. "1[Enter]", "[Escape]")
4564
- log SESSION View conversation log (--tail=N, --follow, --reasoning)
4789
+ log [SESSION] View conversation log (--tail=N, --follow, --reasoning)
4565
4790
 
4566
4791
  Flags:
4567
4792
  --tool=NAME Use specific agent (codex, claude)
4568
4793
  --session=ID name | archangel | uuid-prefix | self
4569
4794
  --fresh Reset conversation before review
4570
4795
  --yolo Skip all confirmations (dangerous)
4796
+ --auto-approve=TOOLS Auto-approve specific tools (e.g. 'Bash("cargo *")')
4571
4797
  --wait Wait for response (default for messages; required for approve/reject)
4572
4798
  --no-wait Fire-and-forget: send message, print session ID, exit immediately
4573
4799
  --timeout=N Set timeout in seconds (default: ${DEFAULT_TIMEOUT_MS / 1000}, reviews: ${REVIEW_TIMEOUT_MS / 1000})
4574
4800
 
4575
4801
  Examples:
4576
4802
  ${name} "explain this codebase"
4577
- ${name} "review the error handling" # Auto custom review (${REVIEW_TIMEOUT_MS / 60000}min timeout)
4578
- ${name} "FYI: auth was refactored" --no-wait # Send context to a working session (no response needed)
4803
+ ${name} "review the error handling" # Auto custom review (${REVIEW_TIMEOUT_MS / 60000}min timeout)
4804
+ ${name} "FYI: auth was refactored" --no-wait # Send context to a working session (no response needed)
4805
+ ${name} --auto-approve='Bash("cargo *")' "run tests" # Session with specific permissions
4579
4806
  ${name} review uncommitted --wait
4580
- ${name} kill # Kill agents in current project
4581
- ${name} kill --all # Kill all agents across all projects
4582
- ${name} kill --session=NAME # Kill specific session
4583
- ${name} summon # Summon all archangels from .ai/agents/*.md
4584
- ${name} summon reviewer # Summon by name (creates config if new)
4585
- ${name} recall # Recall all archangels
4586
- ${name} recall reviewer # Recall one by name
4587
- ${name} agents # List all agents (shows TYPE=archangel)
4807
+ ${name} kill # Kill agents in current project
4808
+ ${name} kill --all # Kill all agents across all projects
4809
+ ${name} kill --session=NAME # Kill specific session
4810
+ ${name} summon # Summon all archangels from .ai/agents/*.md
4811
+ ${name} summon reviewer # Summon by name (creates config if new)
4812
+ ${name} recall # Recall all archangels
4813
+ ${name} recall reviewer # Recall one by name
4814
+ ${name} agents # List all agents (shows TYPE=archangel)
4588
4815
 
4589
4816
  Note: Reviews and complex tasks may take several minutes.
4590
4817
  Use Bash run_in_background for long operations (not --no-wait).`);
@@ -4611,7 +4838,8 @@ async function main() {
4611
4838
  }
4612
4839
 
4613
4840
  // Extract flags into local variables for convenience
4614
- const { wait, noWait, yolo, fresh, reasoning, follow, all, orphans, force } = flags;
4841
+ const { wait, noWait, yolo, fresh, reasoning, follow, all, orphans, force, stale, autoApprove } =
4842
+ flags;
4615
4843
 
4616
4844
  // Session resolution (must happen before agent resolution so we can infer tool from session name)
4617
4845
  let session = null;
@@ -4639,9 +4867,9 @@ async function main() {
4639
4867
  process.exit(1);
4640
4868
  }
4641
4869
 
4642
- // If no explicit session, use agent's default
4870
+ // If no explicit session, use agent's default (with permission filtering)
4643
4871
  if (!session) {
4644
- session = agent.getDefaultSession();
4872
+ session = agent.getDefaultSession({ allowedTools: autoApprove, yolo });
4645
4873
  }
4646
4874
 
4647
4875
  // Timeout (convert seconds to milliseconds)
@@ -4669,7 +4897,7 @@ async function main() {
4669
4897
  // Dispatch commands
4670
4898
  if (cmd === "agents") return cmdAgents();
4671
4899
  if (cmd === "target") {
4672
- const defaultSession = agent.getDefaultSession();
4900
+ const defaultSession = agent.getDefaultSession({ allowedTools: autoApprove, yolo });
4673
4901
  if (defaultSession) {
4674
4902
  console.log(defaultSession);
4675
4903
  } else {
@@ -4717,7 +4945,7 @@ async function main() {
4717
4945
  if (cmd === "output") {
4718
4946
  const indexArg = positionals[1];
4719
4947
  const index = indexArg?.startsWith("-") ? parseInt(indexArg, 10) : 0;
4720
- return cmdOutput(agent, session, index, { wait, timeoutMs });
4948
+ return cmdOutput(agent, session, index, { wait, stale, timeoutMs });
4721
4949
  }
4722
4950
  if (cmd === "send" && positionals.length > 1)
4723
4951
  return cmdSend(session, positionals.slice(1).join(" "));
@@ -4747,7 +4975,12 @@ async function main() {
4747
4975
  });
4748
4976
  }
4749
4977
 
4750
- return cmdAsk(agent, session, messageText, { noWait, yolo, timeoutMs });
4978
+ return cmdAsk(agent, session, messageText, {
4979
+ noWait,
4980
+ yolo,
4981
+ allowedTools: autoApprove,
4982
+ timeoutMs,
4983
+ });
4751
4984
  }
4752
4985
 
4753
4986
  // Run main() only when executed directly (not when imported for testing)
@@ -4787,4 +5020,6 @@ export {
4787
5020
  extractThinking,
4788
5021
  detectState,
4789
5022
  State,
5023
+ normalizeAllowedTools,
5024
+ computePermissionHash,
4790
5025
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ax-agents",
3
- "version": "0.0.1-alpha.12",
3
+ "version": "0.0.1-alpha.13",
4
4
  "description": "A CLI for orchestrating AI coding agents via tmux",
5
5
  "bin": {
6
6
  "ax": "ax.js",