ax-agents 0.1.3 → 0.1.4

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 +764 -51
  2. package/package.json +1 -1
package/ax.js CHANGED
@@ -134,6 +134,71 @@ const VERSION = packageJson.version;
134
134
  * @property {{UserPromptSubmit?: ClaudeHookEntry[], PreToolUse?: ClaudeHookEntry[], Stop?: ClaudeHookEntry[], [key: string]: ClaudeHookEntry[] | undefined}} [hooks]
135
135
  */
136
136
 
137
+ // =============================================================================
138
+ // Terminal Stream Types - Abstraction layer for terminal I/O
139
+ // =============================================================================
140
+
141
+ /**
142
+ * Style properties for terminal text (ANSI colors, formatting)
143
+ * @typedef {Object} TerminalStyle
144
+ * @property {string} [fg] - Foreground color (e.g., "red", "green", "#ff0000")
145
+ * @property {string} [bg] - Background color
146
+ * @property {boolean} [bold] - Bold text
147
+ * @property {boolean} [dim] - Dimmed text
148
+ * @property {boolean} [italic] - Italic text
149
+ * @property {boolean} [underline] - Underlined text
150
+ */
151
+
152
+ /**
153
+ * A span of text with optional styling
154
+ * @typedef {Object} TextSpan
155
+ * @property {string} text - The text content
156
+ * @property {TerminalStyle} [style] - Optional style properties
157
+ */
158
+
159
+ /**
160
+ * A line of terminal output, containing styled spans and raw text
161
+ * @typedef {Object} TerminalLine
162
+ * @property {TextSpan[]} spans - Styled text spans
163
+ * @property {string} raw - Raw text content (spans joined, styles stripped)
164
+ */
165
+
166
+ /**
167
+ * Query for matching terminal lines
168
+ * @typedef {Object} MatchQuery
169
+ * @property {string | RegExp} pattern - Pattern to match against raw line text
170
+ * @property {Partial<TerminalStyle>} [style] - Optional style filter (ignored if implementation doesn't support styles)
171
+ */
172
+
173
+ /**
174
+ * Result of a pattern match operation
175
+ * @typedef {Object} MatchResult
176
+ * @property {boolean} matched - Whether a match was found
177
+ * @property {TerminalLine} [line] - The matched line (if matched)
178
+ * @property {number} [lineIndex] - Index of the matched line (if matched)
179
+ */
180
+
181
+ /**
182
+ * Options for reading from a terminal stream
183
+ * @typedef {Object} ReadOptions
184
+ * @property {number} [max] - Maximum number of lines to return
185
+ * @property {number} [timeoutMs] - Timeout in milliseconds
186
+ */
187
+
188
+ /**
189
+ * Options for waiting for a match
190
+ * @typedef {Object} WaitOptions
191
+ * @property {number} [timeoutMs] - Timeout in milliseconds
192
+ */
193
+
194
+ /**
195
+ * Interface for reading terminal output.
196
+ * Implementations: JsonlTerminalStream (Claude logs), ScreenTerminalStream (tmux capture)
197
+ * @typedef {Object} TerminalStream
198
+ * @property {(opts?: ReadOptions) => Promise<TerminalLine[]>} readNext - Read new lines since last read
199
+ * @property {(query: MatchQuery, opts?: WaitOptions) => Promise<MatchResult>} waitForMatch - Wait for a line matching the query
200
+ */
201
+
137
202
  const DEBUG = process.env.AX_DEBUG === "1";
138
203
 
139
204
  /**
@@ -205,11 +270,13 @@ function tmuxHasSession(session) {
205
270
  /**
206
271
  * @param {string} session
207
272
  * @param {number} [scrollback]
273
+ * @param {boolean} [withEscapes] - Include ANSI escape sequences (uses -e flag)
208
274
  * @returns {string}
209
275
  */
