@phnx-labs/agents-cli 1.22.115 → 1.22.116

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 (88) hide show
  1. package/CHANGELOG.md +149 -14
  2. package/README.md +1 -1
  3. package/dist/commands/browser.js +11 -0
  4. package/dist/commands/exec.js +212 -141
  5. package/dist/commands/feed.js +65 -14
  6. package/dist/commands/sessions-picker.d.ts +11 -0
  7. package/dist/commands/sessions-picker.js +88 -7
  8. package/dist/commands/sessions.d.ts +21 -2
  9. package/dist/commands/sessions.js +157 -3
  10. package/dist/commands/setup-secrets.d.ts +2 -2
  11. package/dist/commands/setup-term.d.ts +24 -0
  12. package/dist/commands/setup-term.js +70 -0
  13. package/dist/commands/setup.d.ts +1 -1
  14. package/dist/commands/setup.js +12 -4
  15. package/dist/commands/ssh.js +1 -69
  16. package/dist/lib/accounting/rotate.d.ts +63 -1
  17. package/dist/lib/accounting/rotate.js +56 -0
  18. package/dist/lib/accounts/add.js +2 -2
  19. package/dist/lib/accounts/slots.js +32 -2
  20. package/dist/lib/answer-router.d.ts +11 -2
  21. package/dist/lib/answer-router.js +26 -2
  22. package/dist/lib/auth-mint.d.ts +5 -4
  23. package/dist/lib/auth-mint.js +4 -3
  24. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  25. package/dist/lib/browser/service.d.ts +10 -0
  26. package/dist/lib/browser/service.js +208 -36
  27. package/dist/lib/browser/types.d.ts +18 -0
  28. package/dist/lib/config-keys.d.ts +1 -1
  29. package/dist/lib/config-keys.js +5 -0
  30. package/dist/lib/device-config.js +61 -0
  31. package/dist/lib/devices/doctor-findings.js +2 -6
  32. package/dist/lib/feed/answer.d.ts +153 -4
  33. package/dist/lib/feed/answer.js +716 -105
  34. package/dist/lib/feed/feed.d.ts +61 -1
  35. package/dist/lib/feed/feed.js +226 -14
  36. package/dist/lib/feed/hub-server.d.ts +58 -3
  37. package/dist/lib/feed/hub-server.js +306 -54
  38. package/dist/lib/feed/pr-status.d.ts +8 -0
  39. package/dist/lib/feed/pr-status.js +9 -1
  40. package/dist/lib/feed-outcome.d.ts +1 -1
  41. package/dist/lib/feed-outcome.js +9 -2
  42. package/dist/lib/feed-policy.js +9 -3
  43. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  44. package/dist/lib/fleet/auth-sync.js +2 -89
  45. package/dist/lib/harness-auth-capabilities.js +7 -2
  46. package/dist/lib/hosts/dispatch.d.ts +20 -1
  47. package/dist/lib/hosts/dispatch.js +52 -30
  48. package/dist/lib/hosts/remote-cmd.d.ts +21 -0
  49. package/dist/lib/hosts/remote-cmd.js +26 -2
  50. package/dist/lib/mailbox.d.ts +12 -0
  51. package/dist/lib/mailbox.js +16 -2
  52. package/dist/lib/menubar/snapshot.d.ts +51 -0
  53. package/dist/lib/menubar/snapshot.js +42 -3
  54. package/dist/lib/open-url.js +2 -2
  55. package/dist/lib/projects.d.ts +23 -0
  56. package/dist/lib/projects.js +78 -0
  57. package/dist/lib/secrets-cli.d.ts +3 -3
  58. package/dist/lib/secrets-cli.js +1 -1
  59. package/dist/lib/session/active.d.ts +1 -0
  60. package/dist/lib/session/active.js +8 -0
  61. package/dist/lib/session/db.d.ts +67 -3
  62. package/dist/lib/session/db.js +381 -126
  63. package/dist/lib/session/prompt.d.ts +23 -7
  64. package/dist/lib/session/prompt.js +46 -8
  65. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  66. package/dist/lib/session/remote/remote-list.js +22 -6
  67. package/dist/lib/session/remote/watch.d.ts +12 -0
  68. package/dist/lib/session/remote/watch.js +9 -0
  69. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  70. package/dist/lib/session/remote-preview-cache.js +373 -0
  71. package/dist/lib/session/tail.d.ts +50 -0
  72. package/dist/lib/session/tail.js +219 -0
  73. package/dist/lib/setup-tool-install.js +2 -1
  74. package/dist/lib/setup-tool-status.d.ts +1 -1
  75. package/dist/lib/setup-tool-status.js +6 -1
  76. package/dist/lib/signin-badge.d.ts +19 -4
  77. package/dist/lib/signin-badge.js +29 -11
  78. package/dist/lib/term-driver.d.ts +24 -0
  79. package/dist/lib/term-driver.js +36 -0
  80. package/dist/lib/terminal/index.d.ts +1 -1
  81. package/dist/lib/terminal/index.js +1 -1
  82. package/dist/lib/terminal/inject.d.ts +38 -0
  83. package/dist/lib/terminal/inject.js +55 -9
  84. package/dist/lib/terminal/transport.d.ts +15 -5
  85. package/dist/lib/terminal/transport.js +61 -11
  86. package/package.json +1 -1
  87. package/dist/lib/fleet/remote-login.d.ts +0 -170
  88. package/dist/lib/fleet/remote-login.js +0 -568
