@phnx-labs/agents-cli 1.20.28 → 1.20.30

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 (63) hide show
  1. package/dist/commands/computer-actions.js +6 -2
  2. package/dist/commands/computer.d.ts +12 -0
  3. package/dist/commands/computer.js +88 -13
  4. package/dist/commands/exec.js +22 -10
  5. package/dist/commands/inspect.js +1 -1
  6. package/dist/commands/models.js +8 -2
  7. package/dist/commands/secrets.js +93 -6
  8. package/dist/commands/sessions.js +157 -44
  9. package/dist/commands/ssh.d.ts +14 -0
  10. package/dist/commands/ssh.js +263 -0
  11. package/dist/commands/sync.js +70 -14
  12. package/dist/index.js +2 -1
  13. package/dist/lib/agents.d.ts +0 -4
  14. package/dist/lib/agents.js +54 -5
  15. package/dist/lib/browser/drivers/ssh.js +4 -35
  16. package/dist/lib/computer-rpc.d.ts +6 -1
  17. package/dist/lib/computer-rpc.js +86 -3
  18. package/dist/lib/devices/connect.d.ts +34 -0
  19. package/dist/lib/devices/connect.js +101 -0
  20. package/dist/lib/devices/registry.d.ts +78 -0
  21. package/dist/lib/devices/registry.js +168 -0
  22. package/dist/lib/devices/ssh-config.d.ts +21 -0
  23. package/dist/lib/devices/ssh-config.js +33 -0
  24. package/dist/lib/devices/tailscale.d.ts +31 -0
  25. package/dist/lib/devices/tailscale.js +126 -0
  26. package/dist/lib/exec.js +14 -0
  27. package/dist/lib/models.js +138 -5
  28. package/dist/lib/runner.js +7 -7
  29. package/dist/lib/secrets/remote.d.ts +67 -0
  30. package/dist/lib/secrets/remote.js +133 -0
  31. package/dist/lib/session/active.d.ts +13 -0
  32. package/dist/lib/session/active.js +79 -18
  33. package/dist/lib/session/cloud.js +2 -0
  34. package/dist/lib/session/db.d.ts +12 -0
  35. package/dist/lib/session/db.js +66 -9
  36. package/dist/lib/session/discover.d.ts +7 -0
  37. package/dist/lib/session/discover.js +309 -0
  38. package/dist/lib/session/parse.d.ts +22 -0
  39. package/dist/lib/session/parse.js +132 -2
  40. package/dist/lib/session/remote.d.ts +1 -1
  41. package/dist/lib/session/remote.js +8 -3
  42. package/dist/lib/session/state.d.ts +82 -0
  43. package/dist/lib/session/state.js +221 -0
  44. package/dist/lib/session/tail.d.ts +18 -0
  45. package/dist/lib/session/tail.js +57 -0
  46. package/dist/lib/session/types.d.ts +10 -1
  47. package/dist/lib/session/types.js +1 -1
  48. package/dist/lib/session/width.d.ts +29 -0
  49. package/dist/lib/session/width.js +91 -0
  50. package/dist/lib/shims.d.ts +17 -1
  51. package/dist/lib/shims.js +130 -6
  52. package/dist/lib/ssh-tunnel.d.ts +127 -0
  53. package/dist/lib/ssh-tunnel.js +346 -0
  54. package/dist/lib/startup/command-registry.d.ts +1 -0
  55. package/dist/lib/startup/command-registry.js +3 -0
  56. package/dist/lib/state.d.ts +4 -0
  57. package/dist/lib/state.js +19 -1
  58. package/dist/lib/teams/agents.d.ts +11 -1
  59. package/dist/lib/teams/agents.js +16 -2
  60. package/dist/lib/types.d.ts +1 -0
  61. package/dist/lib/versions.d.ts +19 -0
  62. package/dist/lib/versions.js +84 -24
  63. package/package.json +1 -1
@@ -20,6 +20,7 @@ import { walkForFiles } from '../fs-walk.js';
20
20
  import { getConfigSymlinkVersion } from '../shims.js';
21
21
  import { SESSION_AGENTS } from './types.js';
22
22
  import { extractSessionTopic } from './prompt.js';
23
+ import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand } from './state.js';
23
24
  import { costOfUsage } from '../pricing/index.js';
24
25
  import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
25
26
  const HOME = os.homedir();
@@ -34,6 +35,7 @@ const RUSH_SESSIONS_DIR = path.join(HOME, '.rush', 'sessions');
34
35
  const HERMES_SESSIONS_DIR = path.join(HOME, '.hermes', 'sessions');
35
36
  /** How long OpenClaw channel/cron snapshots stay valid before we re-shell-out. */
36
37
  const OPENCLAW_TTL_MS = 60_000;