210
- function tmuxCapture(session, scrollback = 0) {
276
+ function tmuxCapture(session, scrollback = 0, withEscapes = false) {
211
277
  try {
212
278
  const args = ["capture-pane", "-t", session, "-p"];
279
+ if (withEscapes) args.push("-e"); // Include escape sequences
213
280
  if (scrollback) args.push("-S", String(-scrollback));
214
281
  return tmux(args);
215
282
  } catch (err) {
@@ -236,6 +303,41 @@ function tmuxSendLiteral(session, text) {
236
303
  tmux(["send-keys", "-t", session, "-l", text]);
237
304
  }
238
305
 
306
+ /**
307
+ * Paste text into a tmux session using load-buffer + paste-buffer.
308
+ * More reliable than send-keys -l for large text.
309
+ * Uses a named buffer to avoid races with concurrent invocations.
310
+ * @param {string} session
311
+ * @param {string} text
312
+ */
313
+ function tmuxPasteLiteral(session, text) {
314
+ debug("tmux", `pasteLiteral session=${session}, text=${text.slice(0, 50)}...`);
315
+ // Use unique buffer name per invocation to avoid races (even to same session)
316
+ const bufferName = `ax-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
317
+ // Load text into named tmux buffer from stdin
318
+ const loadResult = spawnSync("tmux", ["load-buffer", "-b", bufferName, "-"], {
319
+ input: text,
320
+ encoding: "utf-8",
321
+ });
322
+ if (loadResult.status !== 0) {
323
+ debug("tmux", `load-buffer failed: ${loadResult.stderr}`);
324
+ throw new Error(loadResult.stderr || "tmux load-buffer failed");
325
+ }
326
+ try {
327
+ // Paste buffer into the session
328
+ tmux(["paste-buffer", "-b", bufferName, "-t", session]);
329
+ // Ensure cursor is at end of pasted text
330
+ tmux(["send-keys", "-t", session, "End"]);
331
+ } finally {
332
+ // Clean up the named buffer
333
+ try {
334
+ tmux(["delete-buffer", "-b", bufferName]);
335
+ } catch (err) {
336
+ debugError("tmuxPasteLiteral", err);
337
+ }
338
+ }
339
+ }
340
+
239
341
  /**
240
342
  * @param {string} session
241
343
  */
@@ -837,7 +939,10 @@ function findCodexLogPath(sessionName) {
837
939
  }
838
940
  // Return the closest match
839
941
  candidates.sort((a, b) => a.diff - b.diff);
840
- debug("log", `findCodexLogPath: found ${candidates.length} candidates, best: ${candidates[0].path}`);
942
+ debug(
943
+ "log",
944
+ `findCodexLogPath: found ${candidates.length} candidates, best: ${candidates[0].path}`,
945
+ );
841
946
  return candidates[0].path;
842
947
  } catch {
843
948
  debug("log", `findCodexLogPath: exception caught`);
@@ -1026,7 +1131,7 @@ function tailJsonl(logPath, fromOffset) {
1026
1131
  /**
1027
1132
  * Format a Claude Code JSONL log entry for streaming display.
1028
1133
  * 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
1134
+ * @param {{type?: string, message?: {content?: Array<{type?: string, text?: string, thinking?: string, name?: string, input?: ToolInput, tool?: string, arguments?: ToolInput}>}}} entry
1030
1135
  * @returns {string | null}
1031
1136
  */
1032
1137
  function formatClaudeLogEntry(entry) {
@@ -1068,7 +1173,8 @@ function formatClaudeLogEntry(entry) {
1068
1173
  * - {type: "response_item", payload: {type: "message", role: "assistant", content: [{type: "output_text", text: "..."}]}}
1069
1174
  * - {type: "response_item", payload: {type: "function_call", name: "...", arguments: "{...}"}}
1070
1175
  * - {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
1176
+ * - {type: "event_msg", payload: {type: "agent_reasoning", text: "..."}}
1177
+ * @param {{type?: string, payload?: {type?: string, role?: string, name?: string, arguments?: string, message?: string, text?: string, content?: Array<{type?: string, text?: string}>}}} entry
1072
1178
  * @returns {string | null}
1073
1179
  */
1074
1180
  function formatCodexLogEntry(entry) {
@@ -1120,6 +1226,587 @@ function formatCodexLogEntry(entry) {
1120
1226
  return null;
1121
1227
  }
1122
1228
 
1229
+ // =============================================================================
1230
+ // Terminal Stream Primitives - Pure functions for parsing terminal data
1231
+ // =============================================================================
1232
+
1233
+ /**
1234
+ * Parse a JSONL log entry into TerminalLine[].
1235
+ * Wraps formatClaudeLogEntry/formatCodexLogEntry to return structured data.
1236
+ * @param {object} entry - A parsed JSONL entry
1237
+ * @param {'claude' | 'codex'} format - The log format
1238
+ * @returns {TerminalLine[]}
1239
+ */
1240
+ function parseJsonlEntry(entry, format) {
1241
+ const formatted = format === "claude" ? formatClaudeLogEntry(entry) : formatCodexLogEntry(entry);
1242
+ if (!formatted) return [];
1243
+
1244
+ // Split on newlines and create a TerminalLine for each
1245
+ return formatted.split("\n").map((line) => ({
1246
+ spans: [{ text: line }],
1247
+ raw: line,
1248
+ }));
1249
+ }
1250
+
1251
+ /**
1252
+ * Parse raw screen output into TerminalLine[].
1253
+ * Each line becomes a TerminalLine with a single unstyled span.
1254
+ * @param {string} screen - Raw screen content from tmux capture
1255
+ * @returns {TerminalLine[]}
1256
+ */
1257
+ function parseScreenLines(screen) {
1258
+ if (!screen) return [];
1259
+ return screen.split("\n").map((line) => ({
1260
+ spans: [{ text: line }],
1261
+ raw: line,
1262
+ }));
1263
+ }
1264
+
1265
+ /**
1266
+ * ANSI color code to color name mapping.
1267
+ * @type {Record<string, string>}
1268
+ */
1269
+ const ANSI_COLORS = {
1270
+ 30: "black",
1271
+ 31: "red",
1272
+ 32: "green",
1273
+ 33: "yellow",
1274
+ 34: "blue",
1275
+ 35: "magenta",
1276
+ 36: "cyan",
1277
+ 37: "white",
1278
+ 90: "bright-black",
1279
+ 91: "bright-red",
1280
+ 92: "bright-green",
1281
+ 93: "bright-yellow",
1282
+ 94: "bright-blue",
1283
+ 95: "bright-magenta",
1284
+ 96: "bright-cyan",
1285
+ 97: "bright-white",
1286
+ };
1287
+
1288
+ /**
1289
+ * ANSI background color code to color name mapping.
1290
+ * @type {Record<string, string>}
1291
+ */
1292
+ const ANSI_BG_COLORS = {
1293
+ 40: "black",
1294
+ 41: "red",
1295
+ 42: "green",
1296
+ 43: "yellow",
1297
+ 44: "blue",
1298
+ 45: "magenta",
1299
+ 46: "cyan",
1300
+ 47: "white",
1301
+ 100: "bright-black",
1302
+ 101: "bright-red",
1303
+ 102: "bright-green",
1304
+ 103: "bright-yellow",
1305
+ 104: "bright-blue",
1306
+ 105: "bright-magenta",
1307
+ 106: "bright-cyan",
1308
+ 107: "bright-white",
1309
+ };
1310
+
1311
+ /**
1312
+ * Parse ANSI escape sequences from a line of text into styled spans.
1313
+ * @param {string} line - Line containing ANSI escape sequences
1314
+ * @returns {TextSpan[]}
1315
+ */
1316
+ function parseAnsiLine(line) {
1317
+ if (!line) return [{ text: "" }];
1318
+
1319
+ const spans = [];
1320
+ /** @type {TerminalStyle} */
1321
+ let currentStyle = {};
1322
+ let currentText = "";
1323
+
1324
+ // ANSI escape sequence pattern: ESC [ <params> m
1325
+ // Matches sequences like \x1b[31m (red), \x1b[1;31m (bold red), \x1b[0m (reset)
1326
+ // eslint-disable-next-line no-control-regex
1327
+ const ansiPattern = /\x1b\[([0-9;]*)m/g;
1328
+ let lastIndex = 0;
1329
+ let match;
1330
+
1331
+ while ((match = ansiPattern.exec(line)) !== null) {
1332
+ // Add text before this escape sequence
1333
+ const textBefore = line.slice(lastIndex, match.index);
1334
+ if (textBefore) {
1335
+ currentText += textBefore;
1336
+ }
1337
+
1338
+ // Flush current span if we have text
1339
+ if (currentText) {
1340
+ /** @type {TextSpan} */
1341
+ const span = { text: currentText };
1342
+ if (Object.keys(currentStyle).length > 0) {
1343
+ span.style = { ...currentStyle };
1344
+ }
1345
+ spans.push(span);
1346
+ currentText = "";
1347
+ }
1348
+
1349
+ // Parse SGR (Select Graphic Rendition) parameters
1350
+ // Note: \x1b[m (empty params) is equivalent to \x1b[0m (reset)
1351
+ const params = match[1].split(";").filter(Boolean);
1352
+ if (params.length === 0) {
1353
+ // Empty params means reset (e.g., \x1b[m)
1354
+ currentStyle = {};
1355
+ }
1356
+ for (const param of params) {
1357
+ const code = param;
1358
+ if (code === "0") {
1359
+ // Reset
1360
+ currentStyle = {};
1361
+ } else if (code === "1") {
1362
+ currentStyle.bold = true;
1363
+ } else if (code === "2") {
1364
+ currentStyle.dim = true;
1365
+ } else if (code === "3") {
1366
+ currentStyle.italic = true;
1367
+ } else if (code === "4") {
1368
+ currentStyle.underline = true;
1369
+ } else if (code === "22") {
1370
+ // Normal intensity (neither bold nor dim)
1371
+ delete currentStyle.bold;
1372
+ delete currentStyle.dim;
1373
+ } else if (code === "23") {
1374
+ delete currentStyle.italic;
1375
+ } else if (code === "24") {
1376
+ delete currentStyle.underline;
1377
+ } else if (ANSI_COLORS[code]) {
1378
+ currentStyle.fg = ANSI_COLORS[code];
1379
+ } else if (ANSI_BG_COLORS[code]) {
1380
+ currentStyle.bg = ANSI_BG_COLORS[code];
1381
+ } else if (code === "39") {
1382
+ // Default foreground
1383
+ delete currentStyle.fg;
1384
+ } else if (code === "49") {
1385
+ // Default background
1386
+ delete currentStyle.bg;
1387
+ }
1388
+ }
1389
+
1390
+ lastIndex = ansiPattern.lastIndex;
1391
+ }
1392
+
1393
+ // Add remaining text
1394
+ const remaining = line.slice(lastIndex);
1395
+ if (remaining) {
1396
+ currentText += remaining;
1397
+ }
1398
+
1399
+ // Flush final span
1400
+ if (currentText || spans.length === 0) {
1401
+ /** @type {TextSpan} */
1402
+ const span = { text: currentText };
1403
+ if (Object.keys(currentStyle).length > 0) {
1404
+ span.style = { ...currentStyle };
1405
+ }
1406
+ spans.push(span);
1407
+ }
1408
+
1409
+ return spans;
1410
+ }
1411
+
1412
+ /**
1413
+ * Parse raw screen output with ANSI codes into styled TerminalLine[].
1414
+ * @param {string} screen - Screen content with ANSI escape codes
1415
+ * @returns {TerminalLine[]}
1416
+ */
1417
+ function parseStyledScreenLines(screen) {
1418
+ if (!screen) return [];
1419
+ return screen.split("\n").map((line) => {
1420
+ const spans = parseAnsiLine(line);
1421
+ // Raw text is spans joined without styles
1422
+ const raw = spans.map((s) => s.text).join("");
1423
+ return { spans, raw };
1424
+ });
1425
+ }
1426
+
1427
+ /**
1428
+ * Find a line matching the given query.
1429
+ * Style filters are ignored when lines don't have style information.
1430
+ * @param {TerminalLine[]} lines - Lines to search
1431
+ * @param {MatchQuery} query - Query with pattern and optional style filter
1432
+ * @returns {MatchResult}
1433
+ */
1434
+ function findMatch(lines, query) {
1435
+ const { pattern, style } = query;
1436
+
1437
+ for (let i = 0; i < lines.length; i++) {
1438
+ const line = lines[i];
1439
+ const text = line.raw;
1440
+
1441
+ // Check pattern match
1442
+ const patternMatches =
1443
+ typeof pattern === "string" ? text.includes(pattern) : pattern.test(text);
1444
+
1445
+ if (!patternMatches) continue;
1446
+
1447
+ // If no style filter requested, we have a match
1448
+ if (!style) {
1449
+ return { matched: true, line, lineIndex: i };
1450
+ }
1451
+
1452
+ // Check style match (if line has styled spans)
1453
+ // Style filter is silently ignored if implementation doesn't provide styles
1454
+ const hasStyledSpans = line.spans.some((span) => span.style);
1455
+ if (!hasStyledSpans) {
1456
+ // No style info available - pattern match is enough
1457
+ return { matched: true, line, lineIndex: i };
1458
+ }
1459
+
1460
+ // Check if any span matches both pattern and style
1461
+ const styleMatches = line.spans.some((span) => {
1462
+ if (!span.style) return false;
1463
+ const spanMatchesPattern =
1464
+ typeof pattern === "string" ? span.text.includes(pattern) : pattern.test(span.text);
1465
+ if (!spanMatchesPattern) return false;
1466
+
1467
+ // Check each requested style property
1468
+ const spanStyle = /** @type {Record<string, unknown>} */ (span.style);
1469
+ for (const [key, value] of Object.entries(style)) {
1470
+ if (spanStyle[key] !== value) return false;
1471
+ }
1472
+ return true;
1473
+ });
1474
+
1475
+ if (styleMatches) {
1476
+ return { matched: true, line, lineIndex: i };
1477
+ }
1478
+ }
1479
+
1480
+ return { matched: false };
1481
+ }
1482
+
1483
+ // =============================================================================
1484
+ // Terminal Stream Implementations
1485
+ // =============================================================================
1486
+
1487
+ /**
1488
+ * Terminal stream that reads from JSONL log files (Claude/Codex logs).
1489
+ * Implements TerminalStream interface.
1490
+ * @implements {TerminalStream}
1491
+ */
1492
+ class JsonlTerminalStream {
1493
+ /** @type {() => string | null} */
1494
+ logPathFinder;
1495
+ /** @type {'claude' | 'codex'} */
1496
+ format;
1497
+ /** @type {string | null} */
1498
+ logPath;
1499
+ /** @type {number} */
1500
+ offset;
1501
+
1502
+ /**
1503
+ * @param {() => string | null} logPathFinder - Function that returns current log path (may change during session)
1504
+ * @param {'claude' | 'codex'} format - Log format for parsing entries
1505
+ */
1506
+ constructor(logPathFinder, format) {
1507
+ this.logPathFinder = logPathFinder;
1508
+ this.format = format;
1509
+ this.logPath = null;
1510
+ this.offset = 0;
1511
+ }
1512
+
1513
+ /**
1514
+ * Read new lines since last read.
1515
+ * @param {ReadOptions} [opts]
1516
+ * @returns {Promise<TerminalLine[]>}
1517
+ */
1518
+ async readNext(opts = {}) {
1519
+ // Check for new/changed log path
1520
+ const currentLogPath = this.logPathFinder();
1521
+ if (currentLogPath && currentLogPath !== this.logPath) {
1522
+ this.logPath = currentLogPath;
1523
+ // Read from beginning when file is first discovered or changed
1524
+ if (existsSync(this.logPath)) {
1525
+ this.offset = 0;
1526
+ }
1527
+ }
1528
+
1529
+ if (!this.logPath) {
1530
+ return [];
1531
+ }
1532
+
1533
+ const { entries, newOffset } = tailJsonl(this.logPath, this.offset);
1534
+ this.offset = newOffset;
1535
+
1536
+ const lines = [];
1537
+ for (const entry of entries) {
1538
+ const entryLines = parseJsonlEntry(entry, this.format);
1539
+ lines.push(...entryLines);
1540
+ }
1541
+
1542
+ if (opts.max && lines.length > opts.max) {
1543
+ return lines.slice(0, opts.max);
1544
+ }
1545
+
1546
+ return lines;
1547
+ }
1548
+
1549
+ /**
1550
+ * Wait for a line matching the query.
1551
+ * @param {MatchQuery} query
1552
+ * @param {WaitOptions} [opts]
1553
+ * @returns {Promise<MatchResult>}
1554
+ */
1555
+ async waitForMatch(query, opts = {}) {
1556
+ const timeoutMs = opts.timeoutMs || 30000;
1557
+ const pollInterval = 100;
1558
+ const deadline = Date.now() + timeoutMs;
1559
+
1560
+ while (Date.now() < deadline) {
1561
+ const lines = await this.readNext();
1562
+ if (lines.length > 0) {
1563
+ const result = findMatch(lines, query);
1564
+ if (result.matched) {
1565
+ return result;
1566
+ }
1567
+ }
1568
+ await sleep(pollInterval);
1569
+ }
1570
+
1571
+ return { matched: false };
1572
+ }
1573
+ }
1574
+
1575
+ /**
1576
+ * Terminal stream that reads from tmux screen capture.
1577
+ * Implements TerminalStream interface.
1578
+ * @implements {TerminalStream}
1579
+ */
1580
+ class ScreenTerminalStream {
1581
+ /**
1582
+ * @param {string} session - tmux session name
1583
+ * @param {number} [scrollback] - Number of scrollback lines to capture
1584
+ */
1585
+ constructor(session, scrollback = 0) {
1586
+ this.session = session;
1587
+ this.scrollback = scrollback;
1588
+ this.lastScreen = "";
1589
+ }
1590
+
1591
+ /**
1592
+ * Read current screen lines (returns all visible lines on each call).
1593
+ * Note: Unlike JsonlTerminalStream, this returns the full screen each time.
1594
+ * @param {ReadOptions} [opts]
1595
+ * @returns {Promise<TerminalLine[]>}
1596
+ */
1597
+ async readNext(opts = {}) {
1598
+ const screen = tmuxCapture(this.session, this.scrollback);
1599
+ this.lastScreen = screen;
1600
+
1601
+ const lines = parseScreenLines(screen);
1602
+
1603
+ if (opts.max && lines.length > opts.max) {
1604
+ return lines.slice(-opts.max); // Return last N lines for screen capture
1605
+ }
1606
+
1607
+ return lines;
1608
+ }
1609
+
1610
+ /**
1611
+ * Wait for a line matching the query.
1612
+ * @param {MatchQuery} query
1613
+ * @param {WaitOptions} [opts]
1614
+ * @returns {Promise<MatchResult>}
1615
+ */
1616
+ async waitForMatch(query, opts = {}) {
1617
+ const timeoutMs = opts.timeoutMs || 30000;
1618
+ const pollInterval = 100;
1619
+ const deadline = Date.now() + timeoutMs;
1620
+
1621
+ while (Date.now() < deadline) {
1622
+ const lines = await this.readNext();
1623
+ const result = findMatch(lines, query);
1624
+ if (result.matched) {
1625
+ return result;
1626
+ }
1627
+ await sleep(pollInterval);
1628
+ }
1629
+
1630
+ return { matched: false };
1631
+ }
1632
+
1633
+ /**
1634
+ * Get the last captured screen (raw string).
1635
+ * Useful for compatibility with existing code that needs raw screen.
1636
+ * @returns {string}
1637
+ */
1638
+ getLastScreen() {
1639
+ return this.lastScreen;
1640
+ }
1641
+ }
1642
+
1643
+ /**
1644
+ * Terminal stream that reads from tmux screen capture with ANSI styling.
1645
+ * Uses `tmux capture-pane -e` to capture escape sequences.
1646
+ * Implements TerminalStream interface.
1647
+ * @implements {TerminalStream}
1648
+ */
1649
+ class StyledScreenTerminalStream {
1650
+ /**
1651
+ * @param {string} session - tmux session name
1652
+ * @param {number} [scrollback] - Number of scrollback lines to capture
1653
+ */
1654
+ constructor(session, scrollback = 0) {
1655
+ this.session = session;
1656
+ this.scrollback = scrollback;
1657
+ this.lastScreen = "";
1658
+ }
1659
+
1660
+ /**
1661
+ * Read current screen lines with ANSI styling parsed.
1662
+ * @param {ReadOptions} [opts]
1663
+ * @returns {Promise<TerminalLine[]>}
1664
+ */
1665
+ async readNext(opts = {}) {
1666
+ const screen = tmuxCapture(this.session, this.scrollback, true); // withEscapes=true
1667
+ this.lastScreen = screen;
1668
+
1669
+ const lines = parseStyledScreenLines(screen);
1670
+
1671
+ if (opts.max && lines.length > opts.max) {
1672
+ return lines.slice(-opts.max); // Return last N lines for screen capture
1673
+ }
1674
+
1675
+ return lines;
1676
+ }
1677
+
1678
+ /**
1679
+ * Wait for a line matching the query (supports style-aware matching).
1680
+ * @param {MatchQuery} query
1681
+ * @param {WaitOptions} [opts]
1682
+ * @returns {Promise<MatchResult>}
1683
+ */
1684
+ async waitForMatch(query, opts = {}) {
1685
+ const timeoutMs = opts.timeoutMs || 30000;
1686
+ const pollInterval = 100;
1687
+ const deadline = Date.now() + timeoutMs;
1688
+
1689
+ while (Date.now() < deadline) {
1690
+ const lines = await this.readNext();
1691
+ const result = findMatch(lines, query);
1692
+ if (result.matched) {
1693
+ return result;
1694
+ }
1695
+ await sleep(pollInterval);
1696
+ }
1697
+
1698
+ return { matched: false };
1699
+ }
1700
+
1701
+ /**
1702
+ * Get the last captured screen (raw string with ANSI codes).
1703
+ * @returns {string}
1704
+ */
1705
+ getLastScreen() {
1706
+ return this.lastScreen;
1707
+ }
1708
+ }
1709
+
1710
+ /**
1711
+ * Fake terminal stream for testing.
1712
+ * Implements TerminalStream interface.
1713
+ * @implements {TerminalStream}
1714
+ */
1715
+ class FakeTerminalStream {
1716
+ /**
1717
+ * @param {TerminalLine[]} lines - Initial lines to provide
1718
+ */
1719
+ constructor(lines = []) {
1720
+ this.lines = [...lines];
1721
+ this.readCount = 0;
1722
+ /** @type {TerminalLine[][]} */
1723
+ this.pendingLines = [];
1724
+ }
1725
+
1726
+ /**
1727
+ * Queue lines to be returned on subsequent readNext calls.
1728
+ * @param {TerminalLine[]} lines
1729
+ */
1730
+ queueLines(lines) {
1731
+ this.pendingLines.push(lines);
1732
+ }
1733
+
1734
+ /**
1735
+ * Add more lines to the current buffer (simulates new output).
1736
+ * @param {TerminalLine[]} lines
1737
+ */
1738
+ addLines(lines) {
1739
+ this.lines.push(...lines);
1740
+ }
1741
+
1742
+ /**
1743
+ * Read new lines since last read.
1744
+ * First call returns initial lines, subsequent calls return queued lines.
1745
+ * @param {ReadOptions} [opts]
1746
+ * @returns {Promise<TerminalLine[]>}
1747
+ */
1748
+ async readNext(opts = {}) {
1749
+ this.readCount++;
1750
+
1751
+ /** @type {TerminalLine[]} */
1752
+ let result = [];
1753
+ if (this.readCount === 1) {
1754
+ result = this.lines;
1755
+ } else if (this.pendingLines.length > 0) {
1756
+ result = this.pendingLines.shift() || [];
1757
+ }
1758
+
1759
+ if (opts.max && result.length > opts.max) {
1760
+ return result.slice(0, opts.max);
1761
+ }
1762
+
1763
+ return result;
1764
+ }
1765
+
1766
+ /**
1767
+ * Wait for a line matching the query.
1768
+ * Immediately checks available lines without polling.
1769
+ * @param {MatchQuery} query
1770
+ * @param {WaitOptions} [_opts]
1771
+ * @returns {Promise<MatchResult>}
1772
+ */
1773
+ async waitForMatch(query, _opts = {}) {
1774
+ // Check initial lines
1775
+ const result = findMatch(this.lines, query);
1776
+ if (result.matched) {
1777
+ return result;
1778
+ }
1779
+
1780
+ // Check all pending lines
1781
+ for (const pendingBatch of this.pendingLines) {
1782
+ const batchResult = findMatch(pendingBatch, query);
1783
+ if (batchResult.matched) {
1784
+ return batchResult;
1785
+ }
1786
+ }
1787
+
1788
+ return { matched: false };
1789
+ }
1790
+
1791
+ /**
1792
+ * Create a TerminalLine from a raw string (helper for tests).
1793
+ * @param {string} raw
1794
+ * @returns {TerminalLine}
1795
+ */
1796
+ static line(raw) {
1797
+ return { spans: [{ text: raw }], raw };
1798
+ }
1799
+
1800
+ /**
1801
+ * Create multiple TerminalLines from raw strings (helper for tests).
1802
+ * @param {string[]} raws
1803
+ * @returns {TerminalLine[]}
1804
+ */
1805
+ static lines(raws) {
1806
+ return raws.map((raw) => FakeTerminalStream.line(raw));
1807
+ }
1808
+ }
1809
+
1123
1810
  /**
1124
1811
  * Extract pending tool from confirmation screen.
1125
1812
  * @param {string} screen
@@ -2108,6 +2795,7 @@ const State = {
2108
2795
  * @param {string[]} [config.spinners] - Spinner characters indicating thinking
2109
2796
  * @param {RegExp} [config.rateLimitPattern] - Pattern for rate limit detection
2110
2797
  * @param {(string | RegExp | ((lines: string) => boolean))[]} [config.thinkingPatterns] - Text patterns indicating thinking
2798
+ * @param {(string | RegExp)[]} [config.activeWorkPatterns] - Patterns indicating active work (beats ready)
2111
2799
  * @param {(string | ((lines: string) => boolean))[]} [config.confirmPatterns] - Patterns for confirmation dialogs
2112
2800
  * @param {{screen: string[], lastLines: string[]} | null} [config.updatePromptPatterns] - Patterns for update prompts
2113
2801
  * @returns {string} The detected state
@@ -2250,7 +2938,6 @@ function detectState(screen, config) {
2250
2938
  * @property {string} [safeAllowedTools]
2251
2939
  * @property {string | null} [sessionIdFlag]
2252
2940
  * @property {((sessionName: string) => string | null) | null} [logPathFinder]
2253
- * @property {((entry: object) => string | null) | null} [logEntryFormatter]
2254
2941
  */
2255
2942
 
2256
2943
  class Agent {
@@ -2298,8 +2985,6 @@ class Agent {
2298
2985
  this.sessionIdFlag = config.sessionIdFlag || null;
2299
2986
  /** @type {((sessionName: string) => string | null) | null} */
2300
2987
  this.logPathFinder = config.logPathFinder || null;
2301
- /** @type {((entry: object) => string | null) | null} */
2302
- this.logEntryFormatter = config.logEntryFormatter || null;
2303
2988
  }
2304
2989
 
2305
2990
  /**
@@ -2447,15 +3132,32 @@ class Agent {
2447
3132
  }
2448
3133
 
2449
3134
  /**
2450
- * Format a log entry for streaming display.
2451
- * @param {object} entry
2452
- * @returns {string | null}
3135
+ * Create a terminal stream for reading agent output.
3136
+ * Returns JsonlTerminalStream for agents with log file support,
3137
+ * otherwise falls back to ScreenTerminalStream.
3138
+ * @param {string} sessionName
3139
+ * @returns {TerminalStream}
2453
3140
  */
2454
- formatLogEntry(entry) {
2455
- if (this.logEntryFormatter) {
2456
- return this.logEntryFormatter(entry);
3141
+ createStream(sessionName) {
3142
+ // Prefer JSONL stream if agent has log path finder
3143
+ if (this.logPathFinder) {
3144
+ /** @type {'claude' | 'codex'} */
3145
+ const format = this.name === "claude" ? "claude" : "codex";
3146
+ return new JsonlTerminalStream(() => this.findLogPath(sessionName), format);
2457
3147
  }
2458
- return null;
3148
+ // Fall back to screen capture
3149
+ return new ScreenTerminalStream(sessionName);
3150
+ }
3151
+
3152
+ /**
3153
+ * Create a styled terminal stream with ANSI color support.
3154
+ * Only uses screen capture (JSONL doesn't have style info).
3155
+ * @param {string} sessionName
3156
+ * @param {number} [scrollback]
3157
+ * @returns {StyledScreenTerminalStream}
3158
+ */
3159
+ createStyledStream(sessionName, scrollback = 0) {
3160
+ return new StyledScreenTerminalStream(sessionName, scrollback);
2459
3161
  }
2460
3162
 
2461
3163
  /**
@@ -2704,7 +3406,6 @@ const CodexAgent = new Agent({
2704
3406
  reviewOptions: { branch: "1", uncommitted: "2", commit: "3", custom: "4" },
2705
3407
  envVar: "AX_SESSION",
2706
3408
  logPathFinder: findCodexLogPath,
2707
- logEntryFormatter: formatCodexLogEntry,
2708
3409
  });
2709
3410
 
2710
3411
  // =============================================================================
@@ -2754,7 +3455,6 @@ const ClaudeAgent = new Agent({
2754
3455
  if (uuid) return findClaudeLogPath(uuid, sessionName);
2755
3456
  return null;
2756
3457
  },
2757
- logEntryFormatter: formatClaudeLogEntry,
2758
3458
  });
2759
3459
 
2760
3460
  // =============================================================================
@@ -2906,16 +3606,18 @@ async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2906
3606
 
2907
3607
  /**
2908
3608
  * Wait for agent response with streaming output to console.
3609
+ * Uses TerminalStream abstraction for reading agent output.
2909
3610
  * @param {Agent} agent
2910
3611
  * @param {string} session
2911
3612
  * @param {number} [timeoutMs]
2912
3613
  * @returns {Promise<{state: string, screen: string}>}
2913
3614
  */
2914
3615
  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;
3616
+ // Create terminal stream for this agent/session
3617
+ const stream = agent.createStream(session);
2917
3618
  let printedThinking = false;
2918
- debug("stream", `start: logPath=${logPath || "null"}, logOffset=${logOffset}`);
3619
+ debug("stream", `start: using ${stream.constructor.name}`);
3620
+
2919
3621
  // Sliding window for deduplication - only dedupe recent messages
2920
3622
  // This catches Codex's duplicate log entries (A,B,A,B pattern) while
2921
3623
  // allowing legitimate repeated messages across turns
@@ -2923,41 +3625,30 @@ async function streamResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2923
3625
  const recentMessages = [];
2924
3626
  const DEDUPE_WINDOW = 10;
2925
3627
 
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
- }
3628
+ const streamNewLines = async () => {
3629
+ const lines = await stream.readNext();
3630
+ if (lines.length > 0) {
3631
+ debug("stream", `read ${lines.length} lines`);
2935
3632
  }
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
3633
 
2954
- console.log(formatted);
3634
+ for (const line of lines) {
3635
+ const text = line.raw;
3636
+ if (!text) continue;
3637
+
3638
+ // Dedupe messages within sliding window (Codex logs can contain duplicates)
3639
+ // Tool calls (starting with ">") are always printed
3640
+ if (!text.startsWith(">")) {
3641
+ if (recentMessages.includes(text)) continue;
3642
+ recentMessages.push(text);
3643
+ if (recentMessages.length > DEDUPE_WINDOW) recentMessages.shift();
2955
3644
  }
3645
+
3646
+ console.log(text);
2956
3647
  }
2957
3648
  };
2958
3649
 
2959
3650
  return pollForResponse(agent, session, timeoutMs, {
2960
- onPoll: () => streamNewEntries(),
3651
+ onPoll: () => streamNewLines(),
2961
3652
  onStateChange: (state, lastState, screen) => {
2962
3653
  if (state === State.THINKING && !printedThinking) {
2963
3654
  console.log("[THINKING]");
@@ -2970,7 +3661,7 @@ async function streamResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
2970
3661
  printedThinking = false;
2971
3662
  }
2972
3663
  },
2973
- onReady: () => streamNewEntries(),
3664
+ onReady: () => streamNewLines(),
2974
3665
  });
2975
3666
  }
2976
3667
 
@@ -4519,8 +5210,13 @@ async function cmdAsk(
4519
5210
  ? /** @type {string} */ (session)
4520
5211
  : await cmdStart(agent, session, { yolo, allowedTools });
4521
5212
 
4522
- tmuxSendLiteral(activeSession, message);
4523
- await sleep(200);
5213
+ if (sessionExists) {
5214
+ await waitUntilReady(agent, activeSession, timeoutMs);
5215
+ tmuxSend(activeSession, "C-u"); // Clear any stale input
5216
+ await sleep(50);
5217
+ }
5218
+
5219
+ tmuxPasteLiteral(activeSession, message);
4524
5220
  tmuxSend(activeSession, "Enter");
4525
5221
 
4526
5222
  if (noWait) {
@@ -4708,6 +5404,12 @@ async function cmdReview(
4708
5404
  ? /** @type {string} */ (session)
4709
5405
  : await cmdStart(agent, session, { yolo });
4710
5406
 
5407
+ if (sessionExists) {
5408
+ await waitUntilReady(agent, activeSession, timeoutMs);
5409
+ tmuxSend(activeSession, "C-u"); // Clear any stale input
5410
+ await sleep(50);
5411
+ }
5412
+
4711
5413
  debug("review", `Codex path: sending /review command`);
4712
5414
  tmuxSendLiteral(activeSession, "/review");
4713
5415
  await sleep(50);
@@ -5189,7 +5891,7 @@ async function main() {
5189
5891
  const cmd = positionals[0];
5190
5892
 
5191
5893
  // Dispatch commands
5192
- if (cmd === "agents") return cmdAgents();
5894
+ if (cmd === "agents" || cmd === "list") return cmdAgents();
5193
5895
  if (cmd === "target") {
5194
5896
  const defaultSession = agent.getDefaultSession({ allowedTools: autoApprove, yolo });
5195
5897
  if (defaultSession) {
@@ -5318,6 +6020,17 @@ export {
5318
6020
  computePermissionHash,
5319
6021
  formatClaudeLogEntry,
5320
6022
  formatCodexLogEntry,
6023
+ // Terminal stream primitives
6024
+ parseJsonlEntry,
6025
+ parseScreenLines,
6026
+ parseAnsiLine,
6027
+ parseStyledScreenLines,
6028
+ findMatch,
6029
+ // Terminal stream implementations
6030
+ JsonlTerminalStream,
6031
+ ScreenTerminalStream,
6032
+ StyledScreenTerminalStream,
6033
+ FakeTerminalStream,
5321
6034
  CodexAgent,
5322
6035
  ClaudeAgent,
5323
6036
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ax-agents",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A CLI for orchestrating AI coding agents via tmux",
5
5
  "bin": {
6
6
  "ax": "ax.js",