@phnx-labs/agents-cli 1.20.74 → 1.20.76

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 (199) hide show
  1. package/CHANGELOG.md +893 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +18 -0
  13. package/dist/commands/exec.js +31 -3
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +15 -2
  26. package/dist/commands/secrets.js +44 -21
  27. package/dist/commands/sessions-browser.js +1 -1
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.js +16 -1
  32. package/dist/commands/sessions.d.ts +36 -13
  33. package/dist/commands/sessions.js +210 -40
  34. package/dist/commands/setup-computer.js +2 -2
  35. package/dist/commands/ssh.js +71 -35
  36. package/dist/commands/status.js +10 -2
  37. package/dist/commands/versions.js +15 -6
  38. package/dist/commands/view.d.ts +5 -0
  39. package/dist/commands/view.js +24 -4
  40. package/dist/commands/watchdog.d.ts +7 -2
  41. package/dist/commands/watchdog.js +73 -57
  42. package/dist/index.js +58 -7
  43. package/dist/lib/activity.d.ts +14 -4
  44. package/dist/lib/activity.js +17 -5
  45. package/dist/lib/actor.d.ts +50 -0
  46. package/dist/lib/actor.js +166 -0
  47. package/dist/lib/agent-spec/provider.js +2 -1
  48. package/dist/lib/agent-spec/resolve.js +19 -5
  49. package/dist/lib/agent-spec/types.d.ts +9 -1
  50. package/dist/lib/agents.d.ts +21 -0
  51. package/dist/lib/agents.js +70 -6
  52. package/dist/lib/cloud/codex.d.ts +2 -0
  53. package/dist/lib/cloud/codex.js +14 -3
  54. package/dist/lib/cloud/session-index.d.ts +32 -0
  55. package/dist/lib/cloud/session-index.js +58 -0
  56. package/dist/lib/cloud/store.d.ts +7 -0
  57. package/dist/lib/cloud/store.js +25 -0
  58. package/dist/lib/config-transfer.js +4 -0
  59. package/dist/lib/daemon.js +18 -2
  60. package/dist/lib/devices/connect.d.ts +15 -1
  61. package/dist/lib/devices/connect.js +15 -1
  62. package/dist/lib/devices/fleet.d.ts +21 -0
  63. package/dist/lib/devices/fleet.js +26 -0
  64. package/dist/lib/devices/health-report.d.ts +38 -0
  65. package/dist/lib/devices/health-report.js +214 -0
  66. package/dist/lib/devices/health.js +4 -1
  67. package/dist/lib/devices/reachability.d.ts +31 -0
  68. package/dist/lib/devices/reachability.js +40 -0
  69. package/dist/lib/devices/registry.d.ts +33 -0
  70. package/dist/lib/devices/registry.js +37 -0
  71. package/dist/lib/devices/resolve-target.d.ts +15 -27
  72. package/dist/lib/devices/resolve-target.js +63 -102
  73. package/dist/lib/devices/sync.d.ts +18 -0
  74. package/dist/lib/devices/sync.js +23 -1
  75. package/dist/lib/devices/tailscale.d.ts +3 -0
  76. package/dist/lib/devices/tailscale.js +1 -0
  77. package/dist/lib/events.d.ts +1 -1
  78. package/dist/lib/exec.d.ts +66 -0
  79. package/dist/lib/exec.js +195 -6
  80. package/dist/lib/feed-post.d.ts +63 -0
  81. package/dist/lib/feed-post.js +204 -0
  82. package/dist/lib/fleet/apply.d.ts +32 -2
  83. package/dist/lib/fleet/apply.js +97 -10
  84. package/dist/lib/fleet/types.d.ts +11 -0
  85. package/dist/lib/fs-walk.d.ts +13 -0
  86. package/dist/lib/fs-walk.js +16 -7
  87. package/dist/lib/hooks.d.ts +1 -0
  88. package/dist/lib/hooks.js +156 -0
  89. package/dist/lib/hosts/dispatch.d.ts +7 -0
  90. package/dist/lib/hosts/dispatch.js +2 -0
  91. package/dist/lib/hosts/passthrough.js +2 -0
  92. package/dist/lib/hosts/reconnect.d.ts +78 -0
  93. package/dist/lib/hosts/reconnect.js +127 -0
  94. package/dist/lib/hosts/registry.d.ts +75 -14
  95. package/dist/lib/hosts/registry.js +205 -30
  96. package/dist/lib/hosts/remote-cmd.js +1 -0
  97. package/dist/lib/hosts/run-target.d.ts +13 -5
  98. package/dist/lib/hosts/run-target.js +26 -10
  99. package/dist/lib/hosts/session-index.d.ts +18 -1
  100. package/dist/lib/hosts/session-index.js +37 -2
  101. package/dist/lib/hosts/session-marker.d.ts +33 -0
  102. package/dist/lib/hosts/session-marker.js +51 -0
  103. package/dist/lib/hosts/tasks.d.ts +8 -4
  104. package/dist/lib/import.d.ts +2 -0
  105. package/dist/lib/import.js +35 -2
  106. package/dist/lib/menubar/install-menubar.d.ts +20 -0
  107. package/dist/lib/menubar/install-menubar.js +45 -1
  108. package/dist/lib/migrate.d.ts +16 -0
  109. package/dist/lib/migrate.js +36 -0
  110. package/dist/lib/models.d.ts +27 -0
  111. package/dist/lib/models.js +54 -1
  112. package/dist/lib/overdue.d.ts +7 -2
  113. package/dist/lib/overdue.js +18 -11
  114. package/dist/lib/picker.js +4 -1
  115. package/dist/lib/platform/process.d.ts +17 -0
  116. package/dist/lib/platform/process.js +70 -0
  117. package/dist/lib/profiles-presets.js +9 -7
  118. package/dist/lib/profiles.d.ts +31 -0
  119. package/dist/lib/profiles.js +70 -0
  120. package/dist/lib/pty-server.d.ts +2 -10
  121. package/dist/lib/pty-server.js +4 -38
  122. package/dist/lib/registry.d.ts +1 -1
  123. package/dist/lib/registry.js +48 -8
  124. package/dist/lib/rotate.d.ts +18 -0
  125. package/dist/lib/rotate.js +28 -0
  126. package/dist/lib/routines.d.ts +26 -0
  127. package/dist/lib/routines.js +19 -2
  128. package/dist/lib/runner.js +104 -7
  129. package/dist/lib/secrets/agent.d.ts +66 -1
  130. package/dist/lib/secrets/agent.js +154 -90
  131. package/dist/lib/secrets/bundles.d.ts +34 -0
  132. package/dist/lib/secrets/bundles.js +179 -19
  133. package/dist/lib/secrets/index.d.ts +15 -1
  134. package/dist/lib/secrets/index.js +21 -3
  135. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  136. package/dist/lib/secrets/rc-hygiene.js +154 -0
  137. package/dist/lib/secrets/remote.d.ts +7 -4
  138. package/dist/lib/secrets/remote.js +7 -4
  139. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  140. package/dist/lib/secrets/sync-commands.js +21 -0
  141. package/dist/lib/self-update.d.ts +23 -2
  142. package/dist/lib/self-update.js +86 -5
  143. package/dist/lib/session/active.d.ts +114 -7
  144. package/dist/lib/session/active.js +323 -52
  145. package/dist/lib/session/cloud.js +2 -1
  146. package/dist/lib/session/db.d.ts +48 -0
  147. package/dist/lib/session/db.js +238 -14
  148. package/dist/lib/session/detached.d.ts +30 -0
  149. package/dist/lib/session/detached.js +92 -0
  150. package/dist/lib/session/discover.d.ts +465 -2
  151. package/dist/lib/session/discover.js +1648 -468
  152. package/dist/lib/session/fork.js +2 -1
  153. package/dist/lib/session/hook-sessions.d.ts +43 -0
  154. package/dist/lib/session/hook-sessions.js +135 -0
  155. package/dist/lib/session/linear.d.ts +6 -0
  156. package/dist/lib/session/linear.js +55 -0
  157. package/dist/lib/session/migrate-targets.d.ts +65 -0
  158. package/dist/lib/session/migrate-targets.js +94 -0
  159. package/dist/lib/session/migrations.d.ts +37 -0
  160. package/dist/lib/session/migrations.js +60 -0
  161. package/dist/lib/session/parse.d.ts +18 -0
  162. package/dist/lib/session/parse.js +130 -30
  163. package/dist/lib/session/pid-registry.d.ts +34 -2
  164. package/dist/lib/session/pid-registry.js +49 -2
  165. package/dist/lib/session/render.d.ts +7 -2
  166. package/dist/lib/session/render.js +22 -10
  167. package/dist/lib/session/short-id.d.ts +17 -0
  168. package/dist/lib/session/short-id.js +20 -0
  169. package/dist/lib/session/state.js +6 -2
  170. package/dist/lib/shims.d.ts +1 -1
  171. package/dist/lib/shims.js +20 -6
  172. package/dist/lib/sqlite.d.ts +3 -2
  173. package/dist/lib/sqlite.js +27 -4
  174. package/dist/lib/staleness/detectors/commands.js +1 -1
  175. package/dist/lib/staleness/detectors/workflows.js +13 -2
  176. package/dist/lib/staleness/writers/commands.js +7 -7
  177. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  178. package/dist/lib/startup/command-registry.d.ts +1 -0
  179. package/dist/lib/startup/command-registry.js +3 -0
  180. package/dist/lib/state.d.ts +1 -1
  181. package/dist/lib/state.js +1 -32
  182. package/dist/lib/teams/agents.d.ts +19 -10
  183. package/dist/lib/teams/agents.js +40 -39
  184. package/dist/lib/types.d.ts +37 -2
  185. package/dist/lib/usage.d.ts +33 -1
  186. package/dist/lib/usage.js +172 -12
  187. package/dist/lib/versions.d.ts +9 -2
  188. package/dist/lib/versions.js +37 -8
  189. package/dist/lib/watchdog/log.d.ts +43 -0
  190. package/dist/lib/watchdog/log.js +69 -0
  191. package/dist/lib/watchdog/routine.d.ts +44 -0
  192. package/dist/lib/watchdog/routine.js +69 -0
  193. package/dist/lib/watchdog/runner.d.ts +51 -7
  194. package/dist/lib/watchdog/runner.js +239 -64
  195. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  196. package/dist/lib/watchdog/watchdog.js +31 -16
  197. package/dist/lib/workflows.d.ts +16 -0
  198. package/dist/lib/workflows.js +110 -1
  199. package/package.json +1 -1