@@ -46,8 +46,13 @@ export const SCHEMA_VERSION = 50;
46
46
  * v4 (PHNX-3621 leftover) invalidates Grok rows stamped before the bounded
47
47
  * chat_history.jsonl prefix read filled firstUserMessage.
48
48
  * v5 (PHNX-3939) restores first-meta Codex fork ownership, including cold files.
49
+ * v6 (PHNX-3999) invalidates rows whose stored `label` is harness scaffolding — a
50
+ * `<bash-input>` shell echo, a `<command-name>` wrapper, a bare `/clear` — which
51
+ * `cleanGeneratedSessionLabel` now rejects. The label is written at index time, so
52
+ * without this bump every already-indexed session keeps showing the junk title
53
+ * until its transcript happens to change.
49
54
  */
50
- export const CONTENT_INDEX_VERSION = 5;
55
+ export const CONTENT_INDEX_VERSION = 6;
51
56
  /**
52
57
  * Bump to force `agents sessions backfill resources` to re-derive every
53
58
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -518,6 +523,40 @@ CREATE TABLE IF NOT EXISTS computer_sessions (
518
523
  );
519
524
  CREATE INDEX IF NOT EXISTS idx_computer_sessions_session ON computer_sessions(session_id);
520
525
  CREATE INDEX IF NOT EXISTS idx_computer_sessions_started ON computer_sessions(started_at DESC);
526
+
527
+ -- Durable requester-side cache for a REMOTE session's preview envelope
528
+ -- (PHNX-3999). Keyed on (normalized owning device, full session id, schema
529
+ -- version) rather than transcript bytes, since this box never reads the peer's
530
+ -- transcript directly -- it only holds the last envelope one bounded
531
+ -- 'sessions preview <id> --local --json' hop returned FROM that device. Content
532
+ -- freshness (this row) is deliberately independent of live status, which is
533
+ -- never cached here and always re-read from the live registry when available.
534
+ -- ok=1 rows carry the last successful envelope_json; ok=0 rows carry no payload,
535
+ -- only a failure_reason, so a session that was resolvable once but is now
536
+ -- offline still degrades to the last GOOD payload (read separately, ok=1 only)
537
+ -- annotated stale rather than losing it to a later failed attempt overwriting it.
538
+ CREATE TABLE IF NOT EXISTS session_remote_preview_cache (
539
+ device TEXT NOT NULL,
540
+ session_id TEXT NOT NULL,
541
+ schema_version INTEGER NOT NULL,
542
+ fetched_at INTEGER NOT NULL,
543
+ ok INTEGER NOT NULL,
544
+ envelope_json TEXT,
545
+ envelope_bytes INTEGER NOT NULL DEFAULT 0,
546
+ failure_reason TEXT,
547
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
548
+ next_attempt_at INTEGER NOT NULL DEFAULT 0,
549
+ -- The caller's OWN last-supplied --revision cursor (opaque: an epoch-ms
550
+ -- string, an ISO stamp, whatever the caller's own activity feed hands us) --
551
+ -- deliberately NOT compared to the envelope's own details.sourceRevision,
552
+ -- since a caller's revision format (e.g. a feed's lastActivityMs) need not
553
+ -- match the envelope's own (an ISO session.lastActivity). Equality is
554
+ -- against THIS column only: same value back => caller has independently
555
+ -- confirmed nothing changed.
556
+ last_caller_revision TEXT,
557
+ PRIMARY KEY (device, session_id, schema_version)
558
+ );
559
+ CREATE INDEX IF NOT EXISTS idx_remote_preview_cache_fetched ON session_remote_preview_cache(fetched_at DESC);
521
560
  `;
522
561
  /**
523
562
  * Bumping this invalidates every cached facet row without touching the schema
@@ -532,7 +571,7 @@ export const SESSION_TOPIC_EXTRACTOR_VERSION = 2;
532
571
  // Bumped to 2 (PHNX-2973): the digest now carries `changedFiles` (per-file
533
572
  // paths). Bumping invalidates v1 cache rows so a fresh recompute populates the
534
573
  // new field instead of serving a stale digest that predates it.
535
- const PREVIEW_EXTRACTOR_VERSION = 2;
574
+ const PREVIEW_EXTRACTOR_VERSION = 3;
536
575
  /** Bump when classifyPhenotype's output changes so cached phenotypes recompute (PHNX-3327 v1). */
537
576
  export const SESSION_PHENOTYPE_EXTRACTOR_VERSION = 1;
538
577
  /** Bump when the summarizer output shape changes so cached summaries recompute (PHNX-3939 v1). */
@@ -1404,7 +1443,7 @@ function backfillClaudeAccounts(db, scope = 'all') {
1404
1443
  }
1405
1444
  }
1406
1445
  /** Open (or return the cached) sessions database, applying migrations as needed. */
