ax-agents 0.1.1 → 0.1.3

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 +336 -55
  2. package/package.json +1 -1
package/ax.js CHANGED
@@ -144,6 +144,15 @@ function debugError(context, err) {
144
144
  if (DEBUG) console.error(`[debug:${context}]`, err instanceof Error ? err.message : err);
145
145
  }
146
146
 
147
+ /**
148
+ * Log debug message when AX_DEBUG=1
149
+ * @param {string} tag - Short tag for the debug message (e.g., "poll", "tmux")
150
+ * @param {string} message - The debug message
151
+ */
152
+ function debug(tag, message) {
153
+ if (DEBUG) console.error(`[${tag}] ${message}`);
154
+ }
155
+
147
156
  // =============================================================================
148
157
  // Project root detection (walk up to find .ai/ directory)
149
158
  // =============================================================================
@@ -214,6 +223,7 @@ function tmuxCapture(session, scrollback = 0) {
214
223
  * @param {string} keys
215
224
  */
216
225
  function tmuxSend(session, keys) {
226
+ debug("tmux", `send session=${session}, keys=${keys}`);
217
227
  tmux(["send-keys", "-t", session, keys]);
218
228
  }
219
229
 
@@ -222,6 +232,7 @@ function tmuxSend(session, keys) {
222
232
  * @param {string} text
223
233
  */
224
234
  function tmuxSendLiteral(session, text) {
235
+ debug("tmux", `sendLiteral session=${session}, text=${text.slice(0, 50)}...`);
225
236
  tmux(["send-keys", "-t", session, "-l", text]);
226
237
  }
227
238
 
@@ -241,11 +252,15 @@ function tmuxKill(session) {
241
252
  * @param {string} command
242
253
  */
243
254
  function tmuxNewSession(session, command) {
255
+ debug("tmux", `newSession: ${session}, command: ${command.slice(0, 80)}...`);
244
256
  // Use spawnSync to avoid command injection via session/command
245
257
  const result = spawnSync("tmux", ["new-session", "-d", "-s", session, command], {
246
258
  encoding: "utf-8",
247
259
  });
248
- if (result.status !== 0) throw new Error(result.stderr || "tmux new-session failed");
260
+ if (result.status !== 0) {
261
+ debug("tmux", `newSession failed: ${result.stderr}`);
262
+ throw new Error(result.stderr || "tmux new-session failed");
263
+ }
249
264
  }
250
265
 
251
266
  /**
@@ -404,11 +419,16 @@ class TimeoutError extends Error {
404
419
  */
405
420
  async function waitFor(session, predicate, timeoutMs = STARTUP_TIMEOUT_MS) {
406
421
  const start = Date.now();
422
+ debug("waitFor", `waiting (timeout=${timeoutMs}ms)`);
407
423
  while (Date.now() - start < timeoutMs) {
408
424
  const screen = tmuxCapture(session);
409
- if (predicate(screen)) return screen;
425
+ if (predicate(screen)) {
426
+ debug("waitFor", `matched after ${Date.now() - start}ms`);
427
+ return screen;
428
+ }
410
429
  await sleep(POLL_MS);
411
430
  }
431
+ debug("waitFor", `timeout after ${timeoutMs}ms`);
412
432
  throw new TimeoutError(session);
413
433
  }
414
434
 
@@ -711,6 +731,7 @@ function findClaudeLogPath(sessionId, sessionName) {
711
731
  const cwd = (sessionName && getTmuxSessionCwd(sessionName)) || process.cwd();
712
732
  const projectPath = getClaudeProjectPath(cwd);
713
733
  const claudeProjectDir = path.join(CLAUDE_CONFIG_DIR, "projects", projectPath);
734
+ debug("log", `findClaudeLogPath: sessionId=${sessionId}, projectDir=${claudeProjectDir}`);
714
735
 
715
736
  // Check sessions-index.json first
716
737
  const indexPath = path.join(claudeProjectDir, "sessions-index.json");
@@ -720,7 +741,10 @@ function findClaudeLogPath(sessionId, sessionName) {
720
741
  const entry = index.entries?.find(
721
742
  /** @param {{sessionId: string, fullPath?: string}} e */ (e) => e.sessionId === sessionId,
722
743
  );
723
- if (entry?.fullPath) return entry.fullPath;
744
+ if (entry?.fullPath) {
745
+ debug("log", `findClaudeLogPath: found via index -> ${entry.fullPath}`);
746
+ return entry.fullPath;
747
+ }
724
748
  } catch (err) {
725
749
  debugError("findClaudeLogPath", err);
726
750
  }
@@ -728,8 +752,12 @@ function findClaudeLogPath(sessionId, sessionName) {
728
752
 
729
753
  // Fallback: direct path
730
754
  const directPath = path.join(claudeProjectDir, `${sessionId}.jsonl`);
731
- if (existsSync(directPath)) return directPath;
755
+ if (existsSync(directPath)) {
756
+ debug("log", `findClaudeLogPath: found via direct path -> ${directPath}`);
757
+ return directPath;
758
+ }
732
759
 
760
+ debug("log", `findClaudeLogPath: not found`);
733
761
  return null;
734
762
  }
735
763
 
@@ -738,6 +766,7 @@ function findClaudeLogPath(sessionId, sessionName) {
738
766
  * @returns {string | null}
739
767
  */
740
768
  function findCodexLogPath(sessionName) {
769
+ debug("log", `findCodexLogPath: sessionName=${sessionName}`);
741
770
  // For Codex, we need to match by timing since we can't control the session ID
742
771
  // Get tmux session creation time
743
772
  try {
@@ -748,13 +777,22 @@ function findCodexLogPath(sessionName) {
748
777
  encoding: "utf-8",
749
778
  },
750
779
  );
751
- if (result.status !== 0) return null;
780
+ if (result.status !== 0) {
781
+ debug("log", `findCodexLogPath: tmux display-message failed`);
782
+ return null;
783
+ }
752
784
  const createdTs = parseInt(result.stdout.trim(), 10) * 1000; // tmux gives seconds, we need ms
753
- if (isNaN(createdTs)) return null;
785
+ if (isNaN(createdTs)) {
786
+ debug("log", `findCodexLogPath: invalid timestamp`);
787
+ return null;
788
+ }
754
789
 
755
790
  // Codex stores sessions in ~/.codex/sessions/YYYY/MM/DD/rollout-TIMESTAMP-UUID.jsonl
756
791
  const sessionsDir = path.join(CODEX_CONFIG_DIR, "sessions");
757
- if (!existsSync(sessionsDir)) return null;
792
+ if (!existsSync(sessionsDir)) {
793
+ debug("log", `findCodexLogPath: sessions dir not found`);
794
+ return null;
795
+ }
758
796
 
759
797
  const startDate = new Date(createdTs);
760
798
  const year = startDate.getFullYear().toString();
@@ -762,11 +800,15 @@ function findCodexLogPath(sessionName) {
762
800
  const day = String(startDate.getDate()).padStart(2, "0");
763
801
 
764
802
  const dayDir = path.join(sessionsDir, year, month, day);
765
- if (!existsSync(dayDir)) return null;
803
+ if (!existsSync(dayDir)) {
804
+ debug("log", `findCodexLogPath: day dir not found: ${dayDir}`);
805
+ return null;
806
+ }
766
807
 
767
808
  // Find the closest log file created after the tmux session started
768
809
  // Use 60-second window to handle slow startups (model download, first run, heavy load)
769
810
  const files = readdirSync(dayDir).filter((f) => f.endsWith(".jsonl"));
811
+ debug("log", `findCodexLogPath: ${files.length} jsonl files in ${dayDir}`);
770
812
  const candidates = [];
771
813
 
772
814
  for (const file of files) {
@@ -789,11 +831,16 @@ function findCodexLogPath(sessionName) {
789
831
  }
790
832
  }
791
833
 
792
- if (candidates.length === 0) return null;
834
+ if (candidates.length === 0) {
835
+ debug("log", `findCodexLogPath: no candidates within time window`);
836
+ return null;
837
+ }
793
838
  // Return the closest match
794
839
  candidates.sort((a, b) => a.diff - b.diff);
840
+ debug("log", `findCodexLogPath: found ${candidates.length} candidates, best: ${candidates[0].path}`);
795
841
  return candidates[0].path;
796
842
  } catch {
843
+ debug("log", `findCodexLogPath: exception caught`);
797
844
  return null;
798
845
  }
799
846
  }
@@ -977,11 +1024,12 @@ function tailJsonl(logPath, fromOffset) {
977
1024
  */
978
1025
 
979
1026
  /**
980
- * Format a JSONL entry for streaming display.
1027
+ * Format a Claude Code JSONL log entry for streaming display.
1028
+ * Claude format: {type: "assistant", message: {content: [...]}}
981
1029
  * @param {{type?: string, message?: {content?: Array<{type?: string, text?: string, name?: string, input?: ToolInput, tool?: string, arguments?: ToolInput}>}}} entry
982
1030
  * @returns {string | null}
983
1031
  */
984
- function formatEntry(entry) {
1032
+ function formatClaudeLogEntry(entry) {
985
1033
  // Skip tool_result entries (they can be very verbose)
986
1034
  if (entry.type === "tool_result") return null;
987
1035
 
@@ -994,6 +1042,9 @@ function formatEntry(entry) {
994
1042
  for (const part of parts) {
995
1043
  if (part.type === "text" && part.text) {
996
1044
  output.push(part.text);
1045
+ } else if (part.type === "thinking" && part.thinking) {
1046
+ // Include thinking blocks - extended thinking models put responses here
1047
+ output.push(part.thinking);
997
1048
  } else if (part.type === "tool_use" || part.type === "tool_call") {
998
1049
  const name = part.name || part.tool || "tool";
999
1050
  const input = part.input || part.arguments || {};
@@ -1006,12 +1057,69 @@ function formatEntry(entry) {
1006
1057
  }
1007
1058
  output.push(`> ${name}(${summary})`);
1008
1059
  }
1009
- // Skip thinking blocks - internal reasoning
1010
1060
  }
1011
1061
 
1012
1062
  return output.length > 0 ? output.join("\n") : null;
1013
1063
  }
1014
1064
 
1065
+ /**
1066
+ * Format a Codex JSONL log entry for streaming display.
1067
+ * Codex format:
1068
+ * - {type: "response_item", payload: {type: "message", role: "assistant", content: [{type: "output_text", text: "..."}]}}
1069
+ * - {type: "response_item", payload: {type: "function_call", name: "...", arguments: "{...}"}}
1070
+ * - {type: "event_msg", payload: {type: "agent_message", message: "..."}}
1071
+ * @param {{type?: string, payload?: {type?: string, role?: string, name?: string, arguments?: string, message?: string, content?: Array<{type?: string, text?: string}>}}} entry
1072
+ * @returns {string | null}
1073
+ */
1074
+ function formatCodexLogEntry(entry) {
1075
+ // Skip function_call_output entries (equivalent to tool_result - can be verbose)
1076
+ if (entry.type === "response_item" && entry.payload?.type === "function_call_output") {
1077
+ return null;
1078
+ }
1079
+
1080
+ // Handle function calls
1081
+ if (entry.type === "response_item" && entry.payload?.type === "function_call") {
1082
+ const name = entry.payload.name || "tool";
1083
+ let summary = "";
1084
+ try {
1085
+ const args = JSON.parse(entry.payload.arguments || "{}");
1086
+ if (name === "shell_command" && args.command) {
1087
+ summary = args.command.slice(0, 50);
1088
+ } else {
1089
+ const target = args.file_path || args.path || args.pattern || "";
1090
+ summary = target.split("/").pop() || target.slice(0, 30);
1091
+ }
1092
+ } catch {
1093
+ summary = "...";
1094
+ }
1095
+ return `> ${name}(${summary})`;
1096
+ }
1097
+
1098
+ // Handle assistant messages (final response)
1099
+ if (entry.type === "response_item" && entry.payload?.role === "assistant") {
1100
+ const parts = entry.payload.content || [];
1101
+ const output = [];
1102
+ for (const part of parts) {
1103
+ if ((part.type === "output_text" || part.type === "text") && part.text) {
1104
+ output.push(part.text);
1105
+ }
1106
+ }
1107
+ return output.length > 0 ? output.join("\n") : null;
1108
+ }
1109
+
1110
+ // Handle streaming agent messages
1111
+ if (entry.type === "event_msg" && entry.payload?.type === "agent_message") {
1112
+ return entry.payload.message || null;
1113
+ }
1114
+
1115
+ // Handle agent reasoning (thinking during review)
1116
+ if (entry.type === "event_msg" && entry.payload?.type === "agent_reasoning") {
1117
+ return entry.payload.text || null;
1118
+ }
1119
+
1120
+ return null;
1121
+ }
1122
+
1015
1123
  /**
1016
1124
  * Extract pending tool from confirmation screen.
1017
1125
  * @param {string} screen
@@ -1075,16 +1183,23 @@ function resolveSessionName(partial) {
1075
1183
 
1076
1184
  const sessions = tmuxListSessions();
1077
1185
  const agentSessions = sessions.filter((s) => parseSessionName(s));
1186
+ debug("session", `resolving "${partial}" from ${agentSessions.length} agent sessions`);
1078
1187
 
1079
1188
  // Exact match
1080
- if (agentSessions.includes(partial)) return partial;
1189
+ if (agentSessions.includes(partial)) {
1190
+ debug("session", `exact match: ${partial}`);
1191
+ return partial;
1192
+ }
1081
1193
 
1082
1194
  // Archangel name match (e.g., "reviewer" matches "claude-archangel-reviewer-uuid")
1083
1195
  const archangelMatches = agentSessions.filter((s) => {
1084
1196
  const parsed = parseSessionName(s);
1085
1197
  return parsed?.archangelName === partial;
1086
1198
  });
1087
- if (archangelMatches.length === 1) return archangelMatches[0];
1199
+ if (archangelMatches.length === 1) {
1200
+ debug("session", `archangel match: ${archangelMatches[0]}`);
1201
+ return archangelMatches[0];
1202
+ }
1088
1203
  if (archangelMatches.length > 1) {
1089
1204
  console.log("ERROR: ambiguous archangel name. Matches:");
1090
1205
  for (const m of archangelMatches) console.log(` ${m}`);
@@ -1093,7 +1208,10 @@ function resolveSessionName(partial) {
1093
1208
 
1094
1209
  // Prefix match
1095
1210
  const matches = agentSessions.filter((s) => s.startsWith(partial));
1096
- if (matches.length === 1) return matches[0];
1211
+ if (matches.length === 1) {
1212
+ debug("session", `prefix match: ${matches[0]}`);
1213
+ return matches[0];
1214
+ }
1097
1215
  if (matches.length > 1) {
1098
1216
  console.log("ERROR: ambiguous session prefix. Matches:");
1099
1217
  for (const m of matches) console.log(` ${m}`);
@@ -1105,13 +1223,17 @@ function resolveSessionName(partial) {
1105
1223
  const parsed = parseSessionName(s);
1106
1224
  return parsed?.uuid?.startsWith(partial);
1107
1225
  });
1108
- if (uuidMatches.length === 1) return uuidMatches[0];
1226
+ if (uuidMatches.length === 1) {
1227
+ debug("session", `UUID match: ${uuidMatches[0]}`);
1228
+ return uuidMatches[0];
1229
+ }
1109
1230
  if (uuidMatches.length > 1) {
1110
1231
  console.log("ERROR: ambiguous UUID prefix. Matches:");
1111
1232
  for (const m of uuidMatches) console.log(` ${m}`);
1112
1233
  process.exit(1);
1113
1234
  }
1114
1235
 
1236
+ debug("session", `no match found, returning as-is: ${partial}`);
1115
1237
  return partial; // Return as-is, let caller handle not found
1116
1238
  }
1117
1239
 
@@ -1991,7 +2113,10 @@ const State = {
1991
2113
  * @returns {string} The detected state
1992
2114
  */
1993
2115
  function detectState(screen, config) {
1994
- if (!screen) return State.STARTING;
2116
+ if (!screen) {
2117
+ debug("state", "no screen -> STARTING");
2118
+ return State.STARTING;
2119
+ }
1995
2120
 
1996
2121
  const lines = screen.trim().split("\n");
1997
2122
  const lastLines = lines.slice(-8).join("\n");
@@ -2000,6 +2125,7 @@ function detectState(screen, config) {
2000
2125
 
2001
2126
  // Rate limited - check recent lines (not full screen to avoid matching historical output)
2002
2127
  if (config.rateLimitPattern && config.rateLimitPattern.test(recentLines)) {
2128
+ debug("state", "rateLimitPattern matched -> RATE_LIMITED");
2003
2129
  return State.RATE_LIMITED;
2004
2130
  }
2005
2131
 
@@ -2011,6 +2137,7 @@ function detectState(screen, config) {
2011
2137
  /3:\s*Good/i.test(recentLines) &&
2012
2138
  /0:\s*Dismiss/i.test(recentLines)
2013
2139
  ) {
2140
+ debug("state", "feedback modal detected -> FEEDBACK_MODAL");
2014
2141
  return State.FEEDBACK_MODAL;
2015
2142
  }
2016
2143
 
@@ -2019,54 +2146,71 @@ function detectState(screen, config) {
2019
2146
  for (const pattern of confirmPatterns) {
2020
2147
  if (typeof pattern === "function") {
2021
2148
  // Functions check lastLines first (most specific), then recentLines
2022
- if (pattern(lastLines)) return State.CONFIRMING;
2023
- if (pattern(recentLines)) return State.CONFIRMING;
2149
+ if (pattern(lastLines)) {
2150
+ debug("state", "confirmPattern function matched lastLines -> CONFIRMING");
2151
+ return State.CONFIRMING;
2152
+ }
2153
+ if (pattern(recentLines)) {
2154
+ debug("state", "confirmPattern function matched recentLines -> CONFIRMING");
2155
+ return State.CONFIRMING;
2156
+ }
2024
2157
  } else {
2025
2158
  // String patterns check recentLines (bounded range)
2026
- if (recentLines.includes(pattern)) return State.CONFIRMING;
2159
+ if (recentLines.includes(pattern)) {
2160
+ debug("state", `confirmPattern "${pattern}" matched -> CONFIRMING`);
2161
+ return State.CONFIRMING;
2162
+ }
2163
+ }
2164
+ }
2165
+
2166
+ // Check for active work patterns first (agent shows prompt even while working)
2167
+ const activeWorkPatterns = config.activeWorkPatterns || [];
2168
+ for (const p of activeWorkPatterns) {
2169
+ const matched = p instanceof RegExp ? p.test(lastLines) : lastLines.includes(p);
2170
+ if (matched) {
2171
+ debug("state", `activeWorkPattern "${p}" matched -> THINKING`);
2172
+ return State.THINKING;
2027
2173
  }
2028
2174
  }
2029
2175
 
2030
2176
  // Ready - check BEFORE thinking to avoid false positives from timing messages like "✻ Worked for 45s"
2031
2177
  // If the prompt symbol is visible, the agent is ready regardless of spinner characters in timing messages
2032
2178
  if (lastLines.includes(config.promptSymbol)) {
2033
- // Check if any line has the prompt followed by pasted content indicator
2034
- // "[Pasted text" indicates user has pasted content and Claude is still processing
2035
- const linesArray = lastLines.split("\n");
2036
- const promptWithPaste = linesArray.some(
2037
- (l) => l.includes(config.promptSymbol) && l.includes("[Pasted text"),
2038
- );
2039
- if (!promptWithPaste) {
2040
- return State.READY;
2041
- }
2042
- // If prompt has pasted content, Claude is still processing - not ready yet
2179
+ debug("state", `promptSymbol "${config.promptSymbol}" found -> READY`);
2180
+ return State.READY;
2043
2181
  }
2044
2182
 
2045
2183
  // Thinking - spinners (check last lines only)
2046
2184
  const spinners = config.spinners || [];
2047
- if (spinners.some((s) => lastLines.includes(s))) {
2048
- return State.THINKING;
2185
+ for (const s of spinners) {
2186
+ if (lastLines.includes(s)) {
2187
+ debug("state", `spinner "${s}" matched -> THINKING`);
2188
+ return State.THINKING;
2189
+ }
2049
2190
  }
2050
2191
  // Thinking - text patterns (last lines) - supports strings, regexes, and functions
2051
2192
  const thinkingPatterns = config.thinkingPatterns || [];
2052
- if (
2053
- thinkingPatterns.some((p) => {
2054
- if (typeof p === "function") return p(lastLines);
2055
- if (p instanceof RegExp) return p.test(lastLines);
2056
- return lastLines.includes(p);
2057
- })
2058
- ) {
2059
- return State.THINKING;
2193
+ for (const p of thinkingPatterns) {
2194
+ let matched = false;
2195
+ if (typeof p === "function") matched = p(lastLines);
2196
+ else if (p instanceof RegExp) matched = p.test(lastLines);
2197
+ else matched = lastLines.includes(p);
2198
+ if (matched) {
2199
+ debug("state", `thinkingPattern "${p}" matched -> THINKING`);
2200
+ return State.THINKING;
2201
+ }
2060
2202
  }
2061
2203
 
2062
2204
  // Update prompt
2063
2205
  if (config.updatePromptPatterns) {
2064
2206
  const { screen: sp, lastLines: lp } = config.updatePromptPatterns;
2065
2207
  if (sp && sp.some((p) => screen.includes(p)) && lp && lp.some((p) => lastLines.includes(p))) {
2208
+ debug("state", "updatePromptPatterns matched -> UPDATE_PROMPT");
2066
2209
  return State.UPDATE_PROMPT;
2067
2210
  }
2068
2211
  }
2069
2212
 
2213
+ debug("state", "no patterns matched -> STARTING");
2070
2214
  return State.STARTING;
2071
2215
  }
2072
2216
 
@@ -2094,6 +2238,7 @@ function detectState(screen, config) {
2094
2238
  * @property {string[]} [spinners]
2095
2239
  * @property {RegExp} [rateLimitPattern]
2096
2240
  * @property {(string | RegExp | ((lines: string) => boolean))[]} [thinkingPatterns]
2241
+ * @property {(string | RegExp)[]} [activeWorkPatterns]
2097
2242
  * @property {ConfirmPattern[]} [confirmPatterns]
2098
2243
  * @property {UpdatePromptPatterns | null} [updatePromptPatterns]
2099
2244
  * @property {string[]} [responseMarkers]
@@ -2105,6 +2250,7 @@ function detectState(screen, config) {
2105
2250
  * @property {string} [safeAllowedTools]
2106
2251
  * @property {string | null} [sessionIdFlag]
2107
2252
  * @property {((sessionName: string) => string | null) | null} [logPathFinder]
2253
+ * @property {((entry: object) => string | null) | null} [logEntryFormatter]
2108
2254
  */
2109
2255
 
2110
2256
  class Agent {
@@ -2128,6 +2274,8 @@ class Agent {
2128
2274
  this.rateLimitPattern = config.rateLimitPattern;
2129
2275
  /** @type {(string | RegExp | ((lines: string) => boolean))[]} */
2130
2276
  this.thinkingPatterns = config.thinkingPatterns || [];
2277
+ /** @type {(string | RegExp)[]} */
2278
+ this.activeWorkPatterns = config.activeWorkPatterns || [];
2131
2279
  /** @type {ConfirmPattern[]} */
2132
2280
  this.confirmPatterns = config.confirmPatterns || [];
2133
2281
  /** @type {UpdatePromptPatterns | null} */
@@ -2150,6 +2298,8 @@ class Agent {
2150
2298
  this.sessionIdFlag = config.sessionIdFlag || null;
2151
2299
  /** @type {((sessionName: string) => string | null) | null} */
2152
2300
  this.logPathFinder = config.logPathFinder || null;
2301
+ /** @type {((entry: object) => string | null) | null} */
2302
+ this.logEntryFormatter = config.logEntryFormatter || null;
2153
2303
  }
2154
2304
 
2155
2305
  /**
@@ -2162,24 +2312,31 @@ class Agent {
2162
2312
  let base;
2163
2313
  if (yolo) {
2164
2314
  base = this.yoloCommand;
2315
+ debug("command", `mode=yolo`);
2165
2316
  } else if (customAllowedTools) {
2166
2317
  // Custom permissions from --auto-approve flag
2167
2318
  // Escape for shell: backslashes first, then double quotes
2168
2319
  const escaped = customAllowedTools.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2169
2320
  base = `${this.startCommand} --allowedTools "${escaped}"`;
2321
+ debug("command", `mode=custom, allowedTools=${customAllowedTools}`);
2170
2322
  } else if (this.safeAllowedTools) {
2171
2323
  // Default: auto-approve safe read-only operations
2172
2324
  base = `${this.startCommand} --allowedTools "${this.safeAllowedTools}"`;
2325
+ debug("command", `mode=safe, allowedTools=${this.safeAllowedTools}`);
2173
2326
  } else {
2174
2327
  base = this.startCommand;
2328
+ debug("command", `mode=default`);
2175
2329
  }
2176
2330
  // Some agents support session ID flags for deterministic session tracking
2177
2331
  if (this.sessionIdFlag && sessionName) {
2178
2332
  const parsed = parseSessionName(sessionName);
2179
2333
  if (parsed?.uuid) {
2180
- return `${base} ${this.sessionIdFlag} ${parsed.uuid}`;
2334
+ const cmd = `${base} ${this.sessionIdFlag} ${parsed.uuid}`;
2335
+ debug("command", `full: ${cmd}`);
2336
+ return cmd;
2181
2337
  }
2182
2338
  }
2339
+ debug("command", `full: ${base}`);
2183
2340
  return base;
2184
2341
  }
2185
2342
 
@@ -2289,6 +2446,18 @@ class Agent {
2289
2446
  return null;
2290
2447
  }
2291
2448
 
2449
+ /**
2450
+ * Format a log entry for streaming display.
2451
+ * @param {object} entry
2452
+ * @returns {string | null}
2453
+ */
2454
+ formatLogEntry(entry) {
2455
+ if (this.logEntryFormatter) {
2456
+ return this.logEntryFormatter(entry);
2457
+ }
2458
+ return null;
2459
+ }
2460
+
2292
2461
  /**
2293
2462
  * @param {string} screen
2294
2463
  * @returns {string}
@@ -2299,6 +2468,7 @@ class Agent {
2299
2468
  spinners: this.spinners,
2300
2469
  rateLimitPattern: this.rateLimitPattern,
2301
2470
  thinkingPatterns: this.thinkingPatterns,
2471
+ activeWorkPatterns: this.activeWorkPatterns,
2302
2472
  confirmPatterns: this.confirmPatterns,
2303
2473
  updatePromptPatterns: this.updatePromptPatterns,
2304
2474
  });
@@ -2528,11 +2698,13 @@ const CodexAgent = new Agent({
2528
2698
  screen: ["Update available"],
2529
2699
  lastLines: ["Skip"],
2530
2700
  },
2701
+ activeWorkPatterns: ["esc to interrupt"],
2531
2702
  responseMarkers: ["•", "- ", "**"],
2532
2703
  chromePatterns: ["context left", "for shortcuts"],
2533
- reviewOptions: { pr: "1", uncommitted: "2", commit: "3", custom: "4" },
2704
+ reviewOptions: { branch: "1", uncommitted: "2", commit: "3", custom: "4" },
2534
2705
  envVar: "AX_SESSION",
2535
2706
  logPathFinder: findCodexLogPath,
2707
+ logEntryFormatter: formatCodexLogEntry,
2536
2708
  });
2537
2709
 
2538
2710
  // =============================================================================
@@ -2550,6 +2722,7 @@ const ClaudeAgent = new Agent({
2550
2722
  rateLimitPattern: /rate.?limit/i,
2551
2723
  // Claude uses whimsical verbs like "Wibbling…", "Dancing…", etc. Match any capitalized -ing word + ellipsis (… or ...)
2552
2724
  thinkingPatterns: ["Thinking", /[A-Z][a-z]+ing(…|\.\.\.)/],
2725
+ activeWorkPatterns: ["[Pasted text", "esc to interrupt"],
2553
2726
  confirmPatterns: [
2554
2727
  "Do you want to make this edit",
2555
2728
  "Do you want to run this command",
@@ -2581,6 +2754,7 @@ const ClaudeAgent = new Agent({
2581
2754
  if (uuid) return findClaudeLogPath(uuid, sessionName);
2582
2755
  return null;
2583
2756
  },
2757
+ logEntryFormatter: formatClaudeLogEntry,
2584
2758
  });
2585
2759
 
2586
2760
  // =============================================================================
@@ -2599,9 +2773,11 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2599
2773
  const start = Date.now();
2600
2774
  const initialScreen = tmuxCapture(session);
2601
2775
  const initialState = agent.getState(initialScreen);
2776
+ debug("waitUntilReady", `start: initialState=${initialState}, timeout=${timeoutMs}ms`);
2602
2777
 
2603
2778
  // Dismiss feedback modal if present
2604
2779
  if (initialState === State.FEEDBACK_MODAL) {
2780
+ debug("waitUntilReady", `dismissing feedback modal`);
2605
2781
  tmuxSend(session, "0");
2606
2782
  await sleep(200);
2607
2783
  } else if (
@@ -2610,6 +2786,7 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2610
2786
  initialState === State.CONFIRMING ||
2611
2787
  initialState === State.READY
2612
2788
  ) {
2789
+ debug("waitUntilReady", `already in terminal state: ${initialState}`);
2613
2790
  return { state: initialState, screen: initialScreen };
2614
2791
  }
2615
2792
 
@@ -2620,15 +2797,18 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2620
2797
 
2621
2798
  // Dismiss feedback modal if it appears
2622
2799
  if (state === State.FEEDBACK_MODAL) {
2800
+ debug("waitUntilReady", `dismissing feedback modal`);
2623
2801
  tmuxSend(session, "0");
2624
2802
  await sleep(200);
2625
2803
  continue;
2626
2804
  }
2627
2805
 
2628
2806
  if (state === State.RATE_LIMITED || state === State.CONFIRMING || state === State.READY) {
2807
+ debug("waitUntilReady", `reached state=${state} after ${Date.now() - start}ms`);
2629
2808
  return { state, screen };
2630
2809
  }
2631
2810
  }
2811
+ debug("waitUntilReady", `timeout after ${timeoutMs}ms`);
2632
2812
  throw new TimeoutError(session);
2633
2813
  }
2634
2814
 
@@ -2644,11 +2824,19 @@ async function pollForResponse(agent, session, timeoutMs, hooks = {}) {
2644
2824
  const { onPoll, onStateChange, onReady } = hooks;
2645
2825
  const start = Date.now();
2646
2826
  const initialScreen = tmuxCapture(session);
2827
+ const initialState = agent.getState(initialScreen);
2828
+ debug("poll", `start: initialState=${initialState}, timeoutMs=${timeoutMs}`);
2647
2829
 
2648
2830
  let lastScreen = initialScreen;
2649
2831
  let lastState = null;
2650
2832
  let stableAt = null;
2651
2833
  let sawActivity = false;
2834
+ let sawThinking = false;
2835
+
2836
+ // Fallback timeout: accept READY without sawThinking after this many ms
2837
+ // This handles fast responses where we might miss the THINKING state
2838
+ // Clamp to timeoutMs so short timeouts don't always fail
2839
+ const THINKING_FALLBACK_MS = Math.min(10000, timeoutMs);
2652
2840
 
2653
2841
  while (Date.now() - start < timeoutMs) {
2654
2842
  const screen = tmuxCapture(session);
@@ -2676,19 +2864,28 @@ async function pollForResponse(agent, session, timeoutMs, hooks = {}) {
2676
2864
  lastScreen = screen;
2677
2865
  stableAt = Date.now();
2678
2866
  if (screen !== initialScreen) {
2867
+ if (!sawActivity) debug("poll", "sawActivity=true (screen changed from initial)");
2679
2868
  sawActivity = true;
2680
2869
  }
2681
2870
  }
2682
2871
 
2872
+ // Check if we can return READY
2683
2873
  if (sawActivity && stableAt && Date.now() - stableAt >= STABLE_MS) {
2684
2874
  if (state === State.READY) {
2685
- if (onReady) onReady(screen);
2686
- return { state, screen };
2875
+ // Require sawThinking OR enough time has passed (fallback for fast responses)
2876
+ const elapsed = Date.now() - start;
2877
+ if (sawThinking || elapsed >= THINKING_FALLBACK_MS) {
2878
+ debug("poll", `returning READY after ${elapsed}ms (sawThinking=${sawThinking})`);
2879
+ if (onReady) onReady(screen);
2880
+ return { state, screen };
2881
+ }
2687
2882
  }
2688
2883
  }
2689
2884
 
2690
2885
  if (state === State.THINKING) {
2691
2886
  sawActivity = true;
2887
+ if (!sawThinking) debug("poll", "sawThinking=true");
2888
+ sawThinking = true;
2692
2889
  }
2693
2890
 
2694
2891
  await sleep(POLL_MS);
@@ -2718,20 +2915,43 @@ async function streamResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2718
2915
  let logPath = agent.findLogPath(session);
2719
2916
  let logOffset = logPath && existsSync(logPath) ? statSync(logPath).size : 0;
2720
2917
  let printedThinking = false;
2918
+ debug("stream", `start: logPath=${logPath || "null"}, logOffset=${logOffset}`);
2919
+ // Sliding window for deduplication - only dedupe recent messages
2920
+ // This catches Codex's duplicate log entries (A,B,A,B pattern) while
2921
+ // allowing legitimate repeated messages across turns
2922
+ /** @type {string[]} */
2923
+ const recentMessages = [];
2924
+ const DEDUPE_WINDOW = 10;
2721
2925
 
2722
2926
  const streamNewEntries = () => {
2723
2927
  if (!logPath) {
2724
2928
  logPath = agent.findLogPath(session);
2725
2929
  if (logPath && existsSync(logPath)) {
2726
- logOffset = statSync(logPath).size;
2930
+ // Read from beginning when file is first discovered
2931
+ // (Claude creates log file when first message is sent)
2932
+ debug("stream", `log file discovered: ${logPath}`);
2933
+ logOffset = 0;
2727
2934
  }
2728
2935
  }
2729
2936
  if (logPath) {
2730
2937
  const { entries, newOffset } = tailJsonl(logPath, logOffset);
2938
+ if (entries.length > 0) {
2939
+ debug("stream", `read ${entries.length} entries, offset ${logOffset} -> ${newOffset}`);
2940
+ }
2731
2941
  logOffset = newOffset;
2732
2942
  for (const entry of entries) {
2733
- const formatted = formatEntry(entry);
2734
- if (formatted) console.log(formatted);
2943
+ const formatted = agent.formatLogEntry(entry);
2944
+ if (!formatted) continue;
2945
+
2946
+ // Dedupe messages within sliding window (Codex logs can contain duplicates)
2947
+ // Tool calls (starting with ">") are always printed
2948
+ if (!formatted.startsWith(">")) {
2949
+ if (recentMessages.includes(formatted)) continue;
2950
+ recentMessages.push(formatted);
2951
+ if (recentMessages.length > DEDUPE_WINDOW) recentMessages.shift();
2952
+ }
2953
+
2954
+ console.log(formatted);
2735
2955
  }
2736
2956
  }
2737
2957
  };
@@ -2764,6 +2984,7 @@ async function streamResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2764
2984
  */
2765
2985
  async function autoApproveLoop(agent, session, timeoutMs, waitFn) {
2766
2986
  const deadline = Date.now() + timeoutMs;
2987
+ debug("autoApprove", `starting loop, timeout=${timeoutMs}ms`);
2767
2988
 
2768
2989
  while (Date.now() < deadline) {
2769
2990
  const remaining = deadline - Date.now();
@@ -2772,10 +2993,12 @@ async function autoApproveLoop(agent, session, timeoutMs, waitFn) {
2772
2993
  const { state, screen } = await waitFn(agent, session, remaining);
2773
2994
 
2774
2995
  if (state === State.RATE_LIMITED || state === State.READY) {
2996
+ debug("autoApprove", `finished with state=${state}`);
2775
2997
  return { state, screen };
2776
2998
  }
2777
2999
 
2778
3000
  if (state === State.CONFIRMING) {
3001
+ debug("autoApprove", `auto-approving confirmation`);
2779
3002
  tmuxSend(session, agent.approveKey);
2780
3003
  await sleep(APPROVE_DELAY_MS);
2781
3004
  continue;
@@ -2785,6 +3008,7 @@ async function autoApproveLoop(agent, session, timeoutMs, waitFn) {
2785
3008
  debugError("autoApproveLoop", new Error(`unexpected state: ${state}`));
2786
3009
  }
2787
3010
 
3011
+ debug("autoApprove", `timeout`);
2788
3012
  throw new TimeoutError(session);
2789
3013
  }
2790
3014
 
@@ -2800,9 +3024,13 @@ async function cmdStart(agent, session, { yolo = false, allowedTools = null } =
2800
3024
  // Generate session name if not provided
2801
3025
  if (!session) {
2802
3026
  session = agent.generateSession({ allowedTools, yolo });
3027
+ debug("session", `generated new session: ${session}`);
2803
3028
  }
2804
3029
 
2805
- if (tmuxHasSession(session)) return session;
3030
+ if (tmuxHasSession(session)) {
3031
+ debug("session", `reusing existing session: ${session}`);
3032
+ return session;
3033
+ }
2806
3034
 
2807
3035
  // Check agent CLI is installed before trying to start
2808
3036
  const cliCheck = spawnSync("which", [agent.name], { encoding: "utf-8" });
@@ -2812,6 +3040,7 @@ async function cmdStart(agent, session, { yolo = false, allowedTools = null } =
2812
3040
  }
2813
3041
 
2814
3042
  const command = agent.getCommand(yolo, session, allowedTools);
3043
+ debug("session", `creating tmux session: ${session}`);
2815
3044
  tmuxNewSession(session, command);
2816
3045
 
2817
3046
  const start = Date.now();
@@ -4291,7 +4520,7 @@ async function cmdAsk(
4291
4520
  : await cmdStart(agent, session, { yolo, allowedTools });
4292
4521
 
4293
4522
  tmuxSendLiteral(activeSession, message);
4294
- await sleep(50);
4523
+ await sleep(200);
4295
4524
  tmuxSend(activeSession, "Enter");
4296
4525
 
4297
4526
  if (noWait) {
@@ -4422,6 +4651,13 @@ async function cmdReview(
4422
4651
  customInstructions,
4423
4652
  { wait = true, yolo = false, fresh = false, timeoutMs = REVIEW_TIMEOUT_MS } = {},
4424
4653
  ) {
4654
+ const validOptions = ["uncommitted", "custom", "branch", "commit"];
4655
+ if (option && !validOptions.includes(option)) {
4656
+ console.error(`Unknown review option: ${option}`);
4657
+ console.error(`Valid options: ${validOptions.join(", ")}`);
4658
+ process.exit(1);
4659
+ }
4660
+
4425
4661
  const sessionExists = session != null && tmuxHasSession(session);
4426
4662
 
4427
4663
  // Reset conversation if --fresh and session exists
@@ -4436,12 +4672,17 @@ async function cmdReview(
4436
4672
  if (!agent.reviewOptions) {
4437
4673
  /** @type {Record<string, string>} */
4438
4674
  const reviewPrompts = {
4439
- pr: "Review the current PR.",
4440
4675
  uncommitted: "Review uncommitted changes.",
4441
- commit: "Review the most recent git commit.",
4676
+ branch: customInstructions
4677
+ ? `Review changes on the current branch compared to ${customInstructions}.`
4678
+ : "Review changes on the current branch compared to main.",
4679
+ commit: customInstructions
4680
+ ? `Review commit ${customInstructions}.`
4681
+ : "Review the most recent commit.",
4442
4682
  custom: customInstructions || "Review the code.",
4443
4683
  };
4444
- const prompt = (option && reviewPrompts[option]) || reviewPrompts.commit;
4684
+ const prompt = (option && reviewPrompts[option]) || reviewPrompts.uncommitted;
4685
+ debug("review", `Claude path: noWait=${!wait}, timeoutMs=${timeoutMs}`);
4445
4686
  return cmdAsk(agent, session, prompt, { noWait: !wait, yolo, timeoutMs });
4446
4687
  }
4447
4688
 
@@ -4467,21 +4708,55 @@ async function cmdReview(
4467
4708
  ? /** @type {string} */ (session)
4468
4709
  : await cmdStart(agent, session, { yolo });
4469
4710
 
4711
+ debug("review", `Codex path: sending /review command`);
4470
4712
  tmuxSendLiteral(activeSession, "/review");
4471
4713
  await sleep(50);
4472
4714
  tmuxSend(activeSession, "Enter");
4473
4715
 
4716
+ debug("review", `waiting for review menu`);
4474
4717
  await waitFor(activeSession, (s) => s.includes("Select a review preset") || s.includes("review"));
4475
4718
 
4476
4719
  if (option) {
4477
4720
  const key = agent.reviewOptions[option] || option;
4721
+ debug("review", `selecting option=${option} (key=${key})`);
4478
4722
  tmuxSend(activeSession, key);
4479
4723
 
4480
4724
  if (option === "custom" && customInstructions) {
4725
+ debug("review", `waiting for custom instructions prompt`);
4481
4726
  await waitFor(activeSession, (s) => s.includes("custom") || s.includes("instructions"));
4482
4727
  tmuxSendLiteral(activeSession, customInstructions);
4483
4728
  await sleep(50);
4484
4729
  tmuxSend(activeSession, "Enter");
4730
+ } else if (option === "branch") {
4731
+ debug("review", `waiting for branch picker`);
4732
+ await waitFor(activeSession, (s) => !s.includes("Select a review preset"));
4733
+ await sleep(200);
4734
+ if (customInstructions) {
4735
+ debug("review", `typing branch filter: ${customInstructions}`);
4736
+ tmuxSendLiteral(activeSession, customInstructions);
4737
+ await sleep(100);
4738
+ }
4739
+ tmuxSend(activeSession, "Enter");
4740
+ } else if (option === "commit") {
4741
+ debug("review", `waiting for commit picker`);
4742
+ await waitFor(activeSession, (s) => !s.includes("Select a review preset"));
4743
+ await sleep(200);
4744
+ if (customInstructions) {
4745
+ // Codex commit picker shows messages, not hashes - resolve ref to message
4746
+ let searchTerm = customInstructions;
4747
+ const gitResult = spawnSync("git", ["log", "--format=%s", "-n", "1", customInstructions], {
4748
+ encoding: "utf-8",
4749
+ });
4750
+ if (gitResult.status === 0 && gitResult.stdout.trim()) {
4751
+ // Use first few words of commit message for search
4752
+ searchTerm = gitResult.stdout.trim().slice(0, 40);
4753
+ debug("review", `resolved commit ${customInstructions} -> "${searchTerm}"`);
4754
+ }
4755
+ debug("review", `typing commit filter: ${searchTerm}`);
4756
+ tmuxSendLiteral(activeSession, searchTerm);
4757
+ await sleep(100);
4758
+ }
4759
+ tmuxSend(activeSession, "Enter");
4485
4760
  }
4486
4761
  }
4487
4762
 
@@ -4772,7 +5047,7 @@ Usage: ${name} [OPTIONS] <command|message> [ARGS...]
4772
5047
 
4773
5048
  Messaging:
4774
5049
  <message> Send message to ${name}
4775
- review [TYPE] Review code: pr, uncommitted, commit, custom
5050
+ review [TYPE] [TARGET] Review code: uncommitted, branch [base], commit [ref], custom
4776
5051
 
4777
5052
  Sessions:
4778
5053
  compact Summarise session to shrink context size
@@ -4815,6 +5090,8 @@ Examples:
4815
5090
  ${name} "FYI: auth was refactored" --no-wait # Send context to a working session (no response needed)
4816
5091
  ${name} --auto-approve='Bash("cargo *")' "run tests" # Session with specific permissions
4817
5092
  ${name} review uncommitted --wait
5093
+ ${name} review branch main # Review changes vs main branch
5094
+ ${name} review commit HEAD~1 # Review specific commit
4818
5095
  ${name} kill # Kill agents in current project
4819
5096
  ${name} kill --all # Kill all agents across all projects
4820
5097
  ${name} kill --session=NAME # Kill specific session
@@ -4952,9 +5229,9 @@ async function main() {
4952
5229
  if (cmd === "review") {
4953
5230
  const customInstructions = await readStdinIfNeeded(positionals[2]);
4954
5231
  return cmdReview(agent, session, positionals[1], customInstructions ?? undefined, {
4955
- wait,
5232
+ wait: !noWait,
4956
5233
  fresh,
4957
- timeoutMs,
5234
+ timeoutMs: flags.timeout !== undefined ? timeoutMs : REVIEW_TIMEOUT_MS,
4958
5235
  });
4959
5236
  }
4960
5237
  if (cmd === "status") return cmdStatus(agent, session);
@@ -5039,4 +5316,8 @@ export {
5039
5316
  State,
5040
5317
  normalizeAllowedTools,
5041
5318
  computePermissionHash,
5319
+ formatClaudeLogEntry,
5320
+ formatCodexLogEntry,
5321
+ CodexAgent,
5322
+ ClaudeAgent,
5042
5323
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ax-agents",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "A CLI for orchestrating AI coding agents via tmux",
5
5
  "bin": {
6
6
  "ax": "ax.js",