@@ -13,7 +13,7 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
13
13
  const SESSIONS_DIR = getSessionsDir();
14
14
  const DB_PATH = getSessionsDbPath();
15
15
  /** Current schema version; bumped when migrations are added. */
16
- const SCHEMA_VERSION = 13;
16
+ const SCHEMA_VERSION = 16;
17
17
  /**
18
18
  * Canonicalize a file path for use as a scan_ledger key. The same physical
19
19
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
@@ -99,6 +99,27 @@ CREATE TABLE IF NOT EXISTS scan_ledger (
99
99
  file_path TEXT PRIMARY KEY,
100
100
  file_mtime_ms INTEGER NOT NULL,
101
101
  file_size INTEGER NOT NULL,
102
+ scanned_at INTEGER NOT NULL,
103
+ -- Resumable-parse cursor + continuation (B-1). parser_state is a JSON
104
+ -- ClaudeParserState blob (offset + accumulator snapshot) so a scan can pick
105
+ -- up where the last one stopped; content_text caches the accumulated user
106
+ -- doc so detectTicket + FTS can rebuild on append without re-reading the file.
107
+ -- Written by B-2; B-1 only defines + round-trips them.
108
+ parser_state TEXT,
109
+ content_text TEXT
110
+ );
111
+
112
+ -- Tracks the mtime + entry-count of every LEAF directory that directly holds
113
+ -- transcripts (a Claude project dir, a Gemini chats dir). A dir's mtime bumps
114
+ -- on create/delete/rename of its entries but NOT on an in-place append, so a
115
+ -- match here means the dir gained/lost/renamed no files: we can skip the
116
+ -- readdir + per-file stat and serve unchanged files from the DB (append-safety
117
+ -- is preserved by re-stat'ing only the "hot set" — see discover.ts). Keyed by
118
+ -- canonicalLedgerKey, same as scan_ledger.
119
+ CREATE TABLE IF NOT EXISTS dir_ledger (
120
+ dir_path TEXT PRIMARY KEY,
121
+ dir_mtime_ms INTEGER NOT NULL,
122
+ entry_count INTEGER NOT NULL,
102
123
  scanned_at INTEGER NOT NULL
103
124
  );
104
125
  `;
@@ -252,6 +273,49 @@ function migrateSchema(db, fromVersion) {
252
273
  db.exec(`UPDATE sessions SET origin = 'cli' WHERE origin IS NULL OR origin = ''`);
253
274
  db.exec(`DELETE FROM scan_ledger;`);
254
275
  }
276
+ if (fromVersion < 14) {
277
+ // v13 → v14: the discovery scan now short-circuits the readdir + per-file
278
+ // stat of leaf transcript dirs whose (mtime, entry_count) is unchanged,
279
+ // caching that snapshot in the new `dir_ledger` table. Create it, and clear
280
+ // scan_ledger so the first post-upgrade scan does a clean full walk — that
281
+ // walk seeds BOTH ledgers correctly, so a cold/empty dir_ledger degrades to
282
+ // today's full behavior.
283
+ db.exec(`
284
+ CREATE TABLE IF NOT EXISTS dir_ledger (
285
+ dir_path TEXT PRIMARY KEY,
286
+ dir_mtime_ms INTEGER NOT NULL,
287
+ entry_count INTEGER NOT NULL,
288
+ scanned_at INTEGER NOT NULL
289
+ );
290
+ DELETE FROM scan_ledger;
291
+ `);
292
+ }
293
+ if (fromVersion < 15) {
294
+ // v14 → v15: the Claude scan becomes resumable (B-1). scan_ledger gains a
295
+ // `parser_state` continuation blob (offset + accumulator snapshot) and a
296
+ // `content_text` cache of the accumulated user doc. Add both columns, then
297
+ // clear scan_ledger so the first post-upgrade scan does a clean full walk
298
+ // that reseeds the cursor from byte 0.
299
+ const cols = db.prepare(`PRAGMA table_info(scan_ledger)`).all();
300
+ if (!cols.some(c => c.name === 'parser_state'))
301
+ db.exec(`ALTER TABLE scan_ledger ADD COLUMN parser_state TEXT`);
302
+ if (!cols.some(c => c.name === 'content_text'))
303
+ db.exec(`ALTER TABLE scan_ledger ADD COLUMN content_text TEXT`);
304
+ db.exec(`DELETE FROM scan_ledger;`);
305
+ }
306
+ if (fromVersion < 16) {
307
+ // v15 → v16: repair rows poisoned by the empty-shortId bug (now fixed at the
308
+ // source in deriveShortId). A session id that was only a known prefix — a bare
309
+ // `session_` dir, an id of exactly `api-` or `ses_` — derived to '' via
310
+ // `id.replace(prefix, '').slice(0, 8)`, passed the `short_id TEXT NOT NULL`
311
+ // column (empty string is not NULL), yet matched nothing in the
312
+ // `short_id LIKE ?` picker lookups. The parser no longer produces '', but
313
+ // existing rows don't self-heal: an empty short_id is not re-parsed unless its
314
+ // file changes, and an orphaned row (file gone) never re-parses at all. Repair
315
+ // in place — `substr(id, 1, 8)` is non-empty because `id` is the non-empty
316
+ // primary key — so every corrupt row becomes addressable. No rescan needed.
317
+ db.exec(`UPDATE sessions SET short_id = substr(id, 1, 8) WHERE short_id IS NULL OR short_id = ''`);
318
+ }
255
319
  }
256
320
  /** Open (or return the cached) sessions database, applying migrations as needed. */