1407
- export function getDB() {
1446
+ export function getDB(initialBusyTimeoutMs = 30_000) {
1408
1447
  if (dbInstance)
1409
1448
  return dbInstance;
1410
1449
  fs.mkdirSync(SESSIONS_DIR, { recursive: true });
@@ -1416,134 +1455,165 @@ export function getDB() {
1416
1455
  // a new version home can take longer than 10s; concurrent callers need enough
1417
1456
  // headroom to wait. The ledger-recheck in upsertSessionsBatch makes
1418
1457
  // subsequent writers near-instant, so 30s is a rarely-reached safety net.
1419
- db.pragma('busy_timeout = 30000');
1420
- db.pragma('journal_mode = WAL');
1421
- db.pragma('synchronous = NORMAL');
1422
- db.pragma('temp_store = MEMORY');
1423
- db.exec(SCHEMA);
1424
- const readSchemaVersion = () => {
1425
- const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
1426
- return row ? parseInt(row.value, 10) : undefined;
1427
- };
1428
- const currentVersion = readSchemaVersion();
1429
- if (currentVersion === undefined) {
1430
- db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
1431
- }
1432
- else if (currentVersion < SCHEMA_VERSION) {
1433
- // Re-read after BEGIN IMMEDIATE acquires the writer lock. A second process
1434
- // may have completed the migration while this connection was waiting.
1435
- const migrate = db.transaction(() => {
1436
- const lockedVersion = readSchemaVersion();
1437
- if (lockedVersion === undefined || lockedVersion >= SCHEMA_VERSION)
1438
- return;
1439
- migrateSchema(db, lockedVersion);
1440
- db.prepare(`INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
1441
- });
1442
- migrate();
1443
- }
1444
- // Index last_activity only after the column is guaranteed to exist — fresh DBs
1445
- // get it from CREATE TABLE above, existing pre-v8 DBs from the migration just
1446
- // run. It must NOT live in SCHEMA (executed before migration) or an existing
1447
- // DB would fail the index build on a column it doesn't have yet.
1448
- db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)`);
1449
- db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_origin ON sessions(origin)`);
1450
- // Same fresh-vs-migrated rule: the column is guaranteed above (fresh from
1451
- // CREATE TABLE, existing from migration v46), so index the mirror pruner's
1452
- // scan column here rather than in SCHEMA (PHNX-3792).
1453
- db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_mirror_synced ON sessions(mirror_synced_at)`);
1454
- db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_routine_run_id ON sessions(routine_run_id)`);
1455
- const sessionColumns = db.prepare('PRAGMA table_info(sessions)').all();
1456
- if (['account_id', 'phoenix_id'].some(name => !sessionColumns.some(column => column.name === name))) {
1457
- // Partial upgrades can stamp the current version before every column exists.
1458
- // Recheck under the writer lock so concurrent openers cannot add it twice.
1459
- db.transaction(() => {
1460
- const columns = db.prepare('PRAGMA table_info(sessions)').all();
1461
- if (!columns.some(column => column.name === 'account_id'))
1462
- db.exec('ALTER TABLE sessions ADD COLUMN account_id TEXT');
1463
- if (!columns.some(column => column.name === 'phoenix_id'))
1464
- db.exec('ALTER TABLE sessions ADD COLUMN phoenix_id TEXT');
1465
- })();
1466
- }
1467
- // Account attribution repair. Two ways a Claude row ends up wrong even at v33:
1468
- // an older CLI (whose INSERT does not name the column) writes NULL, and a DB
1469
- // migrated by a build that predates the "clear the stale email on a dark row" fix
1470
- // keeps a known-wrong address. The v33 migration cannot fix either — it never runs
1471
- // again. Cheap guard first so the common case is one indexed lookup, then repair.
1472
- // Same shape as the `machine` repair below, for the same reason.
1473
- {
1474
- // Column guard FIRST, like the `machine` repair below. schema_version can be
1475
- // stamped at SCHEMA_VERSION without the column existing — getDB writes the marker
1476
- // for any DB whose meta has no row (a hand-built or partially-created index), and
1477
- // migrateSchema never runs in that path. Querying account_key unguarded would
1478
- // then throw "no such column" and take down every command that opens the index.
1479
- const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
1480
- if (cols.some((c) => c.name === 'account_key') && cols.some((c) => c.name === 'account_org')) {
1481
- const needsRepair = db.prepare(`
1482
- SELECT 1 FROM sessions
1483
- WHERE agent = 'claude'
1484
- AND (account_key IS NULL
1485
- OR (account_key LIKE 'unattributed:%' AND account IS NOT NULL))
1486
- LIMIT 1
1487
- `).get();
1488
- if (needsRepair)
1489
- db.transaction(() => backfillClaudeAccounts(db, 'unresolved'))();
1458
+ try {
1459
+ db.pragma(`busy_timeout = ${Math.max(0, Math.trunc(initialBusyTimeoutMs))}`);
1460
+ db.pragma('journal_mode = WAL');
1461
+ db.pragma('synchronous = NORMAL');
1462
+ db.pragma('temp_store = MEMORY');
1463
+ db.exec(SCHEMA);
1464
+ // `session_remote_preview_cache` is a lazy cache table (like the others
1465
+ // below), independent of SCHEMA_VERSION — but it shipped once already
1466
+ // without `last_caller_revision` before this column was added, so a DB
1467
+ // that already ran that earlier version has the table WITHOUT the column,
1468
+ // and `CREATE TABLE IF NOT EXISTS` above is a no-op against it. Guard with
1469
+ // the same PRAGMA-table_info pattern the versioned `sessions` migrations
1470
+ // use, so an existing cache DB gains the column instead of every read/write
1471
+ // throwing "no such column".
1472
+ const remotePreviewCacheCols = db.prepare(`PRAGMA table_info(session_remote_preview_cache)`).all();
1473
+ if (!remotePreviewCacheCols.some(c => c.name === 'last_caller_revision')) {
1474
+ db.exec(`ALTER TABLE session_remote_preview_cache ADD COLUMN last_caller_revision TEXT`);
1490
1475
  }
1491
- }
1492
- // machine column + indexes: only after the column is guaranteed present.
1493
- // Fresh SCHEMA (v17) includes the column; older DBs get it from migrate v17.
1494
- // If a partial upgrade left schema_version ahead of the column, repair here.
1495
- {
1496
- const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
1497
- if (!cols.some((c) => c.name === 'machine')) {
1498
- db.exec(`ALTER TABLE sessions ADD COLUMN machine TEXT`);
1499
- const rows = db
1500
- .prepare(`SELECT id, agent, file_path FROM sessions WHERE machine IS NULL OR machine = ''`)
1501
- .all();
1502
- const upd = db.prepare(`UPDATE sessions SET machine = ? WHERE id = ?`);
1503
- const txn = db.transaction((items) => {
1504
- for (const r of items) {
1505
- upd.run(machineForSessionFile(r.file_path, r.agent), r.id);
1506
- }
1476
+ const readSchemaVersion = () => {
1477
+ const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
1478
+ return row ? parseInt(row.value, 10) : undefined;
1479
+ };
1480
+ const currentVersion = readSchemaVersion();
1481
+ if (currentVersion === undefined) {
1482
+ db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
1483
+ }
1484
+ else if (currentVersion < SCHEMA_VERSION) {
1485
+ // Re-read after BEGIN IMMEDIATE acquires the writer lock. A second process
1486
+ // may have completed the migration while this connection was waiting.
1487
+ const migrate = db.transaction(() => {
1488
+ const lockedVersion = readSchemaVersion();
1489
+ if (lockedVersion === undefined || lockedVersion >= SCHEMA_VERSION)
1490
+ return;
1491
+ migrateSchema(db, lockedVersion);
1492
+ db.prepare(`INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
1507
1493
  });
1508
- txn(rows);
1494
+ migrate();
1509
1495
  }
1510
- db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_machine_ts ON sessions(machine, timestamp DESC)`);
1511
- db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_agent_ts ON sessions(agent, timestamp DESC)`);
1512
- }
1513
- // harness column: only after the column is guaranteed present.
1514
- // Fresh SCHEMA (v41) includes the column; older DBs get it from migrate v41.
1515
- // schema_version can be stamped at SCHEMA_VERSION without the column existing
1516
- // — getDB writes the marker for any DB whose meta has no row (a hand-built
1517
- // or partially-created index), and migrateSchema never runs in that path
1518
- // (`currentVersion === undefined`). If a partial upgrade left schema_version
1519
- // ahead of the column, repair here so the next upsertSession INSERT naming
1520
- // `harness` does not throw (PHNX-2935). Same shape as the `machine` repair
1521
- // above, for the same reason.
1522
- {
1523
- const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
1524
- if (!cols.some((c) => c.name === 'harness')) {
1525
- db.exec(`ALTER TABLE sessions ADD COLUMN harness TEXT`);
1496
+ // Index last_activity only after the column is guaranteed to exist — fresh DBs
1497
+ // get it from CREATE TABLE above, existing pre-v8 DBs from the migration just
1498
+ // run. It must NOT live in SCHEMA (executed before migration) or an existing
1499
+ // DB would fail the index build on a column it doesn't have yet.
1500
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)`);
1501
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_origin ON sessions(origin)`);
1502
+ // Same fresh-vs-migrated rule: the column is guaranteed above (fresh from
1503
+ // CREATE TABLE, existing from migration v46), so index the mirror pruner's
1504
+ // scan column here rather than in SCHEMA (PHNX-3792).
1505
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_mirror_synced ON sessions(mirror_synced_at)`);
1506
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_routine_run_id ON sessions(routine_run_id)`);
1507
+ const sessionColumns = db.prepare('PRAGMA table_info(sessions)').all();
1508
+ if (['account_id', 'phoenix_id'].some(name => !sessionColumns.some(column => column.name === name))) {
1509
+ // Partial upgrades can stamp the current version before every column exists.
1510
+ // Recheck under the writer lock so concurrent openers cannot add it twice.
1511
+ db.transaction(() => {
1512
+ const columns = db.prepare('PRAGMA table_info(sessions)').all();
1513
+ if (!columns.some(column => column.name === 'account_id'))
1514
+ db.exec('ALTER TABLE sessions ADD COLUMN account_id TEXT');
1515
+ if (!columns.some(column => column.name === 'phoenix_id'))
1516
+ db.exec('ALTER TABLE sessions ADD COLUMN phoenix_id TEXT');
1517
+ })();
1526
1518
  }
