@phnx-labs/agents-cli 1.20.27 → 1.20.29

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 (101) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/dist/commands/doctor.js +57 -4
  3. package/dist/commands/exec.d.ts +1 -1
  4. package/dist/commands/exec.js +198 -15
  5. package/dist/commands/hosts.d.ts +11 -0
  6. package/dist/commands/hosts.js +229 -0
  7. package/dist/commands/repo.d.ts +29 -0
  8. package/dist/commands/repo.js +174 -38
  9. package/dist/commands/secrets.d.ts +2 -7
  10. package/dist/commands/secrets.js +108 -29
  11. package/dist/commands/sessions.d.ts +2 -0
  12. package/dist/commands/sessions.js +8 -24
  13. package/dist/commands/ssh.d.ts +14 -0
  14. package/dist/commands/ssh.js +263 -0
  15. package/dist/commands/sync.d.ts +2 -0
  16. package/dist/commands/sync.js +22 -5
  17. package/dist/commands/view.js +27 -11
  18. package/dist/index.js +3 -1
  19. package/dist/lib/agents.d.ts +1 -0
  20. package/dist/lib/agents.js +44 -4
  21. package/dist/lib/browser/drivers/ssh.d.ts +47 -2
  22. package/dist/lib/browser/drivers/ssh.js +113 -24
  23. package/dist/lib/browser/profiles.js +28 -1
  24. package/dist/lib/browser/runtime-state.js +28 -8
  25. package/dist/lib/browser/types.d.ts +10 -1
  26. package/dist/lib/cli-resources.js +10 -1
  27. package/dist/lib/devices/connect.d.ts +34 -0
  28. package/dist/lib/devices/connect.js +101 -0
  29. package/dist/lib/devices/registry.d.ts +78 -0
  30. package/dist/lib/devices/registry.js +168 -0
  31. package/dist/lib/devices/ssh-config.d.ts +21 -0
  32. package/dist/lib/devices/ssh-config.js +33 -0
  33. package/dist/lib/devices/tailscale.d.ts +31 -0
  34. package/dist/lib/devices/tailscale.js +126 -0
  35. package/dist/lib/doctor-diff.d.ts +12 -0
  36. package/dist/lib/doctor-diff.js +89 -2
  37. package/dist/lib/exec.d.ts +27 -0
  38. package/dist/lib/exec.js +62 -19
  39. package/dist/lib/hooks.d.ts +17 -0
  40. package/dist/lib/hooks.js +127 -3
  41. package/dist/lib/hosts/dispatch.d.ts +26 -0
  42. package/dist/lib/hosts/dispatch.js +71 -0
  43. package/dist/lib/hosts/progress.d.ts +21 -0
  44. package/dist/lib/hosts/progress.js +49 -0
  45. package/dist/lib/hosts/providers/local.d.ts +17 -0
  46. package/dist/lib/hosts/providers/local.js +81 -0
  47. package/dist/lib/hosts/ready.d.ts +37 -0
  48. package/dist/lib/hosts/ready.js +88 -0
  49. package/dist/lib/hosts/registry.d.ts +22 -0
  50. package/dist/lib/hosts/registry.js +65 -0
  51. package/dist/lib/hosts/ssh-config.d.ts +37 -0
  52. package/dist/lib/hosts/ssh-config.js +157 -0
  53. package/dist/lib/hosts/tasks.d.ts +32 -0
  54. package/dist/lib/hosts/tasks.js +58 -0
  55. package/dist/lib/hosts/types.d.ts +51 -0
  56. package/dist/lib/hosts/types.js +21 -0
  57. package/dist/lib/loop.d.ts +9 -0
  58. package/dist/lib/loop.js +13 -1
  59. package/dist/lib/mcp.js +12 -3
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  61. package/dist/lib/migrate.js +9 -5
  62. package/dist/lib/platform/exec.d.ts +10 -0
  63. package/dist/lib/platform/exec.js +17 -0
  64. package/dist/lib/platform/index.d.ts +1 -0
  65. package/dist/lib/platform/index.js +1 -0
  66. package/dist/lib/platform/links.d.ts +15 -0
  67. package/dist/lib/platform/links.js +42 -0
  68. package/dist/lib/platform/paths.d.ts +18 -0
  69. package/dist/lib/platform/paths.js +22 -0
  70. package/dist/lib/platform/posixpath.d.ts +28 -0
  71. package/dist/lib/platform/posixpath.js +153 -0
  72. package/dist/lib/plugins.d.ts +10 -0
  73. package/dist/lib/plugins.js +1 -1
  74. package/dist/lib/project-launch.js +6 -3
  75. package/dist/lib/sandbox.js +5 -2
  76. package/dist/lib/secrets/remote.d.ts +67 -0
  77. package/dist/lib/secrets/remote.js +133 -0
  78. package/dist/lib/self-update.js +7 -2
  79. package/dist/lib/session/db.d.ts +24 -0
  80. package/dist/lib/session/db.js +80 -5
  81. package/dist/lib/session/discover.d.ts +28 -0
  82. package/dist/lib/session/discover.js +303 -4
  83. package/dist/lib/session/parse.d.ts +7 -0
  84. package/dist/lib/session/parse.js +110 -0
  85. package/dist/lib/session/relative-time.d.ts +7 -0
  86. package/dist/lib/session/relative-time.js +28 -0
  87. package/dist/lib/session/remote.d.ts +31 -3
  88. package/dist/lib/session/remote.js +121 -14
  89. package/dist/lib/session/types.d.ts +1 -1
  90. package/dist/lib/session/types.js +1 -1
  91. package/dist/lib/ssh-exec.d.ts +45 -0
  92. package/dist/lib/ssh-exec.js +61 -0
  93. package/dist/lib/startup/command-registry.d.ts +2 -0
  94. package/dist/lib/startup/command-registry.js +5 -0
  95. package/dist/lib/state.d.ts +2 -0
  96. package/dist/lib/state.js +2 -0
  97. package/dist/lib/types.d.ts +21 -0
  98. package/dist/lib/versions.d.ts +6 -2
  99. package/dist/lib/versions.js +8 -4
  100. package/package.json +1 -1
  101. package/scripts/postinstall.js +62 -0