257
321
  export function getDB() {
@@ -310,6 +374,11 @@ export function closeDB() {
310
374
  if (dbInstance) {
311
375
  dbInstance.close();
312
376
  dbInstance = null;
377
+ // Closing the connection finalizes every prepared statement it owns. Drop
378
+ // the cached upsert/FTS statements too, so the next getDB() rebuilds them
379
+ // against the fresh connection instead of re-running a finalized statement
380
+ // (which throws "statement has been finalized" on the first upsert).
381
+ cachedStmts = {};
313
382
  }
314
383
  }
315
384
  // ---------------------------------------------------------------------------
@@ -440,6 +509,55 @@ export function getScanStampsForPaths(filePaths) {
440
509
  }
441
510
  return result;
442
511
  }
512
+ /**
513
+ * Bulk-load the resumable-parse continuation (parser_state + content_text) plus
514
+ * the stamp for a set of file paths in a single chunked query. Mirrors
515
+ * {@link getScanStampsForPaths}: keys by canonical path and fans results back to
516
+ * every original alias so callers can `.get(filePath)` with the path they passed.
517
+ * The Claude incremental scan uses this to fetch each changed file's prior
518
+ * continuation without an N+1 of {@link getScanStampByPath}.
519
+ */
520
+ export function getParserStatesForPaths(filePaths) {
521
+ const result = new Map();
522
+ if (filePaths.length === 0)
523
+ return result;
524
+ const db = getDB();
525
+ const canonicalToOriginals = new Map();
526
+ for (const fp of filePaths) {
527
+ const canonical = canonicalLedgerKey(fp);
528
+ const aliases = canonicalToOriginals.get(canonical);
529
+ if (aliases)
530
+ aliases.push(fp);
531
+ else
532
+ canonicalToOriginals.set(canonical, [fp]);
533
+ }
534
+ const canonicalKeys = [...canonicalToOriginals.keys()];
535
+ const CHUNK = 500;
536
+ for (let i = 0; i < canonicalKeys.length; i += CHUNK) {
537
+ const chunk = canonicalKeys.slice(i, i + CHUNK);
538
+ const placeholders = chunk.map(() => '?').join(',');
539
+ const rows = db
540
+ .prepare(`
541
+ SELECT file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text
542
+ FROM scan_ledger
543
+ WHERE file_path IN (${placeholders})
544
+ `)
545
+ .all(...chunk);
546
+ for (const row of rows) {
547
+ const state = {
548
+ parserState: row.parser_state,
549
+ contentText: row.content_text,
550
+ fileMtimeMs: row.file_mtime_ms,
551
+ fileSize: row.file_size,
552
+ scannedAt: row.scanned_at,
553
+ };
554
+ for (const original of canonicalToOriginals.get(row.file_path) || []) {
555
+ result.set(original, state);
556
+ }
557
+ }
558
+ }
559
+ return result;
560
+ }
443
561
  /**
444
562
  * Record scan stamps for files we've looked at. Covers both files that produced
445
563
  * a session and files we looked at but chose not to index (e.g. malformed).
@@ -464,6 +582,73 @@ export function recordScans(entries) {
464
582
  });
465
583
  txn(entries);
466
584
  }
585
+ /**
586
+ * Bulk-load the dir ledger for a set of leaf directories in a single SQL query.
587
+ * Mirrors {@link getScanStampsForPaths}: keys by canonical path (so a dir
588
+ * reachable via a symlinked version home and its realpath collapse to one row)
589
+ * and fans the result back out to every original alias so callers can
590
+ * `.get(dirPath)` with the path they passed in.
591
+ */
592
+ export function getDirLedgerForPaths(dirs) {
593
+ const result = new Map();
594
+ if (dirs.length === 0)
595
+ return result;
596
+ const db = getDB();
597
+ const canonicalToOriginals = new Map();
598
+ for (const d of dirs) {
599
+ const canonical = canonicalLedgerKey(d);
600
+ const aliases = canonicalToOriginals.get(canonical);
601
+ if (aliases)
602
+ aliases.push(d);
603
+ else
604
+ canonicalToOriginals.set(canonical, [d]);
605
+ }
606
+ const canonicalKeys = [...canonicalToOriginals.keys()];
607
+ const CHUNK = 500;
608
+ for (let i = 0; i < canonicalKeys.length; i += CHUNK) {
609
+ const chunk = canonicalKeys.slice(i, i + CHUNK);
610
+ const placeholders = chunk.map(() => '?').join(',');
611
+ const rows = db
612
+ .prepare(`
613
+ SELECT dir_path, dir_mtime_ms, entry_count
614
+ FROM dir_ledger
615
+ WHERE dir_path IN (${placeholders})
616
+ `)
617
+ .all(...chunk);
618
+ for (const row of rows) {
619
+ const stamp = { dirMtimeMs: row.dir_mtime_ms, entryCount: row.entry_count };
620
+ for (const original of canonicalToOriginals.get(row.dir_path) || []) {
621
+ result.set(original, stamp);
622
+ }
623
+ }
624
+ }
625
+ return result;
626
+ }
627
+ /**
628
+ * Upsert dir-scan stamps. Recorded after a full readdir of a leaf transcript
629
+ * dir so the next scan can skip that dir when its (mtime, entry_count) is
630
+ * unchanged. Mirrors {@link recordScans}.
631
+ */
632
+ export function recordDirScans(entries) {
633
+ if (entries.length === 0)
634
+ return;
635
+ const db = getDB();
636
+ const stmt = db.prepare(`
637
+ INSERT INTO dir_ledger (dir_path, dir_mtime_ms, entry_count, scanned_at)
638
+ VALUES (?, ?, ?, ?)
639
+ ON CONFLICT(dir_path) DO UPDATE SET
640
+ dir_mtime_ms = excluded.dir_mtime_ms,
641
+ entry_count = excluded.entry_count,
642
+ scanned_at = excluded.scanned_at
643
+ `);
644
+ const now = Date.now();
645
+ const txn = db.transaction((items) => {
646
+ for (const { dirPath, dirMtimeMs, entryCount } of items) {
647
+ stmt.run(canonicalLedgerKey(dirPath), dirMtimeMs, entryCount, now);
648
+ }
649
+ });
650
+ txn(entries);
651
+ }
467
652
  const upsertSessionStmt = (db) => db.prepare(`
468
653
  INSERT INTO sessions (
469
654
  id, short_id, agent, origin, routine_name, routine_run_id,
@@ -494,7 +679,15 @@ const upsertSessionStmt = (db) => db.prepare(`
494
679
  cwd = excluded.cwd,
495
680
  git_branch = excluded.git_branch,
496
681
  topic = excluded.topic,
497
- label = excluded.label,
682
+ -- Never let an empty/placeholder incoming label clobber a good stored one.
683
+ -- A real incoming label (non-empty after trim) still wins; a blank one keeps
684
+ -- the label seeded by --name (seedLabelsFromNames) or refined by an agent
685
+ -- title / rename (syncLabels). A bare rescan carries no label, so it must
686
+ -- preserve, not erase, the good one already stored.
687
+ label = CASE
688
+ WHEN excluded.label IS NULL OR trim(excluded.label) = '' THEN sessions.label
689
+ ELSE excluded.label
690
+ END,
498
691
  message_count = excluded.message_count,
499
692
  token_count = excluded.token_count,
500
693
  output_tokens = excluded.output_tokens,
@@ -513,6 +706,10 @@ const upsertSessionStmt = (db) => db.prepare(`
513
706
  `);
514
707
  const deleteTextStmt = (db) => db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
515
708
  const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (session_id, label, topic, project, content) VALUES (?, ?, ?, ?, ?)`);
709
+ // Read back the label the upsert actually stored (which may be the preserved
710
+ // one, not the incoming blank) so the FTS label column stays consistent with
711
+ // sessions.label after a bare rescan.
712
+ const readLabelStmt = (db) => db.prepare(`SELECT label FROM sessions WHERE id = ?`);
516
713
  let cachedStmts = {};
517
714
  function stmts(db) {
518
715
  if (!cachedStmts.upsert) {
@@ -520,17 +717,28 @@ function stmts(db) {
520
717
  upsert: upsertSessionStmt(db),
521
718
  delText: deleteTextStmt(db),
522
719
  insText: insertTextStmt(db),
720
+ readLabel: readLabelStmt(db),
523
721
  };
524
722
  }
525
723
  return cachedStmts;
526
724
  }
725
+ /**
726
+ * Return the label stored for a session, as text for the FTS index (never NULL).
727
+ * Called inside the upsert transaction, AFTER the row upsert, so it reflects the
728
+ * preserve-non-empty-label rule in the ON CONFLICT clause rather than the raw
729
+ * incoming label.
730
+ */
731
+ function storedFtsLabel(readLabel, id) {
732
+ const row = readLabel.get(id);
733
+ return row?.label ?? '';
734
+ }
527
735
  /**
528
736
  * Upsert a session row and replace its FTS5 content in a single transaction.
529
737
  * `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
530
738
  */
531
739
  export function upsertSession(meta, content, scan) {
532
740
  const db = getDB();
533
- const { upsert, delText, insText } = stmts(db);
741
+ const { upsert, delText, insText, readLabel } = stmts(db);
534
742
  const row = {
535
743
  id: meta.id,
536
744
  short_id: meta.shortId,
@@ -566,7 +774,10 @@ export function upsertSession(meta, content, scan) {
566
774
  const txn = db.transaction(() => {
567
775
  upsert.run(row);
568
776
  delText.run(meta.id);
569
- insText.run(meta.id, meta.label ?? '', meta.topic ?? '', meta.project ?? '', content ?? '');
777
+ insText.run(meta.id,
778
+ // Use the label the upsert actually stored (preserve-non-empty rule),
779
+ // not the raw incoming one, so FTS label ranking survives a bare rescan.
780
+ storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '');
570
781
  });
571
782
  txn();
572
783
  }
@@ -575,15 +786,22 @@ export function upsertSessionsBatch(entries) {
575
786
  if (entries.length === 0)
576
787
  return;
577
788
  const db = getDB();
578
- const { upsert, delText, insText } = stmts(db);
789
+ const { upsert, delText, insText, readLabel } = stmts(db);
579
790
  const now = Date.now();
791
+ // Persist the Claude resumable-parse continuation (parser_state + content_text)
792
+ // alongside the stamp. On a full/incremental Claude parse the caller passes the
793
+ // serialized newState + accumulated user doc so the NEXT scan can resume from
794
+ // the persisted offset (B-2). Other scanners pass neither, leaving both columns
795
+ // NULL exactly as before — their ledger rows are unaffected.
580
796
  const ledger = db.prepare(`
581
- INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at)
582
- VALUES (?, ?, ?, ?)
797
+ INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text)
798
+ VALUES (?, ?, ?, ?, ?, ?)
583
799
  ON CONFLICT(file_path) DO UPDATE SET
584
800
  file_mtime_ms = excluded.file_mtime_ms,
585
801
  file_size = excluded.file_size,
586
- scanned_at = excluded.scanned_at
802
+ scanned_at = excluded.scanned_at,
803
+ parser_state = excluded.parser_state,
804
+ content_text = excluded.content_text
587
805
  `);
588
806
  // Build a lookup from canonical file path → entry, used inside the write
589
807
  // transaction to re-check the ledger AFTER acquiring the lock. When a
@@ -613,7 +831,7 @@ export function upsertSessionsBatch(entries) {
613
831
  }
614
832
  }
615
833
  }
616
- for (const { meta, content, scan } of items) {
834
+ for (const { meta, content, scan, parserState, contentText } of items) {
617
835
  if (alreadyIndexed.has(meta.id))
618
836
  continue;
619
837
  // Per-row guard: one malformed session (e.g. a required field that resolves to
@@ -657,9 +875,12 @@ export function upsertSessionsBatch(entries) {
657
875
  plan: meta.plan ?? null,
658
876
  });
659
877
  delText.run(meta.id);
660
- insText.run(meta.id, meta.label ?? '', meta.topic ?? '', meta.project ?? '', content ?? '');
878
+ insText.run(meta.id,
879
+ // Mirror upsertSession: index the label the upsert actually stored
880
+ // (preserve-non-empty rule), not the raw incoming one.
881
+ storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '');
661
882
  if (scan && meta.filePath) {
662
- ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now);
883
+ ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now, parserState ?? null, contentText ?? null);
663
884
  }
664
885
  }