1527
- }
1528
- // One-shot cleanup of the pre-SQLite JSONL indexes. Safe — nothing reads
1529
- // them anymore. Guarded by a meta flag so we only try once.
1530
- const cleaned = db.prepare(`SELECT value FROM meta WHERE key = 'legacy_indexes_removed'`).get();
1531
- if (!cleaned) {
1532
- for (const p of [
1533
- path.join(SESSIONS_DIR, 'index.jsonl'),
1534
- path.join(SESSIONS_DIR, 'content_index.jsonl'),
1535
- path.join(SESSIONS_DIR, 'index.jsonl.bak'),
1536
- ]) {
1537
- try {
1538
- if (fs.existsSync(p))
1539
- fs.unlinkSync(p);
1519
+ // Account attribution repair. Two ways a Claude row ends up wrong even at v33:
1520
+ // an older CLI (whose INSERT does not name the column) writes NULL, and a DB
1521
+ // migrated by a build that predates the "clear the stale email on a dark row" fix
1522
+ // keeps a known-wrong address. The v33 migration cannot fix either — it never runs
1523
+ // again. Cheap guard first so the common case is one indexed lookup, then repair.
1524
+ // Same shape as the `machine` repair below, for the same reason.
1525
+ {
1526
+ // Column guard FIRST, like the `machine` repair below. schema_version can be
1527
+ // stamped at SCHEMA_VERSION without the column existing — getDB writes the marker
1528
+ // for any DB whose meta has no row (a hand-built or partially-created index), and
1529
+ // migrateSchema never runs in that path. Querying account_key unguarded would
1530
+ // then throw "no such column" and take down every command that opens the index.
1531
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
1532
+ if (cols.some((c) => c.name === 'account_key') && cols.some((c) => c.name === 'account_org')) {
1533
+ const needsRepair = db.prepare(`
1534
+ SELECT 1 FROM sessions
1535
+ WHERE agent = 'claude'
1536
+ AND (account_key IS NULL
1537
+ OR (account_key LIKE 'unattributed:%' AND account IS NOT NULL))
1538
+ LIMIT 1
1539
+ `).get();
1540
+ if (needsRepair)
1541
+ db.transaction(() => backfillClaudeAccounts(db, 'unresolved'))();
1540
1542
  }
1541
- catch { /* ignore */ }
1542
1543
  }
1543
- db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES ('legacy_indexes_removed', '1')`).run();
1544
+ // machine column + indexes: only after the column is guaranteed present.
1545
+ // Fresh SCHEMA (v17) includes the column; older DBs get it from migrate v17.
1546
+ // If a partial upgrade left schema_version ahead of the column, repair here.
1547
+ {
1548
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
1549
+ if (!cols.some((c) => c.name === 'machine')) {
1550
+ db.exec(`ALTER TABLE sessions ADD COLUMN machine TEXT`);
1551
+ const rows = db
1552
+ .prepare(`SELECT id, agent, file_path FROM sessions WHERE machine IS NULL OR machine = ''`)
1553
+ .all();
1554
+ const upd = db.prepare(`UPDATE sessions SET machine = ? WHERE id = ?`);
1555
+ const txn = db.transaction((items) => {
1556
+ for (const r of items) {
1557
+ upd.run(machineForSessionFile(r.file_path, r.agent), r.id);
1558
+ }
1559
+ });
1560
+ txn(rows);
1561
+ }
1562
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_machine_ts ON sessions(machine, timestamp DESC)`);
1563
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_agent_ts ON sessions(agent, timestamp DESC)`);
1564
+ }
1565
+ // harness column: only after the column is guaranteed present.
1566
+ // Fresh SCHEMA (v41) includes the column; older DBs get it from migrate v41.
1567
+ // schema_version can be stamped at SCHEMA_VERSION without the column existing
1568
+ // — getDB writes the marker for any DB whose meta has no row (a hand-built
1569
+ // or partially-created index), and migrateSchema never runs in that path
1570
+ // (`currentVersion === undefined`). If a partial upgrade left schema_version
1571
+ // ahead of the column, repair here so the next upsertSession INSERT naming
1572
+ // `harness` does not throw (PHNX-2935). Same shape as the `machine` repair
1573
+ // above, for the same reason.
1574
+ {
1575
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
1576
+ if (!cols.some((c) => c.name === 'harness')) {
1577
+ db.exec(`ALTER TABLE sessions ADD COLUMN harness TEXT`);
1578
+ }
1579
+ }
1580
+ // One-shot cleanup of the pre-SQLite JSONL indexes. Safe — nothing reads
1581
+ // them anymore. Guarded by a meta flag so we only try once.
1582
+ const cleaned = db.prepare(`SELECT value FROM meta WHERE key = 'legacy_indexes_removed'`).get();
1583
+ if (!cleaned) {
1584
+ for (const p of [
1585
+ path.join(SESSIONS_DIR, 'index.jsonl'),
1586
+ path.join(SESSIONS_DIR, 'content_index.jsonl'),
1587
+ path.join(SESSIONS_DIR, 'index.jsonl.bak'),
1588
+ ]) {
1589
+ try {
1590
+ if (fs.existsSync(p))
1591
+ fs.unlinkSync(p);
1592
+ }
1593
+ catch { /* ignore */ }
1594
+ }
1595
+ db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES ('legacy_indexes_removed', '1')`).run();
1596
+ }
1597
+ db.pragma('busy_timeout = 30000');
1598
+ dbInstance = db;
1599
+ return db;
1600
+ }
1601
+ catch (error) {
1602
+ db.close();
1603
+ throw error;
1604
+ }
1605
+ }
1606
+ /** Bound synchronous cache contention without changing other database callers. */
1607
+ export function withSessionDBTimeout(timeoutMs, operation) {
1608
+ const db = getDB(timeoutMs);
1609
+ const previous = db.prepare('PRAGMA busy_timeout').get().timeout;
1610
+ db.pragma(`busy_timeout = ${Math.max(0, Math.trunc(timeoutMs))}`);
1611
+ try {
1612
+ return operation();
1613
+ }
1614
+ finally {
1615
+ db.pragma(`busy_timeout = ${previous}`);
1544
1616
  }
1545
- dbInstance = db;
1546
- return db;
1547
1617
  }
