ax-agents 0.1.3 → 0.1.5

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 +1227 -89
  2. package/package.json +1 -1
package/ax.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // 2 - rate limited
10
10
  // 3 - awaiting confirmation
11
11
  // 4 - thinking
12
+ // 5 - iteration complete, more work to do (ax do)
12
13
 
13
14
  import { execSync, spawnSync, spawn } from "node:child_process";
14
15
  import {
@@ -31,7 +32,7 @@ import { randomUUID, createHash } from "node:crypto";
31
32
  import { fileURLToPath } from "node:url";
32
33
  import path from "node:path";
33
34
  import os from "node:os";
34
- import { parseArgs } from "node:util";
35
+ import { parseArgs, styleText } from "node:util";
35
36
 
36
37
  const __filename = fileURLToPath(import.meta.url);
37
38
  const __dirname = path.dirname(__filename);
@@ -134,14 +135,102 @@ const VERSION = packageJson.version;
134
135
  * @property {{UserPromptSubmit?: ClaudeHookEntry[], PreToolUse?: ClaudeHookEntry[], Stop?: ClaudeHookEntry[], [key: string]: ClaudeHookEntry[] | undefined}} [hooks]
135
136
  */
136
137
 
138
+ // =============================================================================
139
+ // Terminal Stream Types - Abstraction layer for terminal I/O
140
+ // =============================================================================
141
+
142
+ /**
143
+ * Style properties for terminal text (ANSI colors, formatting)
144
+ * @typedef {Object} TerminalStyle
145
+ * @property {string} [fg] - Foreground color (e.g., "red", "green", "#ff0000")
146
+ * @property {string} [bg] - Background color
147
+ * @property {boolean} [bold] - Bold text
148
+ * @property {boolean} [dim] - Dimmed text
149
+ * @property {boolean} [italic] - Italic text
150
+ * @property {boolean} [underline] - Underlined text
151
+ */
152
+
153
+ /**
154
+ * A span of text with optional styling
155
+ * @typedef {Object} TextSpan
156
+ * @property {string} text - The text content
157
+ * @property {TerminalStyle} [style] - Optional style properties
158
+ */
159
+
160
+ /**
161
+ * A line of terminal output, containing styled spans and raw text
162
+ * @typedef {Object} TerminalLine
163
+ * @property {TextSpan[]} spans - Styled text spans
164
+ * @property {string} raw - Raw text content (spans joined, styles stripped)
165
+ * @property {'text' | 'thinking' | 'tool'} [lineType] - Content type for styling
166
+ */
167
+
168
+ /**
169
+ * A segment of log output with type information
170
+ * @typedef {Object} LogSegment
171
+ * @property {'text' | 'thinking' | 'tool'} type - Content type
172
+ * @property {string} content - The text content
173
+ */
174
+
175
+ /**
176
+ * Query for matching terminal lines
177
+ * @typedef {Object} MatchQuery
178
+ * @property {string | RegExp} pattern - Pattern to match against raw line text
179
+ * @property {Partial<TerminalStyle>} [style] - Optional style filter (ignored if implementation doesn't support styles)
180
+ */
181
+
182
+ /**
183
+ * Result of a pattern match operation
184
+ * @typedef {Object} MatchResult
185
+ * @property {boolean} matched - Whether a match was found
186
+ * @property {TerminalLine} [line] - The matched line (if matched)
187
+ * @property {number} [lineIndex] - Index of the matched line (if matched)
188
+ */
189
+
190
+ /**
191
+ * Options for reading from a terminal stream
192
+ * @typedef {Object} ReadOptions
193
+ * @property {number} [max] - Maximum number of lines to return
194
+ * @property {number} [timeoutMs] - Timeout in milliseconds
195
+ */
196
+
197
+ /**
198
+ * Options for waiting for a match
199
+ * @typedef {Object} WaitOptions
200
+ * @property {number} [timeoutMs] - Timeout in milliseconds
201
+ */
202
+
203
+ /**
204
+ * Interface for reading terminal output.
205
+ * Implementations: JsonlTerminalStream (Claude logs), ScreenTerminalStream (tmux capture)
206
+ * @typedef {Object} TerminalStream
207
+ * @property {(opts?: ReadOptions) => Promise<TerminalLine[]>} readNext - Read new lines since last read
208
+ * @property {(query: MatchQuery, opts?: WaitOptions) => Promise<MatchResult>} waitForMatch - Wait for a line matching the query
209
+ */
210
+
137
211
  const DEBUG = process.env.AX_DEBUG === "1";
138
212
 
213
+ // ANSI colour codes for debug output
214
+ const COLORS = {
215
+ reset: "\x1b[0m",
216
+ bright: "\x1b[1m",
217
+ cyan: "\x1b[96m", // Bright cyan
218
+ magenta: "\x1b[95m", // Bright magenta
219
+ yellow: "\x1b[93m", // Bright yellow
220
+ red: "\x1b[91m", // Bright red
221
+ };
222
+
139
223
  /**
140
224
  * @param {string} context
141
225
  * @param {unknown} err
142
226
  */
143
227
  function debugError(context, err) {
144
- if (DEBUG) console.error(`[debug:${context}]`, err instanceof Error ? err.message : err);
228
+ if (DEBUG) {
229
+ const msg = err instanceof Error ? err.message : err;
230
+ console.error(
231
+ `${COLORS.bright}${COLORS.red}[error:${context}]${COLORS.reset} ${COLORS.magenta}${msg}${COLORS.reset}`,
232
+ );
233
+ }
145
234
  }
146
235
 
147
236
  /**
@@ -150,7 +239,11 @@ function debugError(context, err) {
150
239
  * @param {string} message - The debug message
151
240
  */
152
241
  function debug(tag, message) {
153
- if (DEBUG) console.error(`[${tag}] ${message}`);
242
+ if (DEBUG) {
243
+ console.error(
244
+ `${COLORS.bright}${COLORS.cyan}[${tag}]${COLORS.reset} ${COLORS.yellow}${message}${COLORS.reset}`,
245
+ );
246
+ }
154
247
  }
155
248
 
156
249
  // =============================================================================
@@ -174,6 +267,46 @@ const AI_DIR = path.join(PROJECT_ROOT, ".ai");
174
267
  const AGENTS_DIR = path.join(AI_DIR, "agents");
175
268
  const HOOKS_DIR = path.join(AI_DIR, "hooks");
176
269
  const RFP_DIR = path.join(AI_DIR, "rfps");
270
+ const DO_DIR = path.join(AI_DIR, "do");
271
+
272
+ /**
273
+ * Get path to progress file for a named do task
274
+ * @param {string} name - Task name (default: "default")
275
+ * @returns {string}
276
+ */
277
+ function getDoProgressPath(name = "default") {
278
+ const dir = path.join(DO_DIR, name);
279
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
280
+ const filePath = path.join(dir, "progress.txt");
281
+ // Touch the file if it doesn't exist so agent can read it on first iteration
282
+ if (!existsSync(filePath)) writeFileSync(filePath, "");
283
+ return filePath;
284
+ }
285
+
286
+ /**
287
+ * Build prompt for do loop with preamble and progress context
288
+ * @param {string} userPrompt
289
+ * @param {string} name
290
+ * @returns {string}
291
+ */
292
+ function buildDoPrompt(userPrompt, name) {
293
+ const progressPath = getDoProgressPath(name);
294
+ const progress = existsSync(progressPath) ? readFileSync(progressPath, "utf-8") : "";
295
+
296
+ const relProgressPath = `.ai/do/${name}/progress.txt`;
297
+ const preamble = DO_PREAMBLE.replace(/\{progressPath\}/g, relProgressPath);
298
+
299
+ return `${preamble}
300
+
301
+ ## Progress So Far
302
+ ${progress || "(No progress yet)"}
303
+
304
+ ## Your Task
305
+ ${userPrompt}
306
+
307
+ Remember: Work on ONE thing, update ${relProgressPath}, run tests, commit.
308
+ When ALL tasks are complete, output <promise>COMPLETE</promise>`;
309
+ }
177
310
 
178
311
  // =============================================================================
179
312
  // Helpers - tmux
@@ -205,11 +338,13 @@ function tmuxHasSession(session) {
205
338
  /**
206
339
  * @param {string} session
207
340
  * @param {number} [scrollback]
341
+ * @param {boolean} [withEscapes] - Include ANSI escape sequences (uses -e flag)
208
342
  * @returns {string}
209
343
  */
210
- function tmuxCapture(session, scrollback = 0) {
344
+ function tmuxCapture(session, scrollback = 0, withEscapes = false) {
211
345
  try {
212
346
  const args = ["capture-pane", "-t", session, "-p"];
347
+ if (withEscapes) args.push("-e"); // Include escape sequences
213
348
  if (scrollback) args.push("-S", String(-scrollback));
214
349
  return tmux(args);
215
350
  } catch (err) {
@@ -236,6 +371,64 @@ function tmuxSendLiteral(session, text) {
236
371
  tmux(["send-keys", "-t", session, "-l", text]);
237
372
  }
238
373
 
374
+ /**
375
+ * Paste text into a tmux session using load-buffer + paste-buffer.
376
+ * More reliable than send-keys -l for large text.
377
+ * Uses a named buffer to avoid races with concurrent invocations.
378
+ * @param {string} session
379
+ * @param {string} text
380
+ */
381
+ function tmuxPasteLiteral(session, text) {
382
+ debug("tmux", `pasteLiteral session=${session}, text=${text.slice(0, 50)}...`);
383
+ // Use unique buffer name per invocation to avoid races (even to same session)
384
+ const bufferName = `ax-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
385
+ // Load text into named tmux buffer from stdin
386
+ const loadResult = spawnSync("tmux", ["load-buffer", "-b", bufferName, "-"], {
387
+ input: text,
388
+ encoding: "utf-8",
389
+ });
390
+ if (loadResult.status !== 0) {
391
+ debug("tmux", `load-buffer failed: ${loadResult.stderr}`);
392
+ throw new Error(loadResult.stderr || "tmux load-buffer failed");
393
+ }
394
+ try {
395
+ // Paste buffer into the session
396
+ tmux(["paste-buffer", "-b", bufferName, "-t", session]);
397
+ // Move cursor to end of pasted text
398
+ tmux(["send-keys", "-t", session, "End"]);
399
+ } finally {
400
+ // Clean up the named buffer
401
+ try {
402
+ tmux(["delete-buffer", "-b", bufferName]);
403
+ } catch (err) {
404
+ debugError("tmuxPasteLiteral", err);
405
+ }
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Paste text and send Enter, waiting for multiline paste indicator if needed.
411
+ * Claude Code shows "[Pasted text #N +M lines]" for multiline input and needs
412
+ * time to process it before accepting Enter.
413
+ * @param {string} session
414
+ * @param {string} text
415
+ */
416
+ async function tmuxSendText(session, text) {
417
+ const parsed = parseSessionName(session);
418
+ const isClaude = parsed?.tool === "claude";
419
+ const newlineCount = (text.match(/\n/g) || []).length;
420
+
421
+ tmuxPasteLiteral(session, text);
422
+
423
+ // For multiline text in Claude, use adaptive delay based on paste size
424
+ if (isClaude && newlineCount > 0) {
425
+ const delay = Math.min(1500, 50 + 3 * text.length + 20 * newlineCount);
426
+ debug("sendText", `multiline paste (${text.length} chars, ${newlineCount} lines), waiting ${delay}ms`);
427
+ await sleep(delay);
428
+ }
429
+ tmuxSend(session, "Enter");
430
+ }
431
+
239
432
  /**
240
433
  * @param {string} session
241
434
  */
@@ -247,6 +440,23 @@ function tmuxKill(session) {
247
440
  }
248
441
  }
249
442
 
443
+ /**
444
+ * Rename a tmux session.
445
+ * @param {string} oldName
446
+ * @param {string} newName
447
+ * @returns {boolean}
448
+ */
449
+ function tmuxRenameSession(oldName, newName) {
450
+ try {
451
+ tmux(["rename-session", "-t", oldName, newName]);
452
+ debug("tmux", `renamed session: ${oldName} -> ${newName}`);
453
+ return true;
454
+ } catch (err) {
455
+ debugError("tmuxRenameSession", err);
456
+ return false;
457
+ }
458
+ }
459
+
250
460
  /**
251
461
  * @param {string} session
252
462
  * @param {string} command
@@ -396,6 +606,24 @@ const RFP_PREAMBLE = `## Guidelines
396
606
  - Prioritize clarity over brevity.
397
607
  - If you have nothing to propose, respond with ONLY "EMPTY_RESPONSE".`;
398
608
 
609
+ // Note: DO_PREAMBLE is a template - {progressPath} gets replaced at runtime
610
+ const DO_PREAMBLE = `You are an autonomous coding agent in a loop. Each iteration:
611
+
612
+ 1. Read {progressPath} to see what's done
613
+ 2. Choose the highest priority remaining task
614
+ 3. Implement ONE small feature/fix
615
+ 4. Run feedback loops (tests, types, lint)
616
+ 5. Commit your changes with a clear message
617
+ 6. Append to {progressPath} what you did
618
+ 7. If ALL tasks are complete, output: <promise>COMPLETE</promise>
619
+
620
+ Guidelines:
621
+ - Work on ONE task per iteration, keep changes small
622
+ - Always run tests before committing - do NOT commit if tests fail
623
+ - Update {progressPath} BEFORE outputting COMPLETE
624
+ - Prioritize risky/architectural work first
625
+ - If stuck, document the blocker in {progressPath}`;
626
+
399
627
  /**
400
628
  * @param {string} session
401
629
  * @param {(screen: string) => boolean} predicate
@@ -559,6 +787,10 @@ async function readStdinIfNeeded(value) {
559
787
  * @property {string} [branch]
560
788
  * @property {string} [archangels]
561
789
  * @property {string} [autoApprove]
790
+ * @property {string} [name]
791
+ * @property {number} [maxLoops]
792
+ * @property {boolean} loop
793
+ * @property {boolean} reset
562
794
  */
563
795
  function parseCliArgs(args) {
564
796
  const { values, positionals } = parseArgs({
@@ -577,6 +809,8 @@ function parseCliArgs(args) {
577
809
  stale: { type: "boolean", default: false },
578
810
  version: { type: "boolean", short: "V", default: false },
579
811
  help: { type: "boolean", short: "h", default: false },
812
+ loop: { type: "boolean", default: false },
813
+ reset: { type: "boolean", default: false },
580
814
  // Value flags
581
815
  tool: { type: "string" },
582
816
  "auto-approve": { type: "string" },
@@ -586,6 +820,8 @@ function parseCliArgs(args) {
586
820
  limit: { type: "string" },
587
821
  branch: { type: "string" },
588
822
  archangels: { type: "string" },
823
+ name: { type: "string" },
824
+ "max-loops": { type: "string" },
589
825
  },
590
826
  allowPositionals: true,
591
827
  strict: false, // Don't error on unknown flags
@@ -613,6 +849,10 @@ function parseCliArgs(args) {
613
849
  branch: /** @type {string | undefined} */ (values.branch),
614
850
  archangels: /** @type {string | undefined} */ (values.archangels),
615
851
  autoApprove: /** @type {string | undefined} */ (values["auto-approve"]),
852
+ name: /** @type {string | undefined} */ (values.name),
853
+ maxLoops: values["max-loops"] !== undefined ? Number(values["max-loops"]) : undefined,
854
+ loop: Boolean(values.loop),
855
+ reset: Boolean(values.reset),
616
856
  },
617
857
  positionals,
618
858
  };
@@ -681,6 +921,31 @@ function generateSessionName(tool, { allowedTools = null, yolo = false } = {}) {
681
921
  return `${tool}-partner-${uuid}`;
682
922
  }
683
923
 
924
+ /**
925
+ * Rebuild a session name with a new UUID, preserving other attributes.
926
+ * @param {string} sessionName - existing session name
927
+ * @param {string} newUuid - new UUID to use
928
+ * @returns {string | null}
929
+ */
930
+ function rebuildSessionName(sessionName, newUuid) {
931
+ const parsed = parseSessionName(sessionName);
932
+ if (!parsed || !parsed.uuid) return null;
933
+
934
+ // Archangel sessions: {tool}-archangel-{name}-{uuid}
935
+ if (parsed.archangelName) {
936
+ return `${parsed.tool}-archangel-${parsed.archangelName}-${newUuid}`;
937
+ }
938
+
939
+ // Partner sessions: {tool}-partner-{uuid}[-p{hash}|-yolo]
940
+ let name = `${parsed.tool}-partner-${newUuid}`;
941
+ if (parsed.yolo) {
942
+ name += "-yolo";
943
+ } else if (parsed.permissionHash) {
944
+ name += `-p${parsed.permissionHash}`;
945
+ }
946
+ return name;
947
+ }
948
+
684
949
  /**
685
950
  * Quick hash for change detection (not cryptographic).
686
951
  * @param {string | null | undefined} str
@@ -761,6 +1026,42 @@ function findClaudeLogPath(sessionId, sessionName) {
761
1026
  return null;
762
1027
  }
763
1028
 
1029
+ /**
1030
+ * Find the most recently created Claude session UUID for a project.
1031
+ * @param {string} sessionName - tmux session name (used to get cwd)
1032
+ * @returns {string | null}
1033
+ */
1034
+ function findNewestClaudeSessionUuid(sessionName) {
1035
+ const cwd = getTmuxSessionCwd(sessionName) || process.cwd();
1036
+ const projectPath = getClaudeProjectPath(cwd);
1037
+ const claudeProjectDir = path.join(CLAUDE_CONFIG_DIR, "projects", projectPath);
1038
+ const indexPath = path.join(claudeProjectDir, "sessions-index.json");
1039
+
1040
+ if (!existsSync(indexPath)) {
1041
+ debug("log", `findNewestClaudeSessionUuid: no index at ${indexPath}`);
1042
+ return null;
1043
+ }
1044
+
1045
+ try {
1046
+ const index = JSON.parse(readFileSync(indexPath, "utf-8"));
1047
+ if (!index.entries?.length) return null;
1048
+
1049
+ // Sort by created timestamp (most recent first)
1050
+ const sorted = [...index.entries].sort((a, b) => {
1051
+ const aTime = a.created ? new Date(a.created).getTime() : 0;
1052
+ const bTime = b.created ? new Date(b.created).getTime() : 0;
1053
+ return bTime - aTime;
1054
+ });
1055
+
1056
+ const newest = sorted[0];
1057
+ debug("log", `findNewestClaudeSessionUuid: newest=${newest.sessionId}`);
1058
+ return newest.sessionId;
1059
+ } catch (err) {
1060
+ debugError("findNewestClaudeSessionUuid", err);
1061
+ return null;
1062
+ }
1063
+ }
1064
+
764
1065
  /**
765
1066
  * @param {string} sessionName
766
1067
  * @returns {string | null}
@@ -837,7 +1138,10 @@ function findCodexLogPath(sessionName) {
837
1138
  }
838
1139
  // Return the closest match
839
1140
  candidates.sort((a, b) => a.diff - b.diff);
840
- debug("log", `findCodexLogPath: found ${candidates.length} candidates, best: ${candidates[0].path}`);
1141
+ debug(
1142
+ "log",
1143
+ `findCodexLogPath: found ${candidates.length} candidates, best: ${candidates[0].path}`,
1144
+ );
841
1145
  return candidates[0].path;
842
1146
  } catch {
843
1147
  debug("log", `findCodexLogPath: exception caught`);
@@ -1026,9 +1330,14 @@ function tailJsonl(logPath, fromOffset) {
1026
1330
  /**
1027
1331
  * Format a Claude Code JSONL log entry for streaming display.
1028
1332
  * Claude format: {type: "assistant", message: {content: [...]}}
1029
- * @param {{type?: string, message?: {content?: Array<{type?: string, text?: string, name?: string, input?: ToolInput, tool?: string, arguments?: ToolInput}>}}} entry
1333
+ * @param {{type?: string, message?: {content?: Array<{type?: string, text?: string, thinking?: string, name?: string, input?: ToolInput, tool?: string, arguments?: ToolInput}>}}} entry
1030
1334
  * @returns {string | null}
1031
1335
  */
1336
+ /**
1337
+ * Format a Claude JSONL log entry for streaming display.
1338
+ * @param {{type?: string, message?: {content?: Array<{type?: string, text?: string, thinking?: string, name?: string, tool?: string, input?: {command?: string, file_path?: string, path?: string, pattern?: string, description?: string, subagent_type?: string}, arguments?: {command?: string, file_path?: string, path?: string, pattern?: string, description?: string, subagent_type?: string}}>}}} entry
1339
+ * @returns {LogSegment[] | null}
1340
+ */
1032
1341
  function formatClaudeLogEntry(entry) {
1033
1342
  // Skip tool_result entries (they can be very verbose)
1034
1343
  if (entry.type === "tool_result") return null;
@@ -1037,29 +1346,34 @@ function formatClaudeLogEntry(entry) {
1037
1346
  if (entry.type !== "assistant") return null;
1038
1347
 
1039
1348
  const parts = entry.message?.content || [];
1349
+ /** @type {LogSegment[]} */
1040
1350
  const output = [];
1041
1351
 
1042
1352
  for (const part of parts) {
1043
1353
  if (part.type === "text" && part.text) {
1044
- output.push(part.text);
1354
+ output.push({ type: "text", content: part.text });
1045
1355
  } else if (part.type === "thinking" && part.thinking) {
1046
1356
  // Include thinking blocks - extended thinking models put responses here
1047
- output.push(part.thinking);
1357
+ output.push({ type: "thinking", content: part.thinking });
1048
1358
  } else if (part.type === "tool_use" || part.type === "tool_call") {
1049
1359
  const name = part.name || part.tool || "tool";
1050
1360
  const input = part.input || part.arguments || {};
1051
1361
  let summary;
1052
1362
  if (name === "Bash" && input.command) {
1053
1363
  summary = input.command.slice(0, 50);
1364
+ } else if (name === "Task" && (input.description || input.subagent_type)) {
1365
+ // Task tool: show description or subagent type
1366
+ summary = input.description || input.subagent_type || "";
1367
+ summary = summary.slice(0, 40);
1054
1368
  } else {
1055
1369
  const target = input.file_path || input.path || input.pattern || "";
1056
1370
  summary = target.split("/").pop() || target.slice(0, 30);
1057
1371
  }
1058
- output.push(`> ${name}(${summary})`);
1372
+ output.push({ type: "tool", content: `> ${name}(${summary})` });
1059
1373
  }
1060
1374
  }
1061
1375
 
1062
- return output.length > 0 ? output.join("\n") : null;
1376
+ return output.length > 0 ? output : null;
1063
1377
  }
1064
1378
 
1065
1379
  /**
@@ -1068,8 +1382,9 @@ function formatClaudeLogEntry(entry) {
1068
1382
  * - {type: "response_item", payload: {type: "message", role: "assistant", content: [{type: "output_text", text: "..."}]}}
1069
1383
  * - {type: "response_item", payload: {type: "function_call", name: "...", arguments: "{...}"}}
1070
1384
  * - {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}
1385
+ * - {type: "event_msg", payload: {type: "agent_reasoning", text: "..."}}
1386
+ * @param {{type?: string, payload?: {type?: string, role?: string, name?: string, arguments?: string, message?: string, text?: string, content?: Array<{type?: string, text?: string}>}}} entry
1387
+ * @returns {LogSegment[] | null}
1073
1388
  */
1074
1389
  function formatCodexLogEntry(entry) {
1075
1390
  // Skip function_call_output entries (equivalent to tool_result - can be verbose)
@@ -1085,6 +1400,10 @@ function formatCodexLogEntry(entry) {
1085
1400
  const args = JSON.parse(entry.payload.arguments || "{}");
1086
1401
  if (name === "shell_command" && args.command) {
1087
1402
  summary = args.command.slice(0, 50);
1403
+ } else if (name === "Task" && (args.description || args.subagent_type)) {
1404
+ // Task tool: show description or subagent type
1405
+ summary = args.description || args.subagent_type || "";
1406
+ summary = summary.slice(0, 40);
1088
1407
  } else {
1089
1408
  const target = args.file_path || args.path || args.pattern || "";
1090
1409
  summary = target.split("/").pop() || target.slice(0, 30);
@@ -1092,34 +1411,640 @@ function formatCodexLogEntry(entry) {
1092
1411
  } catch {
1093
1412
  summary = "...";
1094
1413
  }
1095
- return `> ${name}(${summary})`;
1414
+ return [{ type: "tool", content: `> ${name}(${summary})` }];
1096
1415
  }
1097
1416
 
1098
1417
  // Handle assistant messages (final response)
1099
1418
  if (entry.type === "response_item" && entry.payload?.role === "assistant") {
1100
1419
  const parts = entry.payload.content || [];
1420
+ /** @type {LogSegment[]} */
1101
1421
  const output = [];
1102
1422
  for (const part of parts) {
1103
1423
  if ((part.type === "output_text" || part.type === "text") && part.text) {
1104
- output.push(part.text);
1424
+ output.push({ type: "text", content: part.text });
1105
1425
  }
1106
1426
  }
1107
- return output.length > 0 ? output.join("\n") : null;
1427
+ return output.length > 0 ? output : null;
1108
1428
  }
1109
1429
 
1110
1430
  // Handle streaming agent messages
1111
1431
  if (entry.type === "event_msg" && entry.payload?.type === "agent_message") {
1112
- return entry.payload.message || null;
1432
+ const message = entry.payload.message;
1433
+ return message ? [{ type: "text", content: message }] : null;
1113
1434
  }
1114
1435
 
1115
1436
  // Handle agent reasoning (thinking during review)
1116
1437
  if (entry.type === "event_msg" && entry.payload?.type === "agent_reasoning") {
1117
- return entry.payload.text || null;
1438
+ const text = entry.payload.text;
1439
+ return text ? [{ type: "thinking", content: text }] : null;
1118
1440
  }
1119
1441
 
1120
1442
  return null;
1121
1443
  }
1122
1444
 
1445
+ // =============================================================================
1446
+ // Terminal Stream Primitives - Pure functions for parsing terminal data
1447
+ // =============================================================================
1448
+
1449
+ /**
1450
+ * Parse a JSONL log entry into TerminalLine[].
1451
+ * Wraps formatClaudeLogEntry/formatCodexLogEntry to return structured data.
1452
+ * @param {object} entry - A parsed JSONL entry
1453
+ * @param {'claude' | 'codex'} format - The log format
1454
+ * @returns {TerminalLine[]}
1455
+ */
1456
+ function parseJsonlEntry(entry, format) {
1457
+ const segments = format === "claude" ? formatClaudeLogEntry(entry) : formatCodexLogEntry(entry);
1458
+ if (!segments) return [];
1459
+
1460
+ // Convert segments to TerminalLines, splitting multiline content
1461
+ /** @type {TerminalLine[]} */
1462
+ const lines = [];
1463
+ for (const segment of segments) {
1464
+ const contentLines = segment.content.split("\n");
1465
+ for (const line of contentLines) {
1466
+ lines.push({
1467
+ spans: [{ text: line }],
1468
+ raw: line,
1469
+ lineType: segment.type,
1470
+ });
1471
+ }
1472
+ }
1473
+ return lines;
1474
+ }
1475
+
1476
+ /**
1477
+ * Parse raw screen output into TerminalLine[].
1478
+ * Each line becomes a TerminalLine with a single unstyled span.
1479
+ * @param {string} screen - Raw screen content from tmux capture
1480
+ * @returns {TerminalLine[]}
1481
+ */
1482
+ function parseScreenLines(screen) {
1483
+ if (!screen) return [];
1484
+ return screen.split("\n").map((line) => ({
1485
+ spans: [{ text: line }],
1486
+ raw: line,
1487
+ }));
1488
+ }
1489
+
1490
+ /**
1491
+ * ANSI color code to color name mapping.
1492
+ * @type {Record<string, string>}
1493
+ */
1494
+ const ANSI_COLORS = {
1495
+ 30: "black",
1496
+ 31: "red",
1497
+ 32: "green",
1498
+ 33: "yellow",
1499
+ 34: "blue",
1500
+ 35: "magenta",
1501
+ 36: "cyan",
1502
+ 37: "white",
1503
+ 90: "bright-black",
1504
+ 91: "bright-red",
1505
+ 92: "bright-green",
1506
+ 93: "bright-yellow",
1507
+ 94: "bright-blue",
1508
+ 95: "bright-magenta",
1509
+ 96: "bright-cyan",
1510
+ 97: "bright-white",
1511
+ };
1512
+
1513
+ /**
1514
+ * ANSI background color code to color name mapping.
1515
+ * @type {Record<string, string>}
1516
+ */
1517
+ const ANSI_BG_COLORS = {
1518
+ 40: "black",
1519
+ 41: "red",
1520
+ 42: "green",
1521
+ 43: "yellow",
1522
+ 44: "blue",
1523
+ 45: "magenta",
1524
+ 46: "cyan",
1525
+ 47: "white",
1526
+ 100: "bright-black",
1527
+ 101: "bright-red",
1528
+ 102: "bright-green",
1529
+ 103: "bright-yellow",
1530
+ 104: "bright-blue",
1531
+ 105: "bright-magenta",
1532
+ 106: "bright-cyan",
1533
+ 107: "bright-white",
1534
+ };
1535
+
1536
+ /**
1537
+ * Parse ANSI escape sequences from a line of text into styled spans.
1538
+ * @param {string} line - Line containing ANSI escape sequences
1539
+ * @returns {TextSpan[]}
1540
+ */
1541
+ function parseAnsiLine(line) {
1542
+ if (!line) return [{ text: "" }];
1543
+
1544
+ const spans = [];
1545
+ /** @type {TerminalStyle} */
1546
+ let currentStyle = {};
1547
+ let currentText = "";
1548
+
1549
+ // ANSI escape sequence pattern: ESC [ <params> m
1550
+ // Matches sequences like \x1b[31m (red), \x1b[1;31m (bold red), \x1b[0m (reset)
1551
+ // eslint-disable-next-line no-control-regex
1552
+ const ansiPattern = /\x1b\[([0-9;]*)m/g;
1553
+ let lastIndex = 0;
1554
+ let match;
1555
+
1556
+ while ((match = ansiPattern.exec(line)) !== null) {
1557
+ // Add text before this escape sequence
1558
+ const textBefore = line.slice(lastIndex, match.index);
1559
+ if (textBefore) {
1560
+ currentText += textBefore;
1561
+ }
1562
+
1563
+ // Flush current span if we have text
1564
+ if (currentText) {
1565
+ /** @type {TextSpan} */
1566
+ const span = { text: currentText };
1567
+ if (Object.keys(currentStyle).length > 0) {
1568
+ span.style = { ...currentStyle };
1569
+ }
1570
+ spans.push(span);
1571
+ currentText = "";
1572
+ }
1573
+
1574
+ // Parse SGR (Select Graphic Rendition) parameters
1575
+ // Note: \x1b[m (empty params) is equivalent to \x1b[0m (reset)
1576
+ const params = match[1].split(";").filter(Boolean);
1577
+ if (params.length === 0) {
1578
+ // Empty params means reset (e.g., \x1b[m)
1579
+ currentStyle = {};
1580
+ }
1581
+ for (const param of params) {
1582
+ const code = param;
1583
+ if (code === "0") {
1584
+ // Reset
1585
+ currentStyle = {};
1586
+ } else if (code === "1") {
1587
+ currentStyle.bold = true;
1588
+ } else if (code === "2") {
1589
+ currentStyle.dim = true;
1590
+ } else if (code === "3") {
1591
+ currentStyle.italic = true;
1592
+ } else if (code === "4") {
1593
+ currentStyle.underline = true;
1594
+ } else if (code === "22") {
1595
+ // Normal intensity (neither bold nor dim)
1596
+ delete currentStyle.bold;
1597
+ delete currentStyle.dim;
1598
+ } else if (code === "23") {
1599
+ delete currentStyle.italic;
1600
+ } else if (code === "24") {
1601
+ delete currentStyle.underline;
1602
+ } else if (ANSI_COLORS[code]) {
1603
+ currentStyle.fg = ANSI_COLORS[code];
1604
+ } else if (ANSI_BG_COLORS[code]) {
1605
+ currentStyle.bg = ANSI_BG_COLORS[code];
1606
+ } else if (code === "39") {
1607
+ // Default foreground
1608
+ delete currentStyle.fg;
1609
+ } else if (code === "49") {
1610
+ // Default background
1611
+ delete currentStyle.bg;
1612
+ }
1613
+ }
1614
+
1615
+ lastIndex = ansiPattern.lastIndex;
1616
+ }
1617
+
1618
+ // Add remaining text
1619
+ const remaining = line.slice(lastIndex);
1620
+ if (remaining) {
1621
+ currentText += remaining;
1622
+ }
1623
+
1624
+ // Flush final span
1625
+ if (currentText || spans.length === 0) {
1626
+ /** @type {TextSpan} */
1627
+ const span = { text: currentText };
1628
+ if (Object.keys(currentStyle).length > 0) {
1629
+ span.style = { ...currentStyle };
1630
+ }
1631
+ spans.push(span);
1632
+ }
1633
+
1634
+ return spans;
1635
+ }
1636
+
1637
+ /**
1638
+ * Parse raw screen output with ANSI codes into styled TerminalLine[].
1639
+ * @param {string} screen - Screen content with ANSI escape codes
1640
+ * @returns {TerminalLine[]}
1641
+ */
1642
+ function parseStyledScreenLines(screen) {
1643
+ if (!screen) return [];
1644
+ return screen.split("\n").map((line) => {
1645
+ const spans = parseAnsiLine(line);
1646
+ // Raw text is spans joined without styles
1647
+ const raw = spans.map((s) => s.text).join("");
1648
+ return { spans, raw };
1649
+ });
1650
+ }
1651
+
1652
+ /**
1653
+ * Find a line matching the given query.
1654
+ * Style filters are ignored when lines don't have style information.
1655
+ * @param {TerminalLine[]} lines - Lines to search
1656
+ * @param {MatchQuery} query - Query with pattern and optional style filter
1657
+ * @returns {MatchResult}
1658
+ */
1659
+ function findMatch(lines, query) {
1660
+ const { pattern, style } = query;
1661
+
1662
+ for (let i = 0; i < lines.length; i++) {
1663
+ const line = lines[i];
1664
+ const text = line.raw;
1665
+
1666
+ // Check pattern match
1667
+ const patternMatches =
1668
+ typeof pattern === "string" ? text.includes(pattern) : pattern.test(text);
1669
+
1670
+ if (!patternMatches) continue;
1671
+
1672
+ // If no style filter requested, we have a match
1673
+ if (!style) {
1674
+ return { matched: true, line, lineIndex: i };
1675
+ }
1676
+
1677
+ // Check style match (if line has styled spans)
1678
+ // Style filter is silently ignored if implementation doesn't provide styles
1679
+ const hasStyledSpans = line.spans.some((span) => span.style);
1680
+ if (!hasStyledSpans) {
1681
+ // No style info available - pattern match is enough
1682
+ return { matched: true, line, lineIndex: i };
1683
+ }
1684
+
1685
+ // Check if any span matches both pattern and style
1686
+ const styleMatches = line.spans.some((span) => {
1687
+ if (!span.style) return false;
1688
+ const spanMatchesPattern =
1689
+ typeof pattern === "string" ? span.text.includes(pattern) : pattern.test(span.text);
1690
+ if (!spanMatchesPattern) return false;
1691
+
1692
+ // Check each requested style property
1693
+ const spanStyle = /** @type {Record<string, unknown>} */ (span.style);
1694
+ for (const [key, value] of Object.entries(style)) {
1695
+ if (spanStyle[key] !== value) return false;
1696
+ }
1697
+ return true;
1698
+ });
1699
+
1700
+ if (styleMatches) {
1701
+ return { matched: true, line, lineIndex: i };
1702
+ }
1703
+ }
1704
+
1705
+ return { matched: false };
1706
+ }
1707
+
1708
+ // =============================================================================
1709
+ // Terminal Stream Implementations
1710
+ // =============================================================================
1711
+
1712
+ /**
1713
+ * Terminal stream that reads from JSONL log files (Claude/Codex logs).
1714
+ * Implements TerminalStream interface.
1715
+ * @implements {TerminalStream}
1716
+ */
1717
+ class JsonlTerminalStream {
1718
+ /** @type {() => string | null} */
1719
+ logPathFinder;
1720
+ /** @type {'claude' | 'codex'} */
1721
+ format;
1722
+ /** @type {string | null} */
1723
+ logPath;
1724
+ /** @type {number} */
1725
+ offset;
1726
+ /** @type {boolean} */
1727
+ skipExisting;
1728
+ /** @type {boolean} */
1729
+ initialized;
1730
+
1731
+ /**
1732
+ * @param {() => string | null} logPathFinder - Function that returns current log path (may change during session)
1733
+ * @param {'claude' | 'codex'} format - Log format for parsing entries
1734
+ * @param {{skipExisting?: boolean}} [opts] - Options
1735
+ */
1736
+ constructor(logPathFinder, format, opts = {}) {
1737
+ this.logPathFinder = logPathFinder;
1738
+ this.format = format;
1739
+ this.logPath = null;
1740
+ this.offset = 0;
1741
+ this.skipExisting = opts.skipExisting ?? false;
1742
+ this.initialized = false;
1743
+ }
1744
+
1745
+ /**
1746
+ * Read new lines since last read.
1747
+ * @param {ReadOptions} [opts]
1748
+ * @returns {Promise<TerminalLine[]>}
1749
+ */
1750
+ async readNext(opts = {}) {
1751
+ // Check for new/changed log path
1752
+ const currentLogPath = this.logPathFinder();
1753
+ if (currentLogPath && currentLogPath !== this.logPath) {
1754
+ this.logPath = currentLogPath;
1755
+ if (existsSync(this.logPath)) {
1756
+ if (this.skipExisting && !this.initialized) {
1757
+ // Skip to end of file - only read new content
1758
+ this.offset = statSync(this.logPath).size;
1759
+ this.initialized = true;
1760
+ } else {
1761
+ // Read from beginning
1762
+ this.offset = 0;
1763
+ }
1764
+ }
1765
+ }
1766
+
1767
+ if (!this.logPath) {
1768
+ return [];
1769
+ }
1770
+
1771
+ const { entries, newOffset } = tailJsonl(this.logPath, this.offset);
1772
+ this.offset = newOffset;
1773
+
1774
+ const lines = [];
1775
+ for (const entry of entries) {
1776
+ const entryLines = parseJsonlEntry(entry, this.format);
1777
+ lines.push(...entryLines);
1778
+ }
1779
+
1780
+ if (opts.max && lines.length > opts.max) {
1781
+ return lines.slice(0, opts.max);
1782
+ }
1783
+
1784
+ return lines;
1785
+ }
1786
+
1787
+ /**
1788
+ * Wait for a line matching the query.
1789
+ * @param {MatchQuery} query
1790
+ * @param {WaitOptions} [opts]
1791
+ * @returns {Promise<MatchResult>}
1792
+ */
1793
+ async waitForMatch(query, opts = {}) {
1794
+ const timeoutMs = opts.timeoutMs || 30000;
1795
+ const pollInterval = 100;
1796
+ const deadline = Date.now() + timeoutMs;
1797
+
1798
+ while (Date.now() < deadline) {
1799
+ const lines = await this.readNext();
1800
+ if (lines.length > 0) {
1801
+ const result = findMatch(lines, query);
1802
+ if (result.matched) {
1803
+ return result;
1804
+ }
1805
+ }
1806
+ await sleep(pollInterval);
1807
+ }
1808
+
1809
+ return { matched: false };
1810
+ }
1811
+ }
1812
+
1813
+ /**
1814
+ * Terminal stream that reads from tmux screen capture.
1815
+ * Implements TerminalStream interface.
1816
+ * @implements {TerminalStream}
1817
+ */
1818
+ class ScreenTerminalStream {
1819
+ /**
1820
+ * @param {string} session - tmux session name
1821
+ * @param {number} [scrollback] - Number of scrollback lines to capture
1822
+ */
1823
+ constructor(session, scrollback = 0) {
1824
+ this.session = session;
1825
+ this.scrollback = scrollback;
1826
+ this.lastScreen = "";
1827
+ }
1828
+
1829
+ /**
1830
+ * Read current screen lines (returns all visible lines on each call).
1831
+ * Note: Unlike JsonlTerminalStream, this returns the full screen each time.
1832
+ * @param {ReadOptions} [opts]
1833
+ * @returns {Promise<TerminalLine[]>}
1834
+ */
1835
+ async readNext(opts = {}) {
1836
+ const screen = tmuxCapture(this.session, this.scrollback);
1837
+ this.lastScreen = screen;
1838
+
1839
+ const lines = parseScreenLines(screen);
1840
+
1841
+ if (opts.max && lines.length > opts.max) {
1842
+ return lines.slice(-opts.max); // Return last N lines for screen capture
1843
+ }
1844
+
1845
+ return lines;
1846
+ }
1847
+
1848
+ /**
1849
+ * Wait for a line matching the query.
1850
+ * @param {MatchQuery} query
1851
+ * @param {WaitOptions} [opts]
1852
+ * @returns {Promise<MatchResult>}
1853
+ */
1854
+ async waitForMatch(query, opts = {}) {
1855
+ const timeoutMs = opts.timeoutMs || 30000;
1856
+ const pollInterval = 100;
1857
+ const deadline = Date.now() + timeoutMs;
1858
+
1859
+ while (Date.now() < deadline) {
1860
+ const lines = await this.readNext();
1861
+ const result = findMatch(lines, query);
1862
+ if (result.matched) {
1863
+ return result;
1864
+ }
1865
+ await sleep(pollInterval);
1866
+ }
1867
+
1868
+ return { matched: false };
1869
+ }
1870
+
1871
+ /**
1872
+ * Get the last captured screen (raw string).
1873
+ * Useful for compatibility with existing code that needs raw screen.
1874
+ * @returns {string}
1875
+ */
1876
+ getLastScreen() {
1877
+ return this.lastScreen;
1878
+ }
1879
+ }
1880
+
1881
+ /**
1882
+ * Terminal stream that reads from tmux screen capture with ANSI styling.
1883
+ * Uses `tmux capture-pane -e` to capture escape sequences.
1884
+ * Implements TerminalStream interface.
1885
+ * @implements {TerminalStream}
1886
+ */
1887
+ class StyledScreenTerminalStream {
1888
+ /**
1889
+ * @param {string} session - tmux session name
1890
+ * @param {number} [scrollback] - Number of scrollback lines to capture
1891
+ */
1892
+ constructor(session, scrollback = 0) {
1893
+ this.session = session;
1894
+ this.scrollback = scrollback;
1895
+ this.lastScreen = "";
1896
+ }
1897
+
1898
+ /**
1899
+ * Read current screen lines with ANSI styling parsed.
1900
+ * @param {ReadOptions} [opts]
1901
+ * @returns {Promise<TerminalLine[]>}
1902
+ */
1903
+ async readNext(opts = {}) {
1904
+ const screen = tmuxCapture(this.session, this.scrollback, true); // withEscapes=true
1905
+ this.lastScreen = screen;
1906
+
1907
+ const lines = parseStyledScreenLines(screen);
1908
+
1909
+ if (opts.max && lines.length > opts.max) {
1910
+ return lines.slice(-opts.max); // Return last N lines for screen capture
1911
+ }
1912
+
1913
+ return lines;
1914
+ }
1915
+
1916
+ /**
1917
+ * Wait for a line matching the query (supports style-aware matching).
1918
+ * @param {MatchQuery} query
1919
+ * @param {WaitOptions} [opts]
1920
+ * @returns {Promise<MatchResult>}
1921
+ */
1922
+ async waitForMatch(query, opts = {}) {
1923
+ const timeoutMs = opts.timeoutMs || 30000;
1924
+ const pollInterval = 100;
1925
+ const deadline = Date.now() + timeoutMs;
1926
+
1927
+ while (Date.now() < deadline) {
1928
+ const lines = await this.readNext();
1929
+ const result = findMatch(lines, query);
1930
+ if (result.matched) {
1931
+ return result;
1932
+ }
1933
+ await sleep(pollInterval);
1934
+ }
1935
+
1936
+ return { matched: false };
1937
+ }
1938
+
1939
+ /**
1940
+ * Get the last captured screen (raw string with ANSI codes).
1941
+ * @returns {string}
1942
+ */
1943
+ getLastScreen() {
1944
+ return this.lastScreen;
1945
+ }
1946
+ }
1947
+
1948
+ /**
1949
+ * Fake terminal stream for testing.
1950
+ * Implements TerminalStream interface.
1951
+ * @implements {TerminalStream}
1952
+ */
1953
+ class FakeTerminalStream {
1954
+ /**
1955
+ * @param {TerminalLine[]} lines - Initial lines to provide
1956
+ */
1957
+ constructor(lines = []) {
1958
+ this.lines = [...lines];
1959
+ this.readCount = 0;
1960
+ /** @type {TerminalLine[][]} */
1961
+ this.pendingLines = [];
1962
+ }
1963
+
1964
+ /**
1965
+ * Queue lines to be returned on subsequent readNext calls.
1966
+ * @param {TerminalLine[]} lines
1967
+ */
1968
+ queueLines(lines) {
1969
+ this.pendingLines.push(lines);
1970
+ }
1971
+
1972
+ /**
1973
+ * Add more lines to the current buffer (simulates new output).
1974
+ * @param {TerminalLine[]} lines
1975
+ */
1976
+ addLines(lines) {
1977
+ this.lines.push(...lines);
1978
+ }
1979
+
1980
+ /**
1981
+ * Read new lines since last read.
1982
+ * First call returns initial lines, subsequent calls return queued lines.
1983
+ * @param {ReadOptions} [opts]
1984
+ * @returns {Promise<TerminalLine[]>}
1985
+ */
1986
+ async readNext(opts = {}) {
1987
+ this.readCount++;
1988
+
1989
+ /** @type {TerminalLine[]} */
1990
+ let result = [];
1991
+ if (this.readCount === 1) {
1992
+ result = this.lines;
1993
+ } else if (this.pendingLines.length > 0) {
1994
+ result = this.pendingLines.shift() || [];
1995
+ }
1996
+
1997
+ if (opts.max && result.length > opts.max) {
1998
+ return result.slice(0, opts.max);
1999
+ }
2000
+
2001
+ return result;
2002
+ }
2003
+
2004
+ /**
2005
+ * Wait for a line matching the query.
2006
+ * Immediately checks available lines without polling.
2007
+ * @param {MatchQuery} query
2008
+ * @param {WaitOptions} [_opts]
2009
+ * @returns {Promise<MatchResult>}
2010
+ */
2011
+ async waitForMatch(query, _opts = {}) {
2012
+ // Check initial lines
2013
+ const result = findMatch(this.lines, query);
2014
+ if (result.matched) {
2015
+ return result;
2016
+ }
2017
+
2018
+ // Check all pending lines
2019
+ for (const pendingBatch of this.pendingLines) {
2020
+ const batchResult = findMatch(pendingBatch, query);
2021
+ if (batchResult.matched) {
2022
+ return batchResult;
2023
+ }
2024
+ }
2025
+
2026
+ return { matched: false };
2027
+ }
2028
+
2029
+ /**
2030
+ * Create a TerminalLine from a raw string (helper for tests).
2031
+ * @param {string} raw
2032
+ * @returns {TerminalLine}
2033
+ */
2034
+ static line(raw) {
2035
+ return { spans: [{ text: raw }], raw };
2036
+ }
2037
+
2038
+ /**
2039
+ * Create multiple TerminalLines from raw strings (helper for tests).
2040
+ * @param {string[]} raws
2041
+ * @returns {TerminalLine[]}
2042
+ */
2043
+ static lines(raws) {
2044
+ return raws.map((raw) => FakeTerminalStream.line(raw));
2045
+ }
2046
+ }
2047
+
1123
2048
  /**
1124
2049
  * Extract pending tool from confirmation screen.
1125
2050
  * @param {string} screen
@@ -2100,6 +3025,36 @@ const State = {
2100
3025
  FEEDBACK_MODAL: "feedback_modal",
2101
3026
  };
2102
3027
 
3028
+ /**
3029
+ * Check if the prompt symbol appears with bold styling in the last lines.
3030
+ * Used to distinguish actual prompts from text that happens to contain the symbol.
3031
+ * @param {string} session - tmux session name
3032
+ * @param {string} promptSymbol - The prompt symbol to look for
3033
+ * @returns {boolean}
3034
+ */
3035
+ function hasStyledPrompt(session, promptSymbol) {
3036
+ const styledScreen = tmuxCapture(session, 0, true); // withEscapes=true
3037
+
3038
+ // If styled capture fails, fall back to allowing READY to avoid deadlock
3039
+ if (!styledScreen) {
3040
+ debug("state", "styled capture failed, falling back to unstyled check");
3041
+ return true;
3042
+ }
3043
+
3044
+ // Trim to match detectState behavior (removes trailing blank lines)
3045
+ const lines = parseStyledScreenLines(styledScreen.trim());
3046
+ const lastLines = lines.slice(-8);
3047
+
3048
+ for (const line of lastLines) {
3049
+ for (const span of line.spans) {
3050
+ if (span.text.includes(promptSymbol) && span.style?.bold) {
3051
+ return true;
3052
+ }
3053
+ }
3054
+ }
3055
+ return false;
3056
+ }
3057
+
2103
3058
  /**
2104
3059
  * Pure function to detect agent state from screen content.
2105
3060
  * @param {string} screen - The screen content to analyze
@@ -2108,8 +3063,11 @@ const State = {
2108
3063
  * @param {string[]} [config.spinners] - Spinner characters indicating thinking
2109
3064
  * @param {RegExp} [config.rateLimitPattern] - Pattern for rate limit detection
2110
3065
  * @param {(string | RegExp | ((lines: string) => boolean))[]} [config.thinkingPatterns] - Text patterns indicating thinking
3066
+ * @param {(string | RegExp)[]} [config.activeWorkPatterns] - Patterns indicating active work (beats ready)
2111
3067
  * @param {(string | ((lines: string) => boolean))[]} [config.confirmPatterns] - Patterns for confirmation dialogs
2112
3068
  * @param {{screen: string[], lastLines: string[]} | null} [config.updatePromptPatterns] - Patterns for update prompts
3069
+ * @param {string} [config.session] - tmux session for styled prompt verification
3070
+ * @param {boolean} [config.requireStyledPrompt] - If true, require prompt to be bold (Codex)
2113
3071
  * @returns {string} The detected state
2114
3072
  */
2115
3073
  function detectState(screen, config) {
@@ -2176,8 +3134,18 @@ function detectState(screen, config) {
2176
3134
  // Ready - check BEFORE thinking to avoid false positives from timing messages like "✻ Worked for 45s"
2177
3135
  // If the prompt symbol is visible, the agent is ready regardless of spinner characters in timing messages
2178
3136
  if (lastLines.includes(config.promptSymbol)) {
2179
- debug("state", `promptSymbol "${config.promptSymbol}" found -> READY`);
2180
- return State.READY;
3137
+ // If styled prompt check is enabled, verify prompt has expected styling
3138
+ // This prevents false positives from output containing the prompt symbol
3139
+ if (config.requireStyledPrompt && config.session) {
3140
+ if (hasStyledPrompt(config.session, config.promptSymbol)) {
3141
+ debug("state", `promptSymbol "${config.promptSymbol}" found with bold styling -> READY`);
3142
+ return State.READY;
3143
+ }
3144
+ debug("state", `promptSymbol "${config.promptSymbol}" found but not bold, continuing checks`);
3145
+ } else {
3146
+ debug("state", `promptSymbol "${config.promptSymbol}" found -> READY`);
3147
+ return State.READY;
3148
+ }
2181
3149
  }
2182
3150
 
2183
3151
  // Thinking - spinners (check last lines only)
@@ -2250,7 +3218,7 @@ function detectState(screen, config) {
2250
3218
  * @property {string} [safeAllowedTools]
2251
3219
  * @property {string | null} [sessionIdFlag]
2252
3220
  * @property {((sessionName: string) => string | null) | null} [logPathFinder]
2253
- * @property {((entry: object) => string | null) | null} [logEntryFormatter]
3221
+ * @property {boolean} [requireStyledPrompt] - If true, require prompt to be bold for READY detection
2254
3222
  */
2255
3223
 
2256
3224
  class Agent {
@@ -2298,8 +3266,8 @@ class Agent {
2298
3266
  this.sessionIdFlag = config.sessionIdFlag || null;
2299
3267
  /** @type {((sessionName: string) => string | null) | null} */
2300
3268
  this.logPathFinder = config.logPathFinder || null;
2301
- /** @type {((entry: object) => string | null) | null} */
2302
- this.logEntryFormatter = config.logEntryFormatter || null;
3269
+ /** @type {boolean} */
3270
+ this.requireStyledPrompt = config.requireStyledPrompt || false;
2303
3271
  }
2304
3272
 
2305
3273
  /**
@@ -2447,22 +3415,41 @@ class Agent {
2447
3415
  }
2448
3416
 
2449
3417
  /**
2450
- * Format a log entry for streaming display.
2451
- * @param {object} entry
2452
- * @returns {string | null}
3418
+ * Create a terminal stream for reading agent output.
3419
+ * Returns JsonlTerminalStream for agents with log file support,
3420
+ * otherwise falls back to ScreenTerminalStream.
3421
+ * @param {string} sessionName
3422
+ * @param {{skipExisting?: boolean}} [opts] - Options
3423
+ * @returns {TerminalStream}
2453
3424
  */
2454
- formatLogEntry(entry) {
2455
- if (this.logEntryFormatter) {
2456
- return this.logEntryFormatter(entry);
3425
+ createStream(sessionName, opts = {}) {
3426
+ // Prefer JSONL stream if agent has log path finder
3427
+ if (this.logPathFinder) {
3428
+ /** @type {'claude' | 'codex'} */
3429
+ const format = this.name === "claude" ? "claude" : "codex";
3430
+ return new JsonlTerminalStream(() => this.findLogPath(sessionName), format, opts);
2457
3431
  }
2458
- return null;
3432
+ // Fall back to screen capture
3433
+ return new ScreenTerminalStream(sessionName);
3434
+ }
3435
+
3436
+ /**
3437
+ * Create a styled terminal stream with ANSI color support.
3438
+ * Only uses screen capture (JSONL doesn't have style info).
3439
+ * @param {string} sessionName
3440
+ * @param {number} [scrollback]
3441
+ * @returns {StyledScreenTerminalStream}
3442
+ */
3443
+ createStyledStream(sessionName, scrollback = 0) {
3444
+ return new StyledScreenTerminalStream(sessionName, scrollback);
2459
3445
  }
2460
3446
 
2461
3447
  /**
2462
3448
  * @param {string} screen
3449
+ * @param {string} [session] - Optional session for styled prompt verification
2463
3450
  * @returns {string}
2464
3451
  */
2465
- getState(screen) {
3452
+ getState(screen, session) {
2466
3453
  return detectState(screen, {
2467
3454
  promptSymbol: this.promptSymbol,
2468
3455
  spinners: this.spinners,
@@ -2471,6 +3458,8 @@ class Agent {
2471
3458
  activeWorkPatterns: this.activeWorkPatterns,
2472
3459
  confirmPatterns: this.confirmPatterns,
2473
3460
  updatePromptPatterns: this.updatePromptPatterns,
3461
+ session,
3462
+ requireStyledPrompt: this.requireStyledPrompt,
2474
3463
  });
2475
3464
  }
2476
3465
 
@@ -2704,7 +3693,7 @@ const CodexAgent = new Agent({
2704
3693
  reviewOptions: { branch: "1", uncommitted: "2", commit: "3", custom: "4" },
2705
3694
  envVar: "AX_SESSION",
2706
3695
  logPathFinder: findCodexLogPath,
2707
- logEntryFormatter: formatCodexLogEntry,
3696
+ requireStyledPrompt: true, // Codex prompt is bold, use this to avoid false positives
2708
3697
  });
2709
3698
 
2710
3699
  // =============================================================================
@@ -2722,7 +3711,7 @@ const ClaudeAgent = new Agent({
2722
3711
  rateLimitPattern: /rate.?limit/i,
2723
3712
  // Claude uses whimsical verbs like "Wibbling…", "Dancing…", etc. Match any capitalized -ing word + ellipsis (… or ...)
2724
3713
  thinkingPatterns: ["Thinking", /[A-Z][a-z]+ing(…|\.\.\.)/],
2725
- activeWorkPatterns: ["[Pasted text", "esc to interrupt"],
3714
+ activeWorkPatterns: ["esc to interrupt"],
2726
3715
  confirmPatterns: [
2727
3716
  "Do you want to make this edit",
2728
3717
  "Do you want to run this command",
@@ -2754,7 +3743,6 @@ const ClaudeAgent = new Agent({
2754
3743
  if (uuid) return findClaudeLogPath(uuid, sessionName);
2755
3744
  return null;
2756
3745
  },
2757
- logEntryFormatter: formatClaudeLogEntry,
2758
3746
  });
2759
3747
 
2760
3748
  // =============================================================================
@@ -2772,7 +3760,7 @@ const ClaudeAgent = new Agent({
2772
3760
  async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2773
3761
  const start = Date.now();
2774
3762
  const initialScreen = tmuxCapture(session);
2775
- const initialState = agent.getState(initialScreen);
3763
+ const initialState = agent.getState(initialScreen, session);
2776
3764
  debug("waitUntilReady", `start: initialState=${initialState}, timeout=${timeoutMs}ms`);
2777
3765
 
2778
3766
  // Dismiss feedback modal if present
@@ -2793,7 +3781,7 @@ async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2793
3781
  while (Date.now() - start < timeoutMs) {
2794
3782
  await sleep(POLL_MS);
2795
3783
  const screen = tmuxCapture(session);
2796
- const state = agent.getState(screen);
3784
+ const state = agent.getState(screen, session);
2797
3785
 
2798
3786
  // Dismiss feedback modal if it appears
2799
3787
  if (state === State.FEEDBACK_MODAL) {
@@ -2824,7 +3812,7 @@ async function pollForResponse(agent, session, timeoutMs, hooks = {}) {
2824
3812
  const { onPoll, onStateChange, onReady } = hooks;
2825
3813
  const start = Date.now();
2826
3814
  const initialScreen = tmuxCapture(session);
2827
- const initialState = agent.getState(initialScreen);
3815
+ const initialState = agent.getState(initialScreen, session);
2828
3816
  debug("poll", `start: initialState=${initialState}, timeoutMs=${timeoutMs}`);
2829
3817
 
2830
3818
  let lastScreen = initialScreen;
@@ -2840,7 +3828,7 @@ async function pollForResponse(agent, session, timeoutMs, hooks = {}) {
2840
3828
 
2841
3829
  while (Date.now() - start < timeoutMs) {
2842
3830
  const screen = tmuxCapture(session);
2843
- const state = agent.getState(screen);
3831
+ const state = agent.getState(screen, session);
2844
3832
 
2845
3833
  if (onPoll) onPoll(screen, state);
2846
3834
 
@@ -2906,16 +3894,19 @@ async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2906
3894
 
2907
3895
  /**
2908
3896
  * Wait for agent response with streaming output to console.
3897
+ * Uses TerminalStream abstraction for reading agent output.
2909
3898
  * @param {Agent} agent
2910
3899
  * @param {string} session
2911
3900
  * @param {number} [timeoutMs]
2912
3901
  * @returns {Promise<{state: string, screen: string}>}
2913
3902
  */
2914
3903
  async function streamResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2915
- let logPath = agent.findLogPath(session);
2916
- let logOffset = logPath && existsSync(logPath) ? statSync(logPath).size : 0;
3904
+ // Create terminal stream for this agent/session
3905
+ // Skip existing content - only stream new responses
3906
+ const stream = agent.createStream(session, { skipExisting: true });
2917
3907
  let printedThinking = false;
2918
- debug("stream", `start: logPath=${logPath || "null"}, logOffset=${logOffset}`);
3908
+ debug("stream", `start: using ${stream.constructor.name}`);
3909
+
2919
3910
  // Sliding window for deduplication - only dedupe recent messages
2920
3911
  // This catches Codex's duplicate log entries (A,B,A,B pattern) while
2921
3912
  // allowing legitimate repeated messages across turns
@@ -2923,54 +3914,50 @@ async function streamResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2923
3914
  const recentMessages = [];
2924
3915
  const DEDUPE_WINDOW = 10;
2925
3916
 
2926
- const streamNewEntries = () => {
2927
- if (!logPath) {
2928
- logPath = agent.findLogPath(session);
2929
- if (logPath && existsSync(logPath)) {
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;
2934
- }
3917
+ const streamNewLines = async () => {
3918
+ const lines = await stream.readNext();
3919
+ if (lines.length > 0) {
3920
+ debug("stream", `read ${lines.length} lines`);
2935
3921
  }
2936
- if (logPath) {
2937
- const { entries, newOffset } = tailJsonl(logPath, logOffset);
2938
- if (entries.length > 0) {
2939
- debug("stream", `read ${entries.length} entries, offset ${logOffset} -> ${newOffset}`);
2940
- }
2941
- logOffset = newOffset;
2942
- for (const entry of entries) {
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
3922
 
2954
- console.log(formatted);
3923
+ for (const line of lines) {
3924
+ const text = line.raw;
3925
+ if (!text) continue;
3926
+
3927
+ // Dedupe messages within sliding window (Codex logs can contain duplicates)
3928
+ // Tool calls are exempt: lineType === "tool" for JSONL streams, or starts with ">" for screen streams
3929
+ const isToolLine = line.lineType === "tool" || (!line.lineType && text.startsWith(">"));
3930
+ if (!isToolLine) {
3931
+ if (recentMessages.includes(text)) continue;
3932
+ recentMessages.push(text);
3933
+ if (recentMessages.length > DEDUPE_WINDOW) recentMessages.shift();
2955
3934
  }
3935
+
3936
+ // Style based on content type
3937
+ // For screen streams, tool lines start with ">" and should be dimmed
3938
+ const isThinking = line.lineType === "thinking";
3939
+ const styled = isToolLine || isThinking ? styleText("dim", text) : text;
3940
+ console.log(styled);
2956
3941
  }
2957
3942
  };
2958
3943
 
2959
3944
  return pollForResponse(agent, session, timeoutMs, {
2960
- onPoll: () => streamNewEntries(),
3945
+ onPoll: () => streamNewLines(),
2961
3946
  onStateChange: (state, lastState, screen) => {
2962
3947
  if (state === State.THINKING && !printedThinking) {
2963
- console.log("[THINKING]");
3948
+ console.log(styleText("dim", "[THINKING]"));
2964
3949
  printedThinking = true;
2965
3950
  } else if (state === State.CONFIRMING) {
2966
3951
  const pendingTool = extractPendingToolFromScreen(screen);
2967
- console.log(pendingTool ? `[CONFIRMING] ${pendingTool}` : "[CONFIRMING]");
3952
+ console.log(
3953
+ styleText("yellow", pendingTool ? `[CONFIRMING] ${pendingTool}` : "[CONFIRMING]"),
3954
+ );
2968
3955
  }
2969
3956
  if (lastState === State.THINKING && state !== State.THINKING) {
2970
3957
  printedThinking = false;
2971
3958
  }
2972
3959
  },
2973
- onReady: () => streamNewEntries(),
3960
+ onReady: () => streamNewLines(),
2974
3961
  });
2975
3962
  }
2976
3963
 
@@ -3046,7 +4033,7 @@ async function cmdStart(agent, session, { yolo = false, allowedTools = null } =
3046
4033
  const start = Date.now();
3047
4034
  while (Date.now() - start < STARTUP_TIMEOUT_MS) {
3048
4035
  const screen = tmuxCapture(session);
3049
- const state = agent.getState(screen);
4036
+ const state = agent.getState(screen, session);
3050
4037
 
3051
4038
  if (state === State.UPDATE_PROMPT) {
3052
4039
  await agent.handleUpdatePrompt(session);
@@ -3107,7 +4094,7 @@ function cmdAgents() {
3107
4094
  const parsed = /** @type {ParsedSession} */ (parseSessionName(session));
3108
4095
  const agent = parsed.tool === "claude" ? ClaudeAgent : CodexAgent;
3109
4096
  const screen = tmuxCapture(session);
3110
- const state = agent.getState(screen);
4097
+ const state = agent.getState(screen, session);
3111
4098
  const type = parsed.archangelName ? "archangel" : "-";
3112
4099
  const isDefault =
3113
4100
  (parsed.tool === "claude" && session === claudeDefault) ||
@@ -3270,7 +4257,7 @@ async function cmdArchangel(agentName) {
3270
4257
  const start = Date.now();
3271
4258
  while (Date.now() - start < ARCHANGEL_STARTUP_TIMEOUT_MS) {
3272
4259
  const screen = tmuxCapture(sessionName);
3273
- const state = agent.getState(screen);
4260
+ const state = agent.getState(screen, sessionName);
3274
4261
 
3275
4262
  if (state === State.UPDATE_PROMPT) {
3276
4263
  await agent.handleUpdatePrompt(sessionName);
@@ -3436,7 +4423,7 @@ async function cmdArchangel(agentName) {
3436
4423
 
3437
4424
  // Wait for ready
3438
4425
  const screen = tmuxCapture(sessionName);
3439
- const state = agent.getState(screen);
4426
+ const state = agent.getState(screen, sessionName);
3440
4427
 
3441
4428
  if (state === State.RATE_LIMITED) {
3442
4429
  console.error(`[archangel:${agentName}] Rate limited - stopping`);
@@ -4519,9 +5506,13 @@ async function cmdAsk(
4519
5506
  ? /** @type {string} */ (session)
4520
5507
  : await cmdStart(agent, session, { yolo, allowedTools });
4521
5508
 
4522
- tmuxSendLiteral(activeSession, message);
4523
- await sleep(200);
4524
- tmuxSend(activeSession, "Enter");
5509
+ if (sessionExists) {
5510
+ await waitUntilReady(agent, activeSession, timeoutMs);
5511
+ tmuxSend(activeSession, "C-u"); // Clear any stale input
5512
+ await sleep(50);
5513
+ }
5514
+
5515
+ await tmuxSendText(activeSession, message);
4525
5516
 
4526
5517
  if (noWait) {
4527
5518
  const parsed = parseSessionName(activeSession);
@@ -4566,6 +5557,92 @@ e.g.
4566
5557
  }
4567
5558
  }
4568
5559
 
5560
+ /**
5561
+ * @param {Agent} agent
5562
+ * @param {string} prompt
5563
+ * @param {{name?: string, maxLoops?: number, loop?: boolean, reset?: boolean, session?: string | null, yolo?: boolean, timeoutMs?: number}} [options]
5564
+ */
5565
+ async function cmdDo(agent, prompt, options = {}) {
5566
+ const maxLoops = options.maxLoops || 10;
5567
+ const name = options.name || "default";
5568
+ const loop = options.loop || false;
5569
+ const reset = options.reset || false;
5570
+ const yolo = options.yolo || false;
5571
+ const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
5572
+
5573
+ // Reset progress file if requested
5574
+ if (reset) {
5575
+ const progressPath = getDoProgressPath(name);
5576
+ writeFileSync(progressPath, "");
5577
+ }
5578
+
5579
+ // Use provided session or start a new one
5580
+ const session = options.session
5581
+ ? await cmdStart(agent, options.session, { yolo })
5582
+ : await cmdStart(agent, null, { yolo });
5583
+
5584
+ // Print session ID for targeting approvals when not in yolo mode
5585
+ if (!yolo) {
5586
+ const parsed = parseSessionName(session);
5587
+ const shortId = parsed?.uuid?.slice(0, 8) || session;
5588
+ console.error(`Session: ${shortId}`);
5589
+ }
5590
+
5591
+ const iterations = loop ? maxLoops : 1;
5592
+
5593
+ for (let i = 0; i < iterations; i++) {
5594
+ // Fresh context (except first iteration)
5595
+ if (i > 0) {
5596
+ tmuxSendLiteral(session, "/new");
5597
+ tmuxSend(session, "Enter");
5598
+ await waitUntilReady(agent, session, timeoutMs);
5599
+ }
5600
+
5601
+ // Build prompt with preamble + progress context
5602
+ const fullPrompt = buildDoPrompt(prompt, name);
5603
+
5604
+ // Send prompt and submit
5605
+ await tmuxSendText(session, fullPrompt);
5606
+
5607
+ const { state, screen } = yolo
5608
+ ? await autoApproveLoop(agent, session, timeoutMs, streamResponse)
5609
+ : await streamResponse(agent, session, timeoutMs);
5610
+
5611
+ if (state === State.RATE_LIMITED) {
5612
+ console.log(`\nRate limited: ${agent.parseRetryTime(screen)}`);
5613
+ process.exit(2);
5614
+ }
5615
+
5616
+ if (state === State.CONFIRMING) {
5617
+ const parsed = parseSessionName(session);
5618
+ const shortId = parsed?.uuid?.slice(0, 8) || session;
5619
+ console.log(`\nAwaiting confirmation: ${formatConfirmationOutput(screen, agent)}`);
5620
+ console.log(`Add --session=${shortId} if you have multiple sessions`);
5621
+ console.log("Use 'ax approve --wait' or 'ax reject' to continue");
5622
+ process.exit(3);
5623
+ }
5624
+
5625
+ const response = agent.getResponse(session, screen) || "";
5626
+
5627
+ // Check completion
5628
+ if (response.includes("<promise>COMPLETE</promise>")) {
5629
+ console.log(`\nCompleted after ${i + 1} iteration(s)`);
5630
+ return;
5631
+ }
5632
+
5633
+ // Single iteration mode (default): exit with code 5 to signal "more work"
5634
+ if (!loop) {
5635
+ console.log(`\nIteration complete. Re-run to continue, or --reset to start over.`);
5636
+ process.exit(5);
5637
+ }
5638
+
5639
+ console.log(`\n--- Iteration ${i + 1}/${maxLoops} complete ---`);
5640
+ }
5641
+
5642
+ console.log(`\nReached max iterations (${maxLoops}) without completion`);
5643
+ process.exit(1);
5644
+ }
5645
+
4569
5646
  /**
4570
5647
  * @param {Agent} agent
4571
5648
  * @param {string | null | undefined} session
@@ -4578,7 +5655,7 @@ async function cmdApprove(agent, session, { wait = false, timeoutMs } = {}) {
4578
5655
  }
4579
5656
 
4580
5657
  const before = tmuxCapture(session);
4581
- const beforeState = agent.getState(before);
5658
+ const beforeState = agent.getState(before, session);
4582
5659
  if (beforeState !== State.CONFIRMING) {
4583
5660
  console.log(`Already ${beforeState}`);
4584
5661
  return;
@@ -4616,7 +5693,7 @@ async function cmdReject(agent, session, { wait = false, timeoutMs } = {}) {
4616
5693
  }
4617
5694
 
4618
5695
  const before = tmuxCapture(session);
4619
- const beforeState = agent.getState(before);
5696
+ const beforeState = agent.getState(before, session);
4620
5697
  if (beforeState !== State.CONFIRMING) {
4621
5698
  console.log(`Already ${beforeState}`);
4622
5699
  return;
@@ -4708,6 +5785,12 @@ async function cmdReview(
4708
5785
  ? /** @type {string} */ (session)
4709
5786
  : await cmdStart(agent, session, { yolo });
4710
5787
 
5788
+ if (sessionExists) {
5789
+ await waitUntilReady(agent, activeSession, timeoutMs);
5790
+ tmuxSend(activeSession, "C-u"); // Clear any stale input
5791
+ await sleep(50);
5792
+ }
5793
+
4711
5794
  debug("review", `Codex path: sending /review command`);
4712
5795
  tmuxSendLiteral(activeSession, "/review");
4713
5796
  await sleep(50);
@@ -4804,7 +5887,7 @@ async function cmdOutput(
4804
5887
  screen = tmuxCapture(session, 500);
4805
5888
  }
4806
5889
 
4807
- const state = agent.getState(screen);
5890
+ const state = agent.getState(screen, session);
4808
5891
 
4809
5892
  if (state === State.RATE_LIMITED) {
4810
5893
  console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
@@ -4843,7 +5926,7 @@ function cmdStatus(agent, session) {
4843
5926
  }
4844
5927
 
4845
5928
  const screen = tmuxCapture(session);
4846
- const state = agent.getState(screen);
5929
+ const state = agent.getState(screen, session);
4847
5930
 
4848
5931
  if (state === State.RATE_LIMITED) {
4849
5932
  console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
@@ -4877,7 +5960,7 @@ function cmdDebug(agent, session, { scrollback = 0 } = {}) {
4877
5960
  }
4878
5961
 
4879
5962
  const screen = tmuxCapture(session, scrollback);
4880
- const state = agent.getState(screen);
5963
+ const state = agent.getState(screen, session);
4881
5964
 
4882
5965
  console.log(`=== Session: ${session} ===`);
4883
5966
  console.log(`=== State: ${state} ===`);
@@ -5048,6 +6131,8 @@ Usage: ${name} [OPTIONS] <command|message> [ARGS...]
5048
6131
  Messaging:
5049
6132
  <message> Send message to ${name}
5050
6133
  review [TYPE] [TARGET] Review code: uncommitted, branch [base], commit [ref], custom
6134
+ do <prompt> Run one iteration (re-run to continue, exit 5 = more work)
6135
+ Options: --name=NAME, --loop, --max-loops=N, --reset, --yolo
5051
6136
 
5052
6137
  Sessions:
5053
6138
  compact Summarise session to shrink context size
@@ -5067,7 +6152,7 @@ Archangels:
5067
6152
  Recovery/State:
5068
6153
  status Exit code: ready=0 rate_limit=2 confirm=3 thinking=4
5069
6154
  output [-N] Show response (0=last, -1=prev, -2=older)
5070
- debug Show raw screen output and detected state
6155
+ debug [SESSION] Show raw screen output and detected state
5071
6156
  approve Approve pending action (send 'y')
5072
6157
  reject Reject pending action (send 'n')
5073
6158
  select N Select menu option N
@@ -5189,7 +6274,7 @@ async function main() {
5189
6274
  const cmd = positionals[0];
5190
6275
 
5191
6276
  // Dispatch commands
5192
- if (cmd === "agents") return cmdAgents();
6277
+ if (cmd === "agents" || cmd === "list") return cmdAgents();
5193
6278
  if (cmd === "target") {
5194
6279
  const defaultSession = agent.getDefaultSession({ allowedTools: autoApprove, yolo });
5195
6280
  if (defaultSession) {
@@ -5204,8 +6289,14 @@ async function main() {
5204
6289
  if (cmd === "recall") return cmdRecall(positionals[1]);
5205
6290
  if (cmd === "archangel") return cmdArchangel(positionals[1]);
5206
6291
  if (cmd === "kill") return cmdKill(session, { all, orphans, force });
5207
- if (cmd === "attach") return cmdAttach(positionals[1] || session);
5208
- if (cmd === "log") return cmdLog(positionals[1] || session, { tail, reasoning, follow });
6292
+ if (cmd === "attach") {
6293
+ const attachSession = positionals[1] ? resolveSessionName(positionals[1]) : session;
6294
+ return cmdAttach(attachSession);
6295
+ }
6296
+ if (cmd === "log") {
6297
+ const logSession = positionals[1] ? resolveSessionName(positionals[1]) : session;
6298
+ return cmdLog(logSession, { tail, reasoning, follow });
6299
+ }
5209
6300
  if (cmd === "mailbox") return cmdMailbox({ limit, branch, all });
5210
6301
  if (cmd === "rfp") {
5211
6302
  if (positionals[1] === "wait") {
@@ -5224,6 +6315,23 @@ async function main() {
5224
6315
  }
5225
6316
  return cmdRfp(prompt, { archangels: flags.archangels, fresh, noWait });
5226
6317
  }
6318
+ if (cmd === "do") {
6319
+ const rawPrompt = positionals.slice(1).join(" ");
6320
+ const prompt = await readStdinIfNeeded(rawPrompt);
6321
+ if (!prompt) {
6322
+ console.log("ERROR: no prompt provided");
6323
+ process.exit(1);
6324
+ }
6325
+ return cmdDo(agent, prompt, {
6326
+ name: flags.name || "default",
6327
+ maxLoops: flags.maxLoops || 10,
6328
+ loop: flags.loop,
6329
+ reset: flags.reset,
6330
+ session: flags.session ? session : null,
6331
+ yolo,
6332
+ timeoutMs,
6333
+ });
6334
+ }
5227
6335
  if (cmd === "approve") return cmdApprove(agent, session, { wait, timeoutMs });
5228
6336
  if (cmd === "reject") return cmdReject(agent, session, { wait, timeoutMs });
5229
6337
  if (cmd === "review") {
@@ -5235,7 +6343,10 @@ async function main() {
5235
6343
  });
5236
6344
  }
5237
6345
  if (cmd === "status") return cmdStatus(agent, session);
5238
- if (cmd === "debug") return cmdDebug(agent, session);
6346
+ if (cmd === "debug") {
6347
+ const debugSession = positionals[1] ? resolveSessionName(positionals[1]) : session;
6348
+ return cmdDebug(agent, debugSession);
6349
+ }
5239
6350
  if (cmd === "output") {
5240
6351
  const indexArg = positionals[1];
5241
6352
  const index = indexArg?.startsWith("-") ? parseInt(indexArg, 10) : 0;
@@ -5244,7 +6355,23 @@ async function main() {
5244
6355
  if (cmd === "send" && positionals.length > 1)
5245
6356
  return cmdSend(session, positionals.slice(1).join(" "));
5246
6357
  if (cmd === "compact") return cmdAsk(agent, session, "/compact", { noWait: true, timeoutMs });
5247
- if (cmd === "reset") return cmdAsk(agent, session, "/new", { noWait: true, timeoutMs });
6358
+ if (cmd === "reset") {
6359
+ // Send /new and wait for completion
6360
+ await cmdAsk(agent, session, "/new", { timeoutMs });
6361
+
6362
+ // Find the newest session UUID and rename tmux session to match
6363
+ if (session && agent.name === "claude") {
6364
+ const newUuid = findNewestClaudeSessionUuid(session);
6365
+ if (newUuid) {
6366
+ const newName = rebuildSessionName(session, newUuid);
6367
+ if (newName && newName !== session) {
6368
+ tmuxRenameSession(session, newName);
6369
+ console.log(`Session: ${newName}`);
6370
+ }
6371
+ }
6372
+ }
6373
+ return;
6374
+ }
5248
6375
  if (cmd === "select" && positionals[1])
5249
6376
  return cmdSelect(agent, session, positionals[1], { wait, timeoutMs });
5250
6377
 
@@ -5318,6 +6445,17 @@ export {
5318
6445
  computePermissionHash,
5319
6446
  formatClaudeLogEntry,
5320
6447
  formatCodexLogEntry,
6448
+ // Terminal stream primitives
6449
+ parseJsonlEntry,
6450
+ parseScreenLines,
6451
+ parseAnsiLine,
6452
+ parseStyledScreenLines,
6453
+ findMatch,
6454
+ // Terminal stream implementations
6455
+ JsonlTerminalStream,
6456
+ ScreenTerminalStream,
6457
+ StyledScreenTerminalStream,
6458
+ FakeTerminalStream,
5321
6459
  CodexAgent,
5322
6460
  ClaudeAgent,
5323
6461
  };