@@ -21,7 +21,7 @@ import { getConfigSymlinkVersion } from '../shims.js';
21
21
  import { SESSION_AGENTS } from './types.js';
22
22
  import { extractSessionTopic } from './prompt.js';
23
23
  import { costOfUsage } from '../pricing/index.js';
24
- import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
24
+ import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
25
25
  const HOME = os.homedir();
26
26
  // Versions can live under either repo: the user repo (current canonical
27
27
  // location, ~/.agents/.history/versions/) or the system repo (legacy / npm-shipped,
@@ -34,6 +34,7 @@ const RUSH_SESSIONS_DIR = path.join(HOME, '.rush', 'sessions');
34
34
  const HERMES_SESSIONS_DIR = path.join(HOME, '.hermes', 'sessions');
35
35
  /** How long OpenClaw channel/cron snapshots stay valid before we re-shell-out. */
36
36
  const OPENCLAW_TTL_MS = 60_000;
37
+ const ACTIVE_APPEND_RESCAN_DEBOUNCE_MS = 5_000;
37
38
  let cachedOpenClawWorkspaces = null;
38
39
  const cachedAgentVersions = new Map();
39
40
  /**
@@ -63,6 +64,7 @@ export async function discoverSessions(options) {
63
64
  case 'rush': return scanRushIncremental(onProgress);
64
65
  case 'hermes': return scanHermesIncremental(onProgress);
65
66
  case 'kimi': return scanKimiIncremental(onProgress);
67
+ case 'droid': return scanDroidIncremental(onProgress);
66
68
  }
67
69
  }));
68
70
  }
@@ -164,10 +166,16 @@ export function searchContentIndex(sessions, query) {
164
166
  /**
165
167
  * For a list of files, stat each, compare to the DB ledger, and return only
166
168
  * the ones that need rescanning. One bulk DB query for the whole list.
169
+ *
170
+ * Actively running agents append to their JSONL every few seconds. Without a
171
+ * small debounce, repeated `agents sessions` invocations stream-parse the same
172
+ * growing transcript over and over. The cached row is good enough for a few
173
+ * seconds; once writes settle or the debounce expires, the file is parsed once.
167
174
  */