1548
1618
  /** Close the cached database connection. */
1549
1619
  export function closeDB() {
@@ -3442,6 +3512,190 @@ export function writeSessionPreviewCache(entry) {
3442
3512
  preview_json = excluded.preview_json
3443
3513
  `).run(entry.id, entry.fileMtimeMs, entry.fileSize, PREVIEW_EXTRACTOR_VERSION, Date.now(), JSON.stringify(entry.preview));
3444
3514
  }
3515
+ /** Bump when the cached remote preview envelope shape changes so cached rows recompute (PHNX-3999 v1). */
3516
+ export const REMOTE_PREVIEW_SCHEMA_VERSION = 1;
3517
+ /** Cap on distinct (device, sessionId) rows this cache holds — a requester box
3518
+ * previews many peers' sessions over a long uptime, so this bounds growth the
3519
+ * same way the fleet session mirror bounds itself to 200 rows (db.ts, PHNX-3792):
3520
+ * oldest-by-last-fetch is evicted first. Row count alone is not a real bound —
3521
+ * every field the cached envelope carries is already bounded (digest fields,
3522
+ * `detail.messages` <= 8 x 4000 chars, `detail.timeline` <= 8 steps, etc.), but
3523
+ * 500 rows x an unbounded per-row size is still unbounded storage, so this is
3524
+ * paired with a total-byte budget below. */
3525
+ const REMOTE_PREVIEW_CACHE_MAX_ROWS = 500;
3526
+ /**
3527
+ * Total on-disk budget for `envelope_json` across every cached row, enforced
3528
+ * ALONGSIDE the row cap (whichever evicts more aggressively wins). At the
3529
+ * 500-row cap this is a ~32 KiB/row average, which comfortably fits the
3530
+ * bounded envelope shape above — a single row is refused entry above
3531
+ * {@link REMOTE_PREVIEW_ENVELOPE_MAX_BYTES} rather than being allowed to
3532
+ * consume the whole budget by itself.
3533
+ */
3534
+ const REMOTE_PREVIEW_CACHE_MAX_TOTAL_BYTES = 16 * 1024 * 1024;
3535
+ /**
3536
+ * Per-envelope cache-write cap. The bounded fields above make a well-formed
3537
+ * envelope small; this exists to refuse an outlier (a version-skewed peer, or
3538
+ * a future field that forgets to bound itself) rather than let ONE session
3539
+ * blow the total budget. A refused write is NOT an error — the caller still
3540
+ * gets the live envelope for this call, it simply is not persisted, so the
3541
+ * next call re-fetches instead of silently caching a giant blob.
3542
+ */
3543
+ export const REMOTE_PREVIEW_ENVELOPE_MAX_BYTES = 512 * 1024;
3544
+ /** Read the durable cached remote preview row for one (device, sessionId) pair,
3545
+ * regardless of whether it currently holds a successful envelope. Undefined
3546
+ * means this box has never attempted (or recorded) a fetch for that pair. */
3547
+ export function readRemotePreviewCache(device, sessionId) {
3548
+ const row = getDB().prepare(`
3549
+ SELECT fetched_at AS fetchedAt, ok, envelope_json AS envelopeJson, failure_reason AS failureReason,
3550
+ consecutive_failures AS consecutiveFailures, next_attempt_at AS nextAttemptAt,
3551
+ last_caller_revision AS lastCallerRevision
3552
+ FROM session_remote_preview_cache
3553
+ WHERE device = ? AND session_id = ? AND schema_version = ?
3554
+ `).get(device, sessionId, REMOTE_PREVIEW_SCHEMA_VERSION);
3555
+ if (!row)
3556
+ return undefined;
3557
+ let envelope;
3558
+ if (row.envelopeJson) {
3559
+ try {
3560
+ envelope = JSON.parse(row.envelopeJson);
3561
+ }
3562
+ catch {
3563
+ // Corrupt cache row: treat as no payload rather than throwing.
3564
+ }
3565
+ }
3566
+ return {
3567
+ fetchedAt: row.fetchedAt,
3568
+ ok: row.ok === 1 && envelope !== undefined,
3569
+ envelope,
3570
+ failureReason: row.failureReason ?? undefined,
3571
+ consecutiveFailures: row.consecutiveFailures,
3572
+ nextAttemptAt: row.nextAttemptAt,
3573
+ lastCallerRevision: row.lastCallerRevision ?? undefined,
3574
+ };
3575
+ }
3576
+ /**
3577
+ * Record the caller's own `--revision` cursor for one (device, sessionId)
3578
+ * pair, independent of any fetch. A no-op if no row exists yet (nothing to
3579
+ * compare against on a session this box has never fetched). This is what lets
3580
+ * "same revision as last time" serve the durable cache with zero SSH
3581
+ * indefinitely — the comparison is against THIS value, never against the
3582
+ * envelope's own `details.sourceRevision` (which may be a different format).
3583
+ */
3584
+ export function writeRemotePreviewCallerRevision(device, sessionId, revision) {
3585
+ getDB().prepare(`
3586
+ UPDATE session_remote_preview_cache
3587
+ SET last_caller_revision = ?
3588
+ WHERE device = ? AND session_id = ? AND schema_version = ?
3589
+ `).run(revision, device, sessionId, REMOTE_PREVIEW_SCHEMA_VERSION);
3590
+ }
3591
+ function pruneRemotePreviewCache(maxRows = REMOTE_PREVIEW_CACHE_MAX_ROWS) {
3592
+ const db = getDB();
3593
+ db.prepare(`
3594
+ DELETE FROM session_remote_preview_cache
3595
+ WHERE rowid NOT IN (
3596
+ SELECT rowid FROM session_remote_preview_cache ORDER BY fetched_at DESC LIMIT ?
3597
+ )
3598
+ `).run(maxRows);
3599
+ // Total-byte budget, independent of row count: walk newest-first, keep
3600
+ // rows until the running total would exceed the budget, drop the rest.
3601
+ const rows = db.prepare(`
3602
+ SELECT rowid AS rowid,
3603
+ envelope_bytes + length(CAST(COALESCE(failure_reason, '') AS BLOB))
3604
+ + length(CAST(COALESCE(last_caller_revision, '') AS BLOB)) AS envelopeBytes
3605
+ FROM session_remote_preview_cache
3606
+ ORDER BY fetched_at DESC
3607
+ `).all();
3608
+ let total = 0;
3609
+ const evict = [];
3610
+ for (const row of rows) {
3611
+ total += row.envelopeBytes;
3612
+ if (total > REMOTE_PREVIEW_CACHE_MAX_TOTAL_BYTES)
3613
+ evict.push(row.rowid);
3614
+ }
3615
+ if (evict.length > 0) {
3616
+ const placeholders = evict.map(() => '?').join(',');
3617
+ db.prepare(`DELETE FROM session_remote_preview_cache WHERE rowid IN (${placeholders})`).run(...evict);
3618
+ }
3619
+ }
3620
+ /**
3621
+ * Record a successful remote fetch: replaces the payload and resets backoff.
3622
+ * An envelope over {@link REMOTE_PREVIEW_ENVELOPE_MAX_BYTES} is refused —
3623
+ * this is a write-path bound, not a request failure: the caller already has
3624
+ * the live envelope for this call from the fetch that produced it, this only
3625
+ * decides whether it is worth persisting.
3626
+ */
3627
+ export function writeRemotePreviewCacheSuccess(device, sessionId, envelope, fetchedAt = Date.now(), revision) {
3628
+ const envelopeJson = JSON.stringify(envelope);
3629
+ const envelopeBytes = Buffer.byteLength(envelopeJson, 'utf8');
3630
+ if (envelopeBytes > REMOTE_PREVIEW_ENVELOPE_MAX_BYTES)
3631
+ return;
3632
+ const db = getDB();
3633
+ const write = db.transaction(() => {
3634
+ db.prepare(`
3635
+ INSERT INTO session_remote_preview_cache
3636
+ (device, session_id, schema_version, fetched_at, ok, envelope_json, envelope_bytes, failure_reason, consecutive_failures, next_attempt_at, last_caller_revision)
3637
+ VALUES (?, ?, ?, ?, 1, ?, ?, NULL, 0, 0, ?)
3638
+ ON CONFLICT(device, session_id, schema_version) DO UPDATE SET
3639
+ fetched_at = excluded.fetched_at,
3640
+ ok = 1,
3641
+ envelope_json = excluded.envelope_json,
3642
+ envelope_bytes = excluded.envelope_bytes,
3643
+ failure_reason = NULL,
3644
+ consecutive_failures = 0,
3645
+ next_attempt_at = 0,
3646
+ last_caller_revision = excluded.last_caller_revision
3647
+ `).run(device, sessionId, REMOTE_PREVIEW_SCHEMA_VERSION, fetchedAt, envelopeJson, envelopeBytes, revision ?? null);
3648
+ pruneRemotePreviewCache();
3649
+ });
3650
+ write();
3651
+ }
3652
+ /** Cap on a stored `failure_reason` string. Some reasons interpolate
3653
+ * peer-controlled content (`validateEnvelope`'s mismatched-id message
3654
+ * embeds the peer's own claimed session id) — bound it so a misbehaving or
3655
+ * malicious peer can't inflate a cache row's stored text unboundedly. */
3656
+ const REMOTE_PREVIEW_FAILURE_REASON_MAX_CHARS = 300;
3657
+ function boundFailureReason(reason) {
3658
+ return reason.length > REMOTE_PREVIEW_FAILURE_REASON_MAX_CHARS
3659
+ ? reason.slice(0, REMOTE_PREVIEW_FAILURE_REASON_MAX_CHARS) + '…'
3660
+ : reason;
3661
+ }
3662
+ /**
3663
+ * Record a failed remote fetch attempt with exponential backoff, WITHOUT
3664
+ * discarding a prior good envelope — a session that answered once and is now
3665
+ * offline still degrades to that last-good payload (read back via
3666
+ * {@link readRemotePreviewCache}'s `ok`/`envelope`), annotated stale by the
3667
+ * caller, rather than losing it the moment one attempt fails.
3668
+ */
3669
+ export function writeRemotePreviewCacheFailure(device, sessionId, reason, backoffMs, now = Date.now()) {
3670
+ const boundedReason = boundFailureReason(reason);
3671
+ const db = getDB();
3672
+ const write = db.transaction(() => {
3673
+ const existing = readRemotePreviewCache(device, sessionId);
3674
+ const consecutiveFailures = (existing?.consecutiveFailures ?? 0) + 1;
3675
+ const nextAttemptAt = now + backoffMs(consecutiveFailures);
3676
+ const keepOk = existing?.ok ? 1 : 0;
3677
+ const envelopeJson = existing?.ok ? JSON.stringify(existing.envelope) : null;
3678
+ const envelopeBytes = envelopeJson ? Buffer.byteLength(envelopeJson, 'utf8') : 0;
3679
+ db.prepare(`
3680
+ INSERT INTO session_remote_preview_cache
3681
+ (device, session_id, schema_version, fetched_at, ok, envelope_json, envelope_bytes, failure_reason, consecutive_failures, next_attempt_at)
3682
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3683
+ ON CONFLICT(device, session_id, schema_version) DO UPDATE SET
3684
+ ok = excluded.ok,
3685
+ envelope_json = excluded.envelope_json,
3686
+ envelope_bytes = excluded.envelope_bytes,
3687
+ failure_reason = excluded.failure_reason,
3688
+ consecutive_failures = excluded.consecutive_failures,
3689
+ next_attempt_at = excluded.next_attempt_at
3690
+ `).run(device, sessionId, REMOTE_PREVIEW_SCHEMA_VERSION, existing?.fetchedAt ?? now, keepOk, envelopeJson, envelopeBytes, boundedReason, consecutiveFailures, nextAttemptAt);
3691
+ // A failure-only row still occupies a slot in the row/byte-bounded cache —
3692
+ // it must be pruned like any other write, or a persistently-offline peer's
3693
+ // failure rows accumulate without bound (they were previously exempt from
3694
+ // this call entirely).
3695
+ pruneRemotePreviewCache();
3696
+ });
3697
+ write();
3698
+ }
3445
3699
  /**
3446
3700
  * The session's durable user-turn text, as stored in the `session_text` FTS
3447
3701
  * `content` column at scan time (all harnesses, keyed by session_id). This is
@@ -3579,12 +3833,13 @@ export function readSessionTimelineEntry(id) {
3579
3833
  * read path. Deliberately does NOT parse the resume state: a live row needs the
3580
3834
  * 8 steps and the request, never the fold's bookkeeping.
3581
3835
  */
3582
- export function readSessionTimelineAny(id) {
3836
+ export function readSessionTimelineAny(id, stamp) {
3583
3837
  const row = getDB().prepare(`
3584
3838
  SELECT projection_json AS projectionJson
3585
3839
  FROM session_timelines
3586
3840
  WHERE session_id = ? AND extractor_version = ?
3587
- `).get(id, TIMELINE_EXTRACTOR_VERSION);
3841
+ AND (? IS NULL OR (file_mtime_ms = ? AND file_size = ?))
3842
+ `).get(id, TIMELINE_EXTRACTOR_VERSION, stamp?.fileMtimeMs ?? null, stamp?.fileMtimeMs ?? null, stamp?.fileSize ?? null);
3588
3843
  if (!row)
3589
3844
  return undefined;
3590
3845
  return parseTimelineProjection(row.projectionJson);
@@ -96,15 +96,31 @@ export declare function classifyUserPrompt(raw: string | undefined, opts?: {
96
96
  hasImageAttachment?: boolean;
97
97
  }): ClassifiedPrompt;
98
98
  /**
99
- * Collapse a harness-generated session title when it is injected skill
100
- * scaffolding; otherwise leave the title as the harness wrote it.
99
+ * Reduce a harness-generated session title to something worth showing as a
100
+ * headline, or `undefined` when it is not (PHNX-3999 F26/F27).
101
101
  *
102
102
  * Claude's `ai-title` and Cursor's `chatMeta.title` are both derived from the
103
- * first turn, so a session opened with a skill gets named after the injected
104
- * "Base directory for this skill: …" line. {@link classifyUserPrompt} reports
105
- * `kind: 'skill'` only for that line, so collapse it to `/<skill>`. Empty or
106
- * whitespace-only input yields `undefined` so the caller falls through to the
107
- * first-prompt topic.
103
+ * FIRST turn of the transcript, so whatever the harness injected there becomes
104
+ * the session's name: a skill's install-directory preamble, a `!`-prefixed shell
105
+ * echo wrapped in `<bash-input>`, a `/clear`. The owner's recording shows both
106
+ * failures — one row titled with shell-command XML wrappers, others reading
107
+ * `/clear`. Three rungs, in order:
108
+ *
109
+ * 1. A skill invocation collapses to `/<skill>` ({@link classifyUserPrompt}
110
+ * reports `kind: 'skill'` only for that injected line).
111
+ * 2. Harness scaffolding is REJECTED, not cleaned: `<bash-input>`,
112
+ * `<command-name>`, `<local-command-stdout>`, `<system-reminder>` and the
113
+ * rest of {@link isSyntheticUserMessage}'s cross-harness list, plus a title
114
+ * that is only tags. Stripping the tags would leave the shell command
115
+ * itself as the headline, which is the same wrong answer with tidier
116
+ * punctuation.
117
+ * 3. A bare control command is rejected ({@link BARE_CONTROL_COMMAND_RE}).
118
+ *
119
+ * A rejected label returns `undefined`, so the row falls to the next rung of the
120
+ * canonical ladder — the daemon-generated title, then the user's own first
121
+ * prompt (`sessionHeadline`, `session/title.ts`). Nothing is deleted: the
122
+ * original turn stays on the row as `firstUserMessage` / `request`, so the
123
+ * details view still shows exactly what the agent was told.
108
124
  *
109
125
  * A user-authored title (Claude `/rename` / `custom-title`) is never passed
110
126
  * here — the caller keeps it verbatim.