38
+ const ACTIVE_APPEND_RESCAN_DEBOUNCE_MS = 5_000;
37
39
  let cachedOpenClawWorkspaces = null;
38
40
  const cachedAgentVersions = new Map();
39
41
  /**
@@ -63,6 +65,7 @@ export async function discoverSessions(options) {
63
65
  case 'rush': return scanRushIncremental(onProgress);
64
66
  case 'hermes': return scanHermesIncremental(onProgress);
65
67
  case 'kimi': return scanKimiIncremental(onProgress);
68
+ case 'droid': return scanDroidIncremental(onProgress);
66
69
  }
67
70
  }));
68
71
  }
@@ -164,10 +167,16 @@ export function searchContentIndex(sessions, query) {
164
167
  /**
165
168
  * For a list of files, stat each, compare to the DB ledger, and return only
166
169
  * the ones that need rescanning. One bulk DB query for the whole list.
170
+ *
171
+ * Actively running agents append to their JSONL every few seconds. Without a
172
+ * small debounce, repeated `agents sessions` invocations stream-parse the same
173
+ * growing transcript over and over. The cached row is good enough for a few
174
+ * seconds; once writes settle or the debounce expires, the file is parsed once.
167
175
  */
168
176
  function filterChangedFiles(filePaths) {
169
177
  const ledger = getScanStampsForPaths(filePaths);
170
178
  const out = [];
179
+ const now = Date.now();
171
180
  for (const filePath of filePaths) {
172
181
  const stat = safeStatSync(filePath);
173
182
  if (!stat)
@@ -180,10 +189,22 @@ function filterChangedFiles(filePaths) {
180
189
  if (prev && prev.fileMtimeMs === scan.fileMtimeMs && prev.fileSize === scan.fileSize) {
181
190
  continue;
182
191
  }
192
+ if (prev && shouldDeferRecentAppend(prev, scan, now)) {
193
+ continue;
194
+ }
183
195
  out.push({ filePath, scan });
184
196
  }
185
197
  return out;
186
198
  }
199
+ export function shouldDeferRecentAppend(prev, current, nowMs, debounceMs = ACTIVE_APPEND_RESCAN_DEBOUNCE_MS) {
200
+ if (prev.scannedAt === undefined)
201
+ return false;
202
+ if (current.fileSize <= prev.fileSize)
203
+ return false;
204
+ if (current.fileMtimeMs < prev.fileMtimeMs)
205
+ return false;
206
+ return nowMs - prev.scannedAt < debounceMs;
207
+ }
187
208
  // ---------------------------------------------------------------------------
188
209
  // Multi-version directory scanning
189
210
  // ---------------------------------------------------------------------------
@@ -407,6 +428,10 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
407
428
  costUsd: scan.costUsd,
408
429
  durationMs: scan.durationMs,
409
430
  isTeamOrigin,
431
+ prUrl: scan.prUrl,
432
+ prNumber: scan.prNumber,
433
+ worktreeSlug: scan.worktreeSlug,
434
+ ticketId: scan.ticketId,
410
435
  };
411
436
  }
412
437
  else {
@@ -425,6 +450,10 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
425
450
  durationMs: scan.durationMs,
426
451
  topic: scan.topic,
427
452
  isTeamOrigin,
453
+ prUrl: scan.prUrl,
454
+ prNumber: scan.prNumber,
455
+ worktreeSlug: scan.worktreeSlug,
456
+ ticketId: scan.ticketId,
428
457
  };
429
458
  }
430
459
  return { meta, content: scan.contentText || '' };
@@ -590,6 +619,10 @@ async function readCodexMeta(filePath, account, currentVersion) {
590
619
  costUsd: scan.costUsd,
591
620
  durationMs: scan.durationMs,
592
621
  account,
622
+ prUrl: scan.prUrl,
623
+ prNumber: scan.prNumber,
624
+ worktreeSlug: scan.worktreeSlug,
625
+ ticketId: scan.ticketId,
593
626
  };
594
627
  return { meta, content: scan.contentText || '' };
595
628
  }
@@ -1291,6 +1324,214 @@ function extractHermesMessageText(content) {
1291
1324
  .join('\n')
1292
1325
  .trim();
1293
1326
  }