168
175
  function filterChangedFiles(filePaths) {
169
176
  const ledger = getScanStampsForPaths(filePaths);
170
177
  const out = [];
178
+ const now = Date.now();
171
179
  for (const filePath of filePaths) {
172
180
  const stat = safeStatSync(filePath);
173
181
  if (!stat)
@@ -180,10 +188,22 @@ function filterChangedFiles(filePaths) {
180
188
  if (prev && prev.fileMtimeMs === scan.fileMtimeMs && prev.fileSize === scan.fileSize) {
181
189
  continue;
182
190
  }
191
+ if (prev && shouldDeferRecentAppend(prev, scan, now)) {
192
+ continue;
193
+ }
183
194
  out.push({ filePath, scan });
184
195
  }
185
196
  return out;
186
197
  }
198
+ export function shouldDeferRecentAppend(prev, current, nowMs, debounceMs = ACTIVE_APPEND_RESCAN_DEBOUNCE_MS) {
199
+ if (prev.scannedAt === undefined)
200
+ return false;
201
+ if (current.fileSize <= prev.fileSize)
202
+ return false;
203
+ if (current.fileMtimeMs < prev.fileMtimeMs)
204
+ return false;
205
+ return nowMs - prev.scannedAt < debounceMs;
206
+ }
187
207
  // ---------------------------------------------------------------------------
188
208
  // Multi-version directory scanning
189
209
  // ---------------------------------------------------------------------------
@@ -486,8 +506,14 @@ async function scanCodexIncremental(onProgress) {
486
506
  }
487
507
  }
488
508
  const changed = filterChangedFiles(filePaths);
489
- if (changed.length === 0)
509
+ // Codex keeps human-readable titles (`thread_name`) in `session_index.jsonl`,
510
+ // which updates independently of the rollout files — apply them by id on every
511
+ // scan so a title that lands after a session was first indexed still surfaces.
512
+ const titles = readCodexThreadNames();
513
+ if (changed.length === 0) {
514
+ syncTopics(titles);
490
515
  return;
516
+ }
491
517
  onProgress?.({ agent: 'codex', parsed: 0, total: changed.length });
492
518
  const entries = [];
493
519
  const touched = [];