665
886
  catch (err) {
@@ -909,9 +1130,12 @@ function buildSessionWhere(options) {
909
1130
  params.push(options.cwd);
910
1131
  }
911
1132
  if (options.cwdPrefix) {
912
- // Stored cwd uses the host path separator (normalizeCwd → path.resolve), so
913
- // the subdir wildcard must too — a hardcoded '/' never matches a Windows
914
- // `C:\a\b` subpath and the listing comes back empty.
1133
+ // A LOCAL stored cwd uses the host path separator (normalizeCwd runs
1134
+ // path.normalize on it), so the subdir wildcard must too — a hardcoded '/'
1135
+ // never matches a Windows `C:\a\b` subpath and the listing comes back empty.
1136
+ // A cwd recorded on another machine keeps its own separators, so this
1137
+ // wildcard does not match foreign subpaths; both sides go through
1138
+ // normalizeCwd, so the exact `cwd = ?` comparison still holds for them.
915
1139
  where.push('(cwd = ? OR cwd LIKE ?)');
916
1140
  params.push(options.cwdPrefix, options.cwdPrefix + path.sep + '%');
917
1141
  }
@@ -0,0 +1,30 @@
1
+ /** A session's foreground/background presence. */
2
+ export type Presence = 'attached' | 'background' | 'parked';
3
+ export interface DetachRecord {
4
+ sessionId: string;
5
+ agent: string;
6
+ cwd?: string;
7
+ /** pid of the detached headless continuation `agents sessions detach` spawned. */
8
+ headlessPid: number;
9
+ /**
10
+ * Start-time fingerprint of {@link headlessPid} at spawn, so a liveness check
11
+ * survives PID reuse: the pid is only "our" continuation if it still occupies
12
+ * the process we launched. Null when the platform capture failed.
13
+ */
14
+ headlessStartTime: string | null;
15
+ detachedAtMs: number;
16
+ }
17
+ export declare function writeDetachRecord(rec: DetachRecord): void;
18
+ export declare function readDetachRecord(sessionId: string): DetachRecord | undefined;
19
+ export declare function clearDetachRecord(sessionId: string): void;
20
+ export declare function listDetachRecords(): DetachRecord[];
21
+ /** True while the recorded headless continuation is still the live process we spawned. */
22
+ export declare function isHeadlessAlive(rec: DetachRecord): boolean;
23
+ /**
24
+ * Presence for a session id from the detach store alone:
25
+ * - no record -> undefined (caller decides: `attached` for a live
26
+ * interactive row, nothing for cloud/team rows)
27
+ * - record + pid alive -> `background` (headless continuation running)
28
+ * - record + pid exited -> `parked` (the run finished; transcript is durable)
29
+ */
30
+ export declare function presenceFromStore(sessionId: string): Presence | undefined;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Detached-session store — the record `agents sessions detach` writes and both
3
+ * `agents sessions attach` and the active-session scan read to know an agent is
4
+ * "backgrounded": running headless with no terminal, continuing its task
5
+ * unattended.
6
+ *
7
+ * One file per detached session under `~/.agents/.system/detached/<id>.json`.
8
+ * Presence is DERIVED, never asserted: a record only means "this session was
9
+ * detached to a headless continuation"; whether it is still `background`
10
+ * (that continuation is alive) or `parked` (it has exited) is decided live from
11
+ * the recorded pid + its start-time fingerprint. That keeps the store honest
12
+ * even across a crash that never ran `attach`.
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { getSystemAgentsDir } from '../state.js';
17
+ import { captureProcessStartTime } from '../pty-server.js';
18
+ function detachedDir() {
19
+ return path.join(getSystemAgentsDir(), 'detached');
20
+ }
21
+ function recordPath(sessionId) {
22
+ return path.join(detachedDir(), `${sessionId}.json`);
23
+ }
24
+ export function writeDetachRecord(rec) {
25
+ fs.mkdirSync(detachedDir(), { recursive: true });
26
+ fs.writeFileSync(recordPath(rec.sessionId), JSON.stringify(rec, null, 2));
27
+ }
28
+ export function readDetachRecord(sessionId) {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(recordPath(sessionId), 'utf8'));
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ }
36
+ export function clearDetachRecord(sessionId) {
37
+ try {
38
+ fs.rmSync(recordPath(sessionId));
39
+ }
40
+ catch {
41
+ /* already gone */
42
+ }
43
+ }
44
+ export function listDetachRecords() {
45
+ let names;
46
+ try {
47
+ names = fs.readdirSync(detachedDir());
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ const out = [];
53
+ for (const name of names) {
54
+ if (!name.endsWith('.json'))
55
+ continue;
56
+ const rec = readDetachRecord(name.slice(0, -'.json'.length));
57
+ if (rec)
58
+ out.push(rec);
59
+ }
60
+ return out;
61
+ }
62
+ /** True while the recorded headless continuation is still the live process we spawned. */
63
+ export function isHeadlessAlive(rec) {
64
+ if (!rec.headlessPid || rec.headlessPid <= 0)
65
+ return false;
66
+ try {
67
+ process.kill(rec.headlessPid, 0);
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ // Defeat PID reuse: if the pid now belongs to a different process, it is not ours.
73
+ if (rec.headlessStartTime !== null) {
74
+ const now = captureProcessStartTime(rec.headlessPid);
75
+ if (now !== null && now !== rec.headlessStartTime)
76
+ return false;
77
+ }
78
+ return true;
79
+ }
80
+ /**
81
+ * Presence for a session id from the detach store alone:
82
+ * - no record -> undefined (caller decides: `attached` for a live
83
+ * interactive row, nothing for cloud/team rows)
84
+ * - record + pid alive -> `background` (headless continuation running)
85
+ * - record + pid exited -> `parked` (the run finished; transcript is durable)
86
+ */
87
+ export function presenceFromStore(sessionId) {
88
+ const rec = readDetachRecord(sessionId);
89
+ if (!rec)
90
+ return undefined;
91
+ return isHeadlessAlive(rec) ? 'background' : 'parked';
92
+ }