1327
+ /**
1328
+ * Incrementally re-scan changed Droid (Factory) session files and upsert into
1329
+ * the DB. Droid writes one `<uuid>.jsonl` transcript plus a sibling
1330
+ * `<uuid>.settings.json` (model + token usage) under
1331
+ * `~/.factory/sessions/<encoded-cwd>/`.
1332
+ */
1333
+ async function scanDroidIncremental(onProgress) {
1334
+ const currentVersion = await getCurrentAgentVersion('droid');
1335
+ const filePaths = [];
1336
+ for (const sessionsDir of getAgentSessionDirs('droid', 'sessions')) {
1337
+ // High limit: we only stat files here, parsing is gated by ledger match.
1338
+ for (const fp of walkForFiles(sessionsDir, '.jsonl', 100_000)) {
1339
+ filePaths.push(fp);
1340
+ }
1341
+ }
1342
+ const changed = filterChangedFiles(filePaths);
1343
+ if (changed.length === 0)
1344
+ return;
1345
+ onProgress?.({ agent: 'droid', parsed: 0, total: changed.length });
1346
+ const entries = [];
1347
+ const touched = [];
1348
+ const seen = new Set();
1349
+ let parsed = 0;
1350
+ for (const { filePath, scan } of changed) {
1351
+ try {
1352
+ const result = await readDroidMeta(filePath, currentVersion);
1353
+ if (result && !seen.has(result.meta.id)) {
1354
+ seen.add(result.meta.id);
1355
+ entries.push({ meta: result.meta, content: result.content, scan });
1356
+ }
1357
+ else {
1358
+ touched.push({ filePath, scan });
1359
+ }
1360
+ }
1361
+ catch {
1362
+ touched.push({ filePath, scan });
1363
+ }
1364
+ parsed++;
1365
+ onProgress?.({ agent: 'droid', parsed, total: changed.length });
1366
+ }
1367
+ upsertSessionsBatch(entries);
1368
+ recordScans(touched);
1369
+ }
1370
+ /** Stream-parse a single Droid JSONL file (+ sibling settings) into session metadata. */
1371
+ async function readDroidMeta(filePath, currentVersion) {
1372
+ const scan = await scanDroidSession(filePath);
1373
+ // The filename is the canonical session id; fall back to the session_start id.
1374
+ const sessionId = path.basename(filePath).replace(/\.jsonl$/, '') || scan.sessionId || '';
1375
+ if (!sessionId)
1376
+ return null;
1377
+ // Token usage and cost live only in the sibling `<uuid>.settings.json`.
1378
+ const settings = readDroidSettings(filePath.replace(/\.jsonl$/, '.settings.json'));
1379
+ const model = settings.model || scan.model;
1380
+ const tokenCount = settings.tokenCount;
1381
+ const costUsd = model && settings.usage
1382
+ ? costOfUsage({
1383
+ model,
1384
+ inputTokens: settings.usage.inputTokens,
1385
+ outputTokens: settings.usage.outputTokens,
1386
+ cacheReadTokens: settings.usage.cacheReadTokens,
1387
+ cacheCreationTokens: settings.usage.cacheCreationTokens,
1388
+ })
1389
+ : 0;
1390
+ const stat = safeStatSync(filePath);
1391
+ const cwd = normalizeCwd(scan.cwd || '');
1392
+ const meta = {
1393
+ id: sessionId,
1394
+ shortId: sessionId.slice(0, 8),
1395
+ agent: 'droid',
1396
+ timestamp: scan.timestamp || (stat ? stat.mtime.toISOString() : new Date().toISOString()),
1397
+ project: cwd ? path.basename(cwd) : undefined,
1398
+ cwd,
1399
+ filePath,
1400
+ version: resolveSessionVersion('droid', filePath, undefined, currentVersion),
1401
+ topic: scan.topic,
1402
+ messageCount: scan.messageCount,
1403
+ tokenCount,
1404
+ costUsd: costUsd > 0 ? costUsd : undefined,
1405
+ durationMs: scan.durationMs,
1406
+ };
1407
+ return { meta, content: scan.contentText || '' };
1408
+ }
1409
+ /** Read model + token usage from a Droid `<uuid>.settings.json` sidecar. */
1410
+ function readDroidSettings(settingsPath) {
1411
+ try {
1412
+ const data = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
1413
+ const model = typeof data.model === 'string' ? data.model : undefined;
1414
+ const u = data.tokenUsage;
1415
+ if (!u || typeof u !== 'object')
1416
+ return { model };
1417
+ const usage = {
1418
+ inputTokens: u.inputTokens,
1419
+ outputTokens: u.outputTokens,
1420
+ cacheReadTokens: u.cacheReadTokens,
1421
+ cacheCreationTokens: u.cacheCreationTokens,
1422
+ };
1423
+ const tokenCount = sumKnownNumbers([
1424
+ u.inputTokens,
1425
+ u.outputTokens,
1426
+ u.cacheCreationTokens,
1427
+ u.cacheReadTokens,
1428
+ ]) ?? undefined;
1429
+ return { model, tokenCount, usage };
1430
+ }
1431
+ catch {
1432
+ return {};
1433
+ }
1434
+ }
1435
+ /** Stream a Droid JSONL file and extract scan-level metadata (id, cwd, topic, model, duration). */
1436
+ async function scanDroidSession(filePath) {
1437
+ const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
1438
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
1439
+ let sessionId;
1440
+ let timestamp;
1441
+ let cwd;
1442
+ let title;
1443
+ let sessionTitle;
1444
+ let firstUserTopic;
1445
+ let model;
1446
+ let messageCount = 0;
1447
+ let firstTsMs;
1448
+ let lastTsMs;
1449
+ const userTexts = [];
1450
+ try {
1451
+ for await (const line of rl) {
1452
+ if (!line.trim())
1453
+ continue;
1454
+ let parsed;
1455
+ try {
1456
+ parsed = JSON.parse(line);
1457
+ }
1458
+ catch {
1459
+ continue;
1460
+ }
1461
+ if (parsed.type === 'session_start') {
1462
+ sessionId = typeof parsed.id === 'string' ? parsed.id : sessionId;
1463
+ cwd = typeof parsed.cwd === 'string' ? parsed.cwd : cwd;
1464
+ // Droid auto-generates `sessionTitle`; `title` is the raw first prompt.
1465
+ if (typeof parsed.sessionTitle === 'string' && parsed.sessionTitle.trim()) {
1466
+ sessionTitle = parsed.sessionTitle.trim();
1467
+ }
1468
+ if (typeof parsed.title === 'string' && parsed.title.trim()) {
1469
+ title = parsed.title.trim();
1470
+ }
1471
+ continue;
1472
+ }
1473
+ if (parsed.type !== 'message')
1474
+ continue;
1475
+ // Track duration across every timestamped message.
1476
+ if (typeof parsed.timestamp === 'string') {
1477
+ const ms = new Date(parsed.timestamp).getTime();
1478
+ if (!Number.isNaN(ms)) {
1479
+ if (firstTsMs === undefined || ms < firstTsMs)
1480
+ firstTsMs = ms;
1481
+ if (lastTsMs === undefined || ms > lastTsMs)
1482
+ lastTsMs = ms;
1483
+ }
1484
+ }
1485
+ if (!timestamp && typeof parsed.timestamp === 'string')
1486
+ timestamp = parsed.timestamp;
1487
+ const msg = parsed.message || {};
1488
+ if (typeof msg.modelId === 'string')
1489
+ model = msg.modelId;
1490
+ const text = extractDroidMessageText(msg.content);
1491
+ if (!text)
1492
+ continue;
1493
+ messageCount++;
1494
+ if (msg.role === 'user') {
1495
+ userTexts.push(text);
1496
+ if (!firstUserTopic)
1497
+ firstUserTopic = extractSessionTopic(text);
1498
+ }
1499
+ }
1500
+ }
1501
+ finally {
1502
+ rl.close();
1503
+ stream.destroy();
1504
+ }
1505
+ const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
1506
+ ? lastTsMs - firstTsMs
1507
+ : undefined;
1508
+ return {
1509
+ sessionId,
1510
+ timestamp,
1511
+ cwd,
1512
+ // Prefer Droid's auto-title, then the raw first-prompt title, then the
1513
+ // derived first-user-message topic.
1514
+ topic: sessionTitle || title || firstUserTopic,
1515
+ model,
1516
+ messageCount,
1517
+ durationMs,
1518
+ contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
1519
+ };
1520
+ }
1521
+ /** Extract plain text from a Droid message content field (Anthropic-shaped blocks). */
1522
+ function extractDroidMessageText(content) {
1523
+ if (typeof content === 'string')
1524
+ return content.trim();
1525
+ if (!Array.isArray(content))
1526
+ return '';
1527
+ return content
1528
+ .map((part) => (typeof part?.text === 'string' && part.type === 'text' ? part.text : ''))
1529
+ // Droid front-loads injected context (date, skills list) as <system-reminder>
1530
+ // text blocks on the first user turn — drop them so topic/content stay clean.
1531
+ .filter((text) => text.trim() && !text.trim().startsWith('<system-reminder>'))
1532
+ .join('\n')
1533
+ .trim();
1534
+ }
1294
1535
  /** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
1295
1536
  export async function scanClaudeSession(filePath) {
1296
1537
  const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
@@ -1315,6 +1556,12 @@ export async function scanClaudeSession(filePath) {
1315
1556
  let lastTsMs;
1316
1557
  const seenAssistantIds = new Set();
1317
1558
  const userTexts = [];
1559
+ // Durable PR signal: set only when an actual `gh pr create` Bash *command*
1560
+ // runs (structural — the command field, not any prose mentioning it), then
1561
+ // capture the pull URL from a later tool_result's output.
1562
+ let sawPrCreate = false;
1563
+ let prUrl;
1564
+ let prNumber;
1318
1565
  try {
1319
1566
  for await (const line of rl) {
1320
1567
  if (!line.trim())
@@ -1331,6 +1578,31 @@ export async function scanClaudeSession(filePath) {
1331
1578
  if (!entrypoint && typeof parsed.entrypoint === 'string') {
1332
1579
  entrypoint = parsed.entrypoint;
1333
1580
  }
1581
+ // PR signal, structurally: a Bash tool_use whose command is `gh pr create`
1582
+ // marks intent; the pull URL is then read from a tool_result's output.
1583
+ if (!prUrl) {
1584
+ if (!sawPrCreate && parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
1585
+ for (const b of parsed.message.content) {
1586
+ if (b?.type === 'tool_use' && typeof b?.input?.command === 'string' && isPrCreateCommand(b.input.command)) {
1587
+ sawPrCreate = true;
1588
+ }
1589
+ }
1590
+ }
1591
+ if (sawPrCreate && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
1592
+ for (const b of parsed.message.content) {
1593
+ if (b?.type !== 'tool_result')
1594
+ continue;
1595
+ const text = typeof b.content === 'string'
1596
+ ? b.content
1597
+ : Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
1598
+ const pr = extractPrUrl(text);
1599
+ if (pr) {
1600
+ prUrl = pr.url;
1601
+ prNumber = pr.number;
1602
+ }
1603
+ }
1604
+ }
1605
+ }
1334
1606
  // Track duration across every timestamped event, not just the first.
1335
1607
  if (typeof parsed.timestamp === 'string') {
1336
1608
  const ms = new Date(parsed.timestamp).getTime();
@@ -1415,6 +1687,8 @@ export async function scanClaudeSession(filePath) {
1415
1687
  // Prefer an explicit session title (user `/rename` > Claude auto-title) over
1416
1688
  // the first-prompt topic.
1417
1689
  const resolvedTopic = customTitle || aiTitle || topic;
1690
+ const worktree = detectWorktree(cwd, gitBranch);
1691
+ const ticket = detectTicket(userTexts.join('\n') || undefined, gitBranch);
1418
1692
  return {
1419
1693
  timestamp,
1420
1694
  cwd,
@@ -1427,6 +1701,10 @@ export async function scanClaudeSession(filePath) {
1427
1701
  costUsd: sawCost ? costUsd : undefined,
1428
1702
  durationMs,
1429
1703
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
1704
+ prUrl,
1705
+ prNumber,
1706
+ worktreeSlug: worktree?.slug,
1707
+ ticketId: ticket?.id,
1430
1708
  };
1431
1709
  }
1432
1710
  /** Stream a Codex JSONL file and extract scan-level metadata (session ID, cwd, topic, tokens). */