@@ -498,6 +524,10 @@ async function scanCodexIncremental(onProgress) {
498
524
  const result = await readCodexMeta(filePath, account, currentVersion);
499
525
  if (result && !seen.has(result.meta.id)) {
500
526
  seen.add(result.meta.id);
527
+ // Prefer the Codex-generated title over the first-prompt fallback.
528
+ const title = titles.get(result.meta.id);
529
+ if (title)
530
+ result.meta.topic = title;
501
531
  entries.push({ meta: result.meta, content: result.content, scan });
502
532
  }
503
533
  else {
@@ -512,6 +542,48 @@ async function scanCodexIncremental(onProgress) {
512
542
  }
513
543
  upsertSessionsBatch(entries);
514
544
  recordScans(touched);
545
+ // Catch sessions whose rollout file was unchanged but gained a title since the
546
+ // last scan (the index changed, the transcript did not).
547
+ syncTopics(titles);
548
+ }
549
+ /** Parse the lines of a Codex `session_index.jsonl` into a session id -> title map. */
550
+ export function parseCodexThreadNameIndex(raw) {
551
+ const titles = new Map();
552
+ for (const line of raw.split('\n')) {
553
+ if (!line.trim())
554
+ continue;
555
+ try {
556
+ const entry = JSON.parse(line);
557
+ const id = typeof entry.id === 'string' ? entry.id : '';
558
+ const name = typeof entry.thread_name === 'string' ? entry.thread_name.trim() : '';
559
+ if (id && name)
560
+ titles.set(id, name);
561
+ }
562
+ catch {
563
+ // skip malformed line
564
+ }
565
+ }
566
+ return titles;
567
+ }
568
+ /**
569
+ * Read Codex session titles across every Codex home (live + versioned). The
570
+ * `session_index.jsonl` file sits beside each `sessions/` rollout tree.
571
+ */
572
+ function readCodexThreadNames() {
573
+ const titles = new Map();
574
+ for (const sessionsDir of getAgentSessionDirs('codex', 'sessions')) {
575
+ const indexPath = path.join(path.dirname(sessionsDir), 'session_index.jsonl');
576
+ let raw;
577
+ try {
578
+ raw = fs.readFileSync(indexPath, 'utf-8');
579
+ }
580
+ catch {
581
+ continue; // no index in this home
582
+ }
583
+ for (const [id, name] of parseCodexThreadNameIndex(raw))
584
+ titles.set(id, name);
585
+ }
586
+ return titles;
515
587
  }
516
588
  /** Stream-parse a single Codex JSONL file to extract session metadata. */
517
589
  async function readCodexMeta(filePath, account, currentVersion) {
@@ -1239,8 +1311,216 @@ function extractHermesMessageText(content) {
1239
1311
  .join('\n')
1240
1312
  .trim();
1241
1313
  }
1314
+ /**
1315
+ * Incrementally re-scan changed Droid (Factory) session files and upsert into
1316
+ * the DB. Droid writes one `<uuid>.jsonl` transcript plus a sibling
1317
+ * `<uuid>.settings.json` (model + token usage) under
1318
+ * `~/.factory/sessions/<encoded-cwd>/`.
1319
+ */
1320
+ async function scanDroidIncremental(onProgress) {
1321
+ const currentVersion = await getCurrentAgentVersion('droid');
1322
+ const filePaths = [];
1323
+ for (const sessionsDir of getAgentSessionDirs('droid', 'sessions')) {
1324
+ // High limit: we only stat files here, parsing is gated by ledger match.
1325
+ for (const fp of walkForFiles(sessionsDir, '.jsonl', 100_000)) {
1326
+ filePaths.push(fp);
1327
+ }
1328
+ }
1329
+ const changed = filterChangedFiles(filePaths);
1330
+ if (changed.length === 0)
1331
+ return;
1332
+ onProgress?.({ agent: 'droid', parsed: 0, total: changed.length });
1333
+ const entries = [];
1334
+ const touched = [];
1335
+ const seen = new Set();
1336
+ let parsed = 0;
1337
+ for (const { filePath, scan } of changed) {
1338
+ try {
1339
+ const result = await readDroidMeta(filePath, currentVersion);
1340
+ if (result && !seen.has(result.meta.id)) {
1341
+ seen.add(result.meta.id);
1342
+ entries.push({ meta: result.meta, content: result.content, scan });
1343
+ }
1344
+ else {
1345
+ touched.push({ filePath, scan });
1346
+ }
1347
+ }
1348
+ catch {
1349
+ touched.push({ filePath, scan });
1350
+ }
1351
+ parsed++;
1352
+ onProgress?.({ agent: 'droid', parsed, total: changed.length });
1353
+ }
1354
+ upsertSessionsBatch(entries);
1355
+ recordScans(touched);
1356
+ }
1357
+ /** Stream-parse a single Droid JSONL file (+ sibling settings) into session metadata. */
1358
+ async function readDroidMeta(filePath, currentVersion) {
1359
+ const scan = await scanDroidSession(filePath);
1360
+ // The filename is the canonical session id; fall back to the session_start id.
1361
+ const sessionId = path.basename(filePath).replace(/\.jsonl$/, '') || scan.sessionId || '';
1362
+ if (!sessionId)
1363
+ return null;
1364
+ // Token usage and cost live only in the sibling `<uuid>.settings.json`.
1365
+ const settings = readDroidSettings(filePath.replace(/\.jsonl$/, '.settings.json'));
1366
+ const model = settings.model || scan.model;
1367
+ const tokenCount = settings.tokenCount;
1368
+ const costUsd = model && settings.usage
1369
+ ? costOfUsage({
1370
+ model,
1371
+ inputTokens: settings.usage.inputTokens,
1372
+ outputTokens: settings.usage.outputTokens,
1373
+ cacheReadTokens: settings.usage.cacheReadTokens,
1374
+ cacheCreationTokens: settings.usage.cacheCreationTokens,
1375
+ })
1376
+ : 0;
1377
+ const stat = safeStatSync(filePath);
1378
+ const cwd = normalizeCwd(scan.cwd || '');
1379
+ const meta = {
1380
+ id: sessionId,
1381
+ shortId: sessionId.slice(0, 8),
1382
+ agent: 'droid',
1383
+ timestamp: scan.timestamp || (stat ? stat.mtime.toISOString() : new Date().toISOString()),
1384
+ project: cwd ? path.basename(cwd) : undefined,
1385
+ cwd,
1386
+ filePath,
1387
+ version: resolveSessionVersion('droid', filePath, undefined, currentVersion),
1388
+ topic: scan.topic,
1389
+ messageCount: scan.messageCount,
1390
+ tokenCount,
1391
+ costUsd: costUsd > 0 ? costUsd : undefined,
1392
+ durationMs: scan.durationMs,
1393
+ };
1394
+ return { meta, content: scan.contentText || '' };
1395
+ }
1396
+ /** Read model + token usage from a Droid `<uuid>.settings.json` sidecar. */
1397
+ function readDroidSettings(settingsPath) {
1398
+ try {
1399
+ const data = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
1400
+ const model = typeof data.model === 'string' ? data.model : undefined;
1401
+ const u = data.tokenUsage;
1402
+ if (!u || typeof u !== 'object')
1403
+ return { model };
1404
+ const usage = {
1405
+ inputTokens: u.inputTokens,
1406
+ outputTokens: u.outputTokens,
1407
+ cacheReadTokens: u.cacheReadTokens,
1408
+ cacheCreationTokens: u.cacheCreationTokens,
1409
+ };
1410
+ const tokenCount = sumKnownNumbers([
1411
+ u.inputTokens,
1412
+ u.outputTokens,
1413
+ u.cacheCreationTokens,
1414
+ u.cacheReadTokens,
1415
+ ]) ?? undefined;
1416
+ return { model, tokenCount, usage };
1417
+ }
1418
+ catch {
1419
+ return {};
1420
+ }
1421
+ }
1422
+ /** Stream a Droid JSONL file and extract scan-level metadata (id, cwd, topic, model, duration). */
1423
+ async function scanDroidSession(filePath) {
1424
+ const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
1425
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
1426
+ let sessionId;
1427
+ let timestamp;
1428
+ let cwd;
1429
+ let title;
1430
+ let sessionTitle;
1431
+ let firstUserTopic;
1432
+ let model;
1433
+ let messageCount = 0;
1434
+ let firstTsMs;
1435
+ let lastTsMs;
1436
+ const userTexts = [];
1437
+ try {
1438
+ for await (const line of rl) {
1439
+ if (!line.trim())
1440
+ continue;
1441
+ let parsed;
1442
+ try {
1443
+ parsed = JSON.parse(line);
1444
+ }
1445
+ catch {
1446
+ continue;
1447
+ }
1448
+ if (parsed.type === 'session_start') {
1449
+ sessionId = typeof parsed.id === 'string' ? parsed.id : sessionId;
1450
+ cwd = typeof parsed.cwd === 'string' ? parsed.cwd : cwd;
1451
+ // Droid auto-generates `sessionTitle`; `title` is the raw first prompt.
1452
+ if (typeof parsed.sessionTitle === 'string' && parsed.sessionTitle.trim()) {
1453
+ sessionTitle = parsed.sessionTitle.trim();
1454
+ }
1455
+ if (typeof parsed.title === 'string' && parsed.title.trim()) {
1456
+ title = parsed.title.trim();
1457
+ }
1458
+ continue;
1459
+ }
1460
+ if (parsed.type !== 'message')
1461
+ continue;
1462
+ // Track duration across every timestamped message.
1463
+ if (typeof parsed.timestamp === 'string') {
1464
+ const ms = new Date(parsed.timestamp).getTime();
1465
+ if (!Number.isNaN(ms)) {
1466
+ if (firstTsMs === undefined || ms < firstTsMs)
1467
+ firstTsMs = ms;
1468
+ if (lastTsMs === undefined || ms > lastTsMs)
1469
+ lastTsMs = ms;
1470
+ }
1471
+ }
1472
+ if (!timestamp && typeof parsed.timestamp === 'string')
1473
+ timestamp = parsed.timestamp;
1474
+ const msg = parsed.message || {};
1475
+ if (typeof msg.modelId === 'string')
1476
+ model = msg.modelId;
1477
+ const text = extractDroidMessageText(msg.content);
1478
+ if (!text)
1479
+ continue;
1480
+ messageCount++;
1481
+ if (msg.role === 'user') {
1482
+ userTexts.push(text);
1483
+ if (!firstUserTopic)
1484
+ firstUserTopic = extractSessionTopic(text);
1485
+ }
1486
+ }
1487
+ }
1488
+ finally {
1489
+ rl.close();
1490
+ stream.destroy();
1491
+ }
1492
+ const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
1493
+ ? lastTsMs - firstTsMs
1494
+ : undefined;
1495
+ return {
1496
+ sessionId,
1497
+ timestamp,
1498
+ cwd,
1499
+ // Prefer Droid's auto-title, then the raw first-prompt title, then the
1500
+ // derived first-user-message topic.
1501
+ topic: sessionTitle || title || firstUserTopic,
1502
+ model,
1503
+ messageCount,
1504
+ durationMs,
1505
+ contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
1506
+ };
1507
+ }
1508
+ /** Extract plain text from a Droid message content field (Anthropic-shaped blocks). */
1509
+ function extractDroidMessageText(content) {
1510
+ if (typeof content === 'string')
1511
+ return content.trim();
1512
+ if (!Array.isArray(content))
1513
+ return '';
1514
+ return content
1515
+ .map((part) => (typeof part?.text === 'string' && part.type === 'text' ? part.text : ''))
1516
+ // Droid front-loads injected context (date, skills list) as <system-reminder>
1517
+ // text blocks on the first user turn — drop them so topic/content stay clean.
1518
+ .filter((text) => text.trim() && !text.trim().startsWith('<system-reminder>'))
1519
+ .join('\n')
1520
+ .trim();
1521
+ }
1242
1522
  /** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
1243
- async function scanClaudeSession(filePath) {
1523
+ export async function scanClaudeSession(filePath) {
1244
1524
  const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
1245
1525
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
1246
1526
  let timestamp;
@@ -1248,6 +1528,10 @@ async function scanClaudeSession(filePath) {
1248
1528
  let gitBranch;
1249
1529
  let version;
1250
1530
  let topic;
1531
+ // Explicit session titles: `/rename` writes a `custom-title` event; Claude
1532
+ // auto-generates an `ai-title`. Both can repeat across the file — last wins.
1533
+ let customTitle;
1534
+ let aiTitle;
1251
1535
  let entrypoint;
1252
1536
  let messageCount = 0;
1253
1537
  let tokenCount = 0;
@@ -1291,6 +1575,18 @@ async function scanClaudeSession(filePath) {
1291
1575
  gitBranch = parsed.gitBranch || undefined;
1292
1576
  version = parsed.version || undefined;
1293
1577
  }
1578
+ if (parsed.type === 'custom-title') {
1579
+ const t = typeof parsed.customTitle === 'string' ? parsed.customTitle.trim() : '';
1580
+ if (t)
1581
+ customTitle = t;
1582
+ continue;
1583
+ }
1584
+ if (parsed.type === 'ai-title') {
1585
+ const t = typeof parsed.aiTitle === 'string' ? parsed.aiTitle.trim() : '';
1586
+ if (t)
1587
+ aiTitle = t;
1588
+ continue;
1589
+ }
1294
1590
  if (parsed.type === 'user') {
1295
1591
  const text = extractClaudeUserText(parsed);
1296
1592
  if (text) {
@@ -1344,12 +1640,15 @@ async function scanClaudeSession(filePath) {
1344
1640
  const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
1345
1641
  ? lastTsMs - firstTsMs
1346
1642
  : undefined;
1643
+ // Prefer an explicit session title (user `/rename` > Claude auto-title) over
1644
+ // the first-prompt topic.
1645
+ const resolvedTopic = customTitle || aiTitle || topic;
1347
1646
  return {
1348
1647
  timestamp,
1349
1648
  cwd,
1350
1649
  gitBranch,
1351
1650
  version,
1352
- topic,
1651
+ topic: resolvedTopic,
1353
1652
  entrypoint,
1354
1653
  messageCount,
1355
1654
  tokenCount: sawTokenCount ? tokenCount : undefined,
@@ -51,3 +51,10 @@ export declare function parseRush(filePath: string): SessionEvent[];
51
51
  export declare function parseHermes(filePath: string): SessionEvent[];
52
52
  /** Parse a Kimi session state.json file by reading its agents/main/wire.jsonl. */
53
53
  export declare function parseKimi(filePath: string): SessionEvent[];
54
+ /**
55
+ * Parse a Droid (Factory) JSONL session file into normalized events. Droid
56
+ * wraps each turn in a `{type:'message', message:{role, content, modelId}}`
57
+ * envelope; the content blocks are Anthropic-shaped (text/thinking/tool_use/
58
+ * tool_result), so block handling mirrors the Claude parser.
59
+ */
60
+ export declare function parseDroid(filePath: string): SessionEvent[];
@@ -109,6 +109,9 @@ export function parseSession(filePath, agent) {
109
109
  case 'kimi':
110
110
  events = parseKimi(filePath);
111
111
  break;
112
+ case 'droid':
113
+ events = parseDroid(filePath);
114
+ break;
112
115
  }
113
116
  // Chokepoint: every string field that originated in an untrusted session
114
117
  // file gets stripped of terminal escapes here, so renderers downstream can
@@ -133,6 +136,8 @@ export function detectAgent(filePath) {
133
136
  return 'hermes';
134
137
  if (filePath.includes('/.kimi-code/') || filePath.includes('\\.kimi-code\\'))
135
138
  return 'kimi';
139
+ if (filePath.includes('/.factory/') || filePath.includes('\\.factory\\'))
140
+ return 'droid';
136
141
  // Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
137
142
  const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
138
143
  if (cloudMatch)
@@ -1124,3 +1129,108 @@ export function parseKimi(filePath) {
1124
1129
  }
1125
1130
  return events;
1126
1131
  }
1132
+ // ---------------------------------------------------------------------------
1133
+ // Droid (Factory) parser
1134
+ // ---------------------------------------------------------------------------
1135
+ /**
1136
+ * Parse a Droid (Factory) JSONL session file into normalized events. Droid
1137
+ * wraps each turn in a `{type:'message', message:{role, content, modelId}}`
1138
+ * envelope; the content blocks are Anthropic-shaped (text/thinking/tool_use/
1139
+ * tool_result), so block handling mirrors the Claude parser.
1140
+ */
1141
+ export function parseDroid(filePath) {
1142
+ const content = safeReadSessionFile(filePath);
1143
+ const lines = content.split('\n').filter(l => l.trim());
1144
+ const events = [];
1145
+ // Map tool_use id -> {tool, args} for correlating with tool_result.
1146
+ const toolUseMap = new Map();
1147
+ for (const line of lines) {
1148
+ let raw;
1149
+ try {
1150
+ raw = JSON.parse(line);
1151
+ }
1152
+ catch {
1153
+ continue;
1154
+ }
1155
+ if (raw.type !== 'message')
1156
+ continue;
1157
+ const message = raw.message || {};
1158
+ const role = message.role === 'user' ? 'user' : 'assistant';
1159
+ const timestamp = raw.timestamp || new Date().toISOString();
1160
+ const blocks = message.content;
1161
+ // Plain-string content (rare) renders as a single message.
1162
+ if (typeof blocks === 'string') {
1163
+ const text = blocks.trim();
1164
+ if (text)
1165
+ events.push({ type: 'message', agent: 'droid', timestamp, role, content: text });
1166
+ continue;
1167
+ }
1168
+ if (!Array.isArray(blocks))
1169
+ continue;
1170
+ for (const block of blocks) {
1171
+ if (block.type === 'text') {
1172
+ const text = (block.text || '').trim();
1173
+ // Skip injected context blocks (date, skills list) on the first user turn.
1174
+ if (text && !(role === 'user' && text.startsWith('<system-reminder>'))) {
1175
+ events.push({ type: 'message', agent: 'droid', timestamp, role, content: text });
1176
+ }
1177
+ }
1178
+ else if (block.type === 'thinking') {
1179
+ const thinkingText = (block.thinking || '').trim();
1180
+ if (thinkingText)
1181
+ events.push({ type: 'thinking', agent: 'droid', timestamp, content: thinkingText });
1182
+ }
1183
+ else if (block.type === 'tool_use') {
1184
+ const toolName = block.name || 'unknown';
1185
+ const toolInput = block.input || {};
1186
+ if (block.id)
1187
+ toolUseMap.set(block.id, { tool: toolName, args: toolInput });
1188
+ events.push({
1189
+ type: 'tool_use',
1190
+ agent: 'droid',
1191
+ timestamp,
1192
+ tool: toolName,
1193
+ args: toolInput,
1194
+ path: toolInput.file_path || toolInput.path || undefined,
1195
+ command: toolName === 'Bash' ? toolInput.command : undefined,
1196
+ });
1197
+ }
1198
+ else if (block.type === 'tool_result') {
1199
+ const toolId = block.tool_use_id;
1200
+ const toolInfo = toolId ? toolUseMap.get(toolId) : undefined;
1201
+ const isError = block.is_error === true;
1202
+ let output = '';
1203
+ if (typeof block.content === 'string') {
1204
+ output = block.content;
1205
+ }
1206
+ else if (Array.isArray(block.content)) {
1207
+ output = block.content
1208
+ .filter((c) => c.type === 'text')
1209
+ .map((c) => c.text || '')
1210
+ .join('\n');
1211
+ }
1212
+ if (isError) {
1213
+ events.push({ type: 'error', agent: 'droid', timestamp, tool: toolInfo?.tool, content: output || 'Tool execution failed' });
1214
+ }
1215
+ else {
1216
+ events.push({
1217
+ type: 'tool_result',
1218
+ agent: 'droid',
1219
+ timestamp,
1220
+ tool: toolInfo?.tool,
1221
+ success: true,
1222
+ output: output.length > 500 ? output.slice(0, 497) + '...' : output,
1223
+ });
1224
+ }
1225
+ if (toolId)
1226
+ toolUseMap.delete(toolId);
1227
+ }
1228
+ else if (block.type === 'image') {
1229
+ const source = block.source || {};
1230
+ const sizeBytes = source.type === 'base64' ? Math.ceil((source.data?.length || 0) * 0.75) : 0;
1231
+ events.push({ type: 'attachment', agent: 'droid', timestamp, mediaType: source.media_type || 'image/png', sizeBytes });
1232
+ }
1233
+ }
1234
+ }
1235
+ return events;
1236
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Human-readable "time since" formatting for session timestamps. Lives here (not
3
+ * inline in `sessions.ts`) so both the session list renderer and the remote
4
+ * offline-cache banner (`remote.ts`) share one formatter — `sessions.ts` imports
5
+ * `remote.ts`, so a back-import would cycle.
6
+ */
7
+ export declare function formatRelativeTime(isoTimestamp: string): string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Human-readable "time since" formatting for session timestamps. Lives here (not
3
+ * inline in `sessions.ts`) so both the session list renderer and the remote
4
+ * offline-cache banner (`remote.ts`) share one formatter — `sessions.ts` imports
5
+ * `remote.ts`, so a back-import would cycle.
6
+ */
7
+ export function formatRelativeTime(isoTimestamp) {
8
+ const now = Date.now();
9
+ const then = new Date(isoTimestamp).getTime();
10
+ if (isNaN(then))
11
+ return isoTimestamp;
12
+ const diffMs = now - then;
13
+ const diffMin = Math.floor(diffMs / 60_000);
14
+ const diffHrs = Math.floor(diffMs / 3_600_000);
15
+ const diffDays = Math.floor(diffMs / 86_400_000);
16
+ if (diffMin < 1)
17
+ return 'just now';
18
+ if (diffMin < 60)
19
+ return `${diffMin} min ago`;
20
+ if (diffHrs < 24)
21
+ return `${diffHrs} hour${diffHrs === 1 ? '' : 's'} ago`;
22
+ if (diffDays < 7)
23
+ return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
24
+ // Older: show date
25
+ const d = new Date(then);
26
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
27
+ return `${months[d.getMonth()]} ${d.getDate()}`;
28
+ }
@@ -24,10 +24,38 @@ export declare function buildForwardedArgs(argv: string[], hosts?: Set<string>):
24
24
  * quoted again so it survives `bash -lc <...>`.
25
25
  */
26
26
  export declare function buildRemoteCommand(forwardedArgs: string[]): string;
27
+ /** The four outcomes of one `ssh <host> agents sessions …` invocation. */
28
+ export type SshOutcome = 'ok' | 'unreachable' | 'query-failed' | 'spawn-error';
29
+ /**
30
+ * Classify an ssh `spawnSync` result. ssh(1) reserves exit 255 for its own
31
+ * connection-layer failures (host down, timeout, refused, auth, changed host
32
+ * key) — distinct from any other non-zero, which is the remote `agents sessions`
33
+ * exit code forwarded back (the query ran but failed). The two must be handled
34
+ * differently: 255 may fall back to cache, a forwarded failure must surface.
35
+ */
36
+ export declare function classifySshFailure(res: {
37
+ error?: Error | null;
38
+ status: number | null;
39
+ }): SshOutcome;
40
+ /**
41
+ * Deterministic cache path for a (host, forwarded-args) pair. The forwarded args
42
+ * are hashed so distinct queries cache independently; the host stays readable in
43
+ * the filename (sanitised so `user@host` and aliases are filesystem-safe).
44
+ */
45
+ export declare function remoteCachePath(host: string, forwardedArgs: string[]): string;
46
+ /** Banner shown above replayed cache rows when the peer is offline. */
47
+ export declare function formatStaleBanner(host: string, mtimeMs: number): string;
48
+ /** Message shown when a host is unreachable and there is no cache to fall back to. */
49
+ export declare function formatUnreachable(host: string): string;
27
50
  /**
28
51
  * Run the current `agents sessions` invocation on one or more remote machines over
29
- * SSH, streaming each remote's output to the terminal. Sets `process.exitCode = 1`
30
- * if any host fails. Reads the invocation from `process.argv` (override via
31
- * `argv` for testing).
52
+ * SSH, writing each remote's output to the terminal. A successful fetch is cached;
53
+ * an unreachable host falls back to that cache (with a stale banner) when present.
54
+ * Sets `process.exitCode = 1` if any host could not be answered (live or cached).
55
+ * Reads the invocation from `process.argv` (override via `argv` for testing).
56
+ *
57
+ * Output is captured rather than `stdio: 'inherit'`-streamed so it can be cached.
58
+ * Session output is small and the remote returns quickly, so buffering is
59
+ * imperceptible; `maxBuffer` is generous for the rare large `--markdown <id>` dump.
32
60
  */
33
61
  export declare function runRemoteSessions(hosts: string[], argv?: string[]): void;