@@ -1446,6 +1724,9 @@ async function scanCodexSession(filePath) {
1446
1724
  let firstTsMs;
1447
1725
  let lastTsMs;
1448
1726
  const userTexts = [];
1727
+ let sawPrCreate = false;
1728
+ let prUrl;
1729
+ let prNumber;
1449
1730
  try {
1450
1731
  for await (const line of rl) {
1451
1732
  if (!line.trim())
@@ -1457,6 +1738,28 @@ async function scanCodexSession(filePath) {
1457
1738
  catch {
1458
1739
  continue;
1459
1740
  }
1741
+ // PR signal, structurally: a Codex `function_call` whose command is
1742
+ // `gh pr create`, then the pull URL from a `function_call_output`.
1743
+ if (!prUrl && parsed.type === 'response_item') {
1744
+ const p = parsed.payload || {};
1745
+ if (!sawPrCreate && p.type === 'function_call') {
1746
+ let cmd = '';
1747
+ try {
1748
+ const args = typeof p.arguments === 'string' ? JSON.parse(p.arguments) : (p.arguments || {});
1749
+ cmd = String(args.command || args.cmd || '');
1750
+ }
1751
+ catch { /* non-JSON args */ }
1752
+ if (isPrCreateCommand(cmd))
1753
+ sawPrCreate = true;
1754
+ }
1755
+ if (sawPrCreate && p.type === 'function_call_output') {
1756
+ const pr = extractPrUrl(String(p.output || ''));
1757
+ if (pr) {
1758
+ prUrl = pr.url;
1759
+ prNumber = pr.number;
1760
+ }
1761
+ }
1762
+ }
1460
1763
  // Track duration across every timestamped event.
1461
1764
  if (typeof parsed.timestamp === 'string') {
1462
1765
  const ms = new Date(parsed.timestamp).getTime();
@@ -1527,6 +1830,8 @@ async function scanCodexSession(filePath) {
1527
1830
  const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
1528
1831
  ? lastTsMs - firstTsMs
1529
1832
  : undefined;
1833
+ const worktree = detectWorktree(cwd, gitBranch);
1834
+ const ticket = detectTicket(userTexts.join('\n') || undefined, gitBranch);
1530
1835
  return {
1531
1836
  sessionId,
1532
1837
  timestamp,
@@ -1539,6 +1844,10 @@ async function scanCodexSession(filePath) {
1539
1844
  costUsd,
1540
1845
  durationMs,
1541
1846
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
1847
+ prUrl,
1848
+ prNumber,
1849
+ worktreeSlug: worktree?.slug,
1850
+ ticketId: ticket?.id,
1542
1851
  };
1543
1852
  }
1544
1853
  /** Resolve the working directory for an OpenClaw agent from its workspace config. */
@@ -13,6 +13,8 @@ import type { SessionAgentId, SessionEvent } from './types.js';
13
13
  */
14
14
  export declare const SESSION_FILE_MAX_BYTES = 200000000;
15
15
  export declare function sanitizeForTerminal(s: string): string;
16
+ /** In-place sanitize every user-visible string field on a list of events. */
17
+ export declare function sanitizeEvents(events: SessionEvent[]): void;
16
18
  /**
17
19
  * Read a session file, refusing files above maxBytes. Bounded read protects
18
20
  * against multi-GB session blobs that would OOM the CLI or exceed V8's
@@ -31,8 +33,21 @@ export declare function detectAgent(filePath: string): SessionAgentId | null;
31
33
  export declare function summarizeToolUse(tool: string, args?: Record<string, any>): string;
32
34
  /** Parse a Claude JSONL session file into normalized events. */
33
35
  export declare function parseClaude(filePath: string): SessionEvent[];
36
+ /**
37
+ * Parse Claude JSONL *content* (already read into a string) into normalized
38
+ * events. Split from `parseClaude` so the tail reader can parse just the last
39
+ * chunk of a file without re-reading the whole thing. Malformed leading lines
40
+ * (a tail that starts mid-line) are skipped by the per-line try/catch below.
41
+ */
42
+ export declare function parseClaudeContent(content: string): SessionEvent[];
34
43
  /** Parse a Codex JSONL session file into normalized events. */
35
44
  export declare function parseCodex(filePath: string): SessionEvent[];
45
+ /**
46
+ * Parse Codex JSONL *content* (already read into a string) into normalized
47
+ * events. Split from `parseCodex` so the tail reader can parse just the last
48
+ * chunk without re-reading the whole file.
49
+ */
50
+ export declare function parseCodexContent(content: string): SessionEvent[];
36
51
  /** Parse a Gemini JSON session file into normalized events. */
37
52
  export declare function parseGemini(filePath: string): SessionEvent[];
38
53
  /**
@@ -51,3 +66,10 @@ export declare function parseRush(filePath: string): SessionEvent[];
51
66
  export declare function parseHermes(filePath: string): SessionEvent[];
52
67
  /** Parse a Kimi session state.json file by reading its agents/main/wire.jsonl. */
53
68
  export declare function parseKimi(filePath: string): SessionEvent[];
69
+ /**
70
+ * Parse a Droid (Factory) JSONL session file into normalized events. Droid
71
+ * wraps each turn in a `{type:'message', message:{role, content, modelId}}`
72
+ * envelope; the content blocks are Anthropic-shaped (text/thinking/tool_use/
73
+ * tool_result), so block handling mirrors the Claude parser.
74
+ */
75
+ export declare function parseDroid(filePath: string): SessionEvent[];
@@ -41,6 +41,11 @@ function sanitizeArgsDeep(value) {
41
41
  }
42
42
  return value;
43
43
  }
44
+ /** In-place sanitize every user-visible string field on a list of events. */
45
+ export function sanitizeEvents(events) {
46
+ for (const e of events)
47
+ sanitizeEvent(e);
48
+ }
44
49
  /** In-place sanitize all user-visible string fields on an event. */
45
50
  function sanitizeEvent(e) {
46
51
  if (e.content)
@@ -109,6 +114,9 @@ export function parseSession(filePath, agent) {
109
114
  case 'kimi':
110
115
  events = parseKimi(filePath);
111
116
  break;
117
+ case 'droid':
118
+ events = parseDroid(filePath);
119
+ break;
112
120
  }
113
121
  // Chokepoint: every string field that originated in an untrusted session
114
122
  // file gets stripped of terminal escapes here, so renderers downstream can
@@ -133,6 +141,8 @@ export function detectAgent(filePath) {
133
141
  return 'hermes';
134
142
  if (filePath.includes('/.kimi-code/') || filePath.includes('\\.kimi-code\\'))
135
143
  return 'kimi';
144
+ if (filePath.includes('/.factory/') || filePath.includes('\\.factory\\'))
145
+ return 'droid';
136
146
  // Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
137
147
  const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
138
148
  if (cloudMatch)
@@ -209,7 +219,15 @@ function shortenPath(p) {
209
219
  // ---------------------------------------------------------------------------
210
220
  /** Parse a Claude JSONL session file into normalized events. */
211
221
  export function parseClaude(filePath) {
212
- const content = safeReadSessionFile(filePath);
222
+ return parseClaudeContent(safeReadSessionFile(filePath));
223
+ }
224
+ /**
225
+ * Parse Claude JSONL *content* (already read into a string) into normalized
226
+ * events. Split from `parseClaude` so the tail reader can parse just the last
227
+ * chunk of a file without re-reading the whole thing. Malformed leading lines
228
+ * (a tail that starts mid-line) are skipped by the per-line try/catch below.
229
+ */
230
+ export function parseClaudeContent(content) {
213
231
  const lines = content.split('\n').filter(l => l.trim());
214
232
  const events = [];
215
233
  // Map tool_use id -> {tool, args} for correlating with tool_result
@@ -398,7 +416,14 @@ export function parseClaude(filePath) {
398
416
  // ---------------------------------------------------------------------------
399
417
  /** Parse a Codex JSONL session file into normalized events. */
400
418
  export function parseCodex(filePath) {
401
- const content = safeReadSessionFile(filePath);
419
+ return parseCodexContent(safeReadSessionFile(filePath));
420
+ }
421
+ /**
422
+ * Parse Codex JSONL *content* (already read into a string) into normalized
423
+ * events. Split from `parseCodex` so the tail reader can parse just the last
424
+ * chunk without re-reading the whole file.
425
+ */
426
+ export function parseCodexContent(content) {
402
427
  const lines = content.split('\n').filter(l => l.trim());
403
428
  const events = [];
404
429
  // Track function_call id -> name for correlating with function_call_output
@@ -1124,3 +1149,108 @@ export function parseKimi(filePath) {
1124
1149
  }
1125
1150
  return events;
1126
1151
  }
1152
+ // ---------------------------------------------------------------------------
1153
+ // Droid (Factory) parser
1154
+ // ---------------------------------------------------------------------------
1155
+ /**
1156
+ * Parse a Droid (Factory) JSONL session file into normalized events. Droid
1157
+ * wraps each turn in a `{type:'message', message:{role, content, modelId}}`
1158
+ * envelope; the content blocks are Anthropic-shaped (text/thinking/tool_use/
1159
+ * tool_result), so block handling mirrors the Claude parser.
1160
+ */
1161
+ export function parseDroid(filePath) {
1162
+ const content = safeReadSessionFile(filePath);
1163
+ const lines = content.split('\n').filter(l => l.trim());
1164
+ const events = [];
1165
+ // Map tool_use id -> {tool, args} for correlating with tool_result.
1166
+ const toolUseMap = new Map();
1167
+ for (const line of lines) {
1168
+ let raw;
1169
+ try {
1170
+ raw = JSON.parse(line);
1171
+ }
1172
+ catch {
1173
+ continue;
1174
+ }
1175
+ if (raw.type !== 'message')
1176
+ continue;
1177
+ const message = raw.message || {};
1178
+ const role = message.role === 'user' ? 'user' : 'assistant';
1179
+ const timestamp = raw.timestamp || new Date().toISOString();
1180
+ const blocks = message.content;
1181
+ // Plain-string content (rare) renders as a single message.
1182
+ if (typeof blocks === 'string') {
1183
+ const text = blocks.trim();
1184
+ if (text)
1185
+ events.push({ type: 'message', agent: 'droid', timestamp, role, content: text });
1186
+ continue;
1187
+ }
1188
+ if (!Array.isArray(blocks))
1189
+ continue;
1190
+ for (const block of blocks) {
1191
+ if (block.type === 'text') {
1192
+ const text = (block.text || '').trim();
1193
+ // Skip injected context blocks (date, skills list) on the first user turn.
1194
+ if (text && !(role === 'user' && text.startsWith('<system-reminder>'))) {
1195
+ events.push({ type: 'message', agent: 'droid', timestamp, role, content: text });
1196
+ }
1197
+ }
1198
+ else if (block.type === 'thinking') {
1199
+ const thinkingText = (block.thinking || '').trim();
1200
+ if (thinkingText)
1201
+ events.push({ type: 'thinking', agent: 'droid', timestamp, content: thinkingText });
1202
+ }
1203
+ else if (block.type === 'tool_use') {
1204
+ const toolName = block.name || 'unknown';
1205
+ const toolInput = block.input || {};
1206
+ if (block.id)
1207
+ toolUseMap.set(block.id, { tool: toolName, args: toolInput });
1208
+ events.push({
1209
+ type: 'tool_use',
1210
+ agent: 'droid',
1211
+ timestamp,
1212
+ tool: toolName,
1213
+ args: toolInput,
1214
+ path: toolInput.file_path || toolInput.path || undefined,
1215
+ command: toolName === 'Bash' ? toolInput.command : undefined,
1216
+ });
1217
+ }
1218
+ else if (block.type === 'tool_result') {
1219
+ const toolId = block.tool_use_id;
1220
+ const toolInfo = toolId ? toolUseMap.get(toolId) : undefined;
1221
+ const isError = block.is_error === true;
1222
+ let output = '';
1223
+ if (typeof block.content === 'string') {
1224
+ output = block.content;
1225
+ }
1226
+ else if (Array.isArray(block.content)) {
1227
+ output = block.content
1228
+ .filter((c) => c.type === 'text')
1229
+ .map((c) => c.text || '')
1230
+ .join('\n');
1231
+ }
1232
+ if (isError) {
1233
+ events.push({ type: 'error', agent: 'droid', timestamp, tool: toolInfo?.tool, content: output || 'Tool execution failed' });
1234
+ }
1235
+ else {
1236
+ events.push({
1237
+ type: 'tool_result',
1238
+ agent: 'droid',
1239
+ timestamp,
1240
+ tool: toolInfo?.tool,
1241
+ success: true,
1242
+ output: output.length > 500 ? output.slice(0, 497) + '...' : output,
1243
+ });
1244
+ }
1245
+ if (toolId)
1246
+ toolUseMap.delete(toolId);
1247
+ }
1248
+ else if (block.type === 'image') {
1249
+ const source = block.source || {};
1250
+ const sizeBytes = source.type === 'base64' ? Math.ceil((source.data?.length || 0) * 0.75) : 0;
1251
+ events.push({ type: 'attachment', agent: 'droid', timestamp, mediaType: source.media_type || 'image/png', sizeBytes });
1252
+ }
1253
+ }
1254
+ }
1255
+ return events;
1256
+ }
@@ -23,7 +23,7 @@ export declare function buildForwardedArgs(argv: string[], hosts?: Set<string>):
23
23
  * are quoted for the inner login shell, then the whole `agents …` invocation is
24
24
  * quoted again so it survives `bash -lc <...>`.
25
25
  */
26
- export declare function buildRemoteCommand(forwardedArgs: string[]): string;
26
+ export declare function buildRemoteCommand(forwardedArgs: string[], columns?: number): string;
27
27
  /** The four outcomes of one `ssh <host> agents sessions …` invocation. */
28
28
  export type SshOutcome = 'ok' | 'unreachable' | 'query-failed' | 'spawn-error';
29
29
  /**
@@ -28,6 +28,7 @@ import { createHash } from 'crypto';
28
28
  import chalk from 'chalk';
29
29
  import { getCacheDir } from '../state.js';
30
30
  import { formatRelativeTime } from './relative-time.js';
31
+ import { terminalWidth } from './width.js';
31
32
  /**
32
33
  * SSH target: a bare ssh-config host alias (e.g. `yosemite-s1`) or `user@host`.
33
34
  * The strict allowlist blocks shell metacharacters and a leading `-`, so a target
@@ -87,9 +88,13 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
87
88
  * are quoted for the inner login shell, then the whole `agents …` invocation is
88
89
  * quoted again so it survives `bash -lc <...>`.
89
90
  */
90
- export function buildRemoteCommand(forwardedArgs) {
91
+ export function buildRemoteCommand(forwardedArgs, columns) {
91
92
  const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
92
- return `bash -lc ${shellQuote(inner)}`;
93
+ // Forward the caller's terminal width so the remote renders the table to the
94
+ // local screen (over SSH the remote's own COLUMNS is unset/wrong). `VAR=val
95
+ // cmd` scopes the env to that process — the remote's terminalWidth() reads it.
96
+ const withCols = columns && columns > 0 ? `COLUMNS=${columns} ${inner}` : inner;
97
+ return `bash -lc ${shellQuote(withCols)}`;
93
98
  }
94
99
  const SSH_OPTS = [
95
100
  '-o', 'BatchMode=yes',
@@ -175,7 +180,7 @@ export function runRemoteSessions(hosts, argv = process.argv) {
175
180
  for (const host of hosts)
176
181
  assertValidSshTarget(host); // fail fast on any bad target
177
182
  const forwarded = buildForwardedArgs(argv, new Set(hosts));
178
- const remoteCmd = buildRemoteCommand(forwarded);
183
+ const remoteCmd = buildRemoteCommand(forwarded, terminalWidth());
179
184
  const multi = hosts.length > 1;
180
185
  let failures = 0;
181
186
  for (const host of hosts) {