@phnx-labs/agents-cli 1.22.57 → 1.22.59

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 (152) hide show
  1. package/CHANGELOG.md +294 -0
  2. package/README.md +29 -0
  3. package/dist/bootstrap.js +39 -1
  4. package/dist/commands/accounts.js +7 -3
  5. package/dist/commands/apply.js +10 -2
  6. package/dist/commands/fork.d.ts +23 -10
  7. package/dist/commands/fork.js +115 -58
  8. package/dist/commands/monitors.js +198 -23
  9. package/dist/commands/prune.js +5 -3
  10. package/dist/commands/routines.d.ts +8 -0
  11. package/dist/commands/routines.js +57 -3
  12. package/dist/commands/routines.test-fixture.js +5 -0
  13. package/dist/commands/send.d.ts +2 -1
  14. package/dist/commands/send.js +7 -5
  15. package/dist/commands/sessions-picker.d.ts +11 -0
  16. package/dist/commands/sessions-picker.js +16 -0
  17. package/dist/commands/sessions-stats.js +37 -5
  18. package/dist/commands/sessions.js +40 -5
  19. package/dist/commands/share.d.ts +14 -0
  20. package/dist/commands/share.js +43 -2
  21. package/dist/commands/ssh.js +12 -1
  22. package/dist/commands/status.js +1 -1
  23. package/dist/commands/sync.js +83 -7
  24. package/dist/commands/traces.js +7 -0
  25. package/dist/commands/versions.js +12 -4
  26. package/dist/commands/view.js +7 -2
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +6 -1
  29. package/dist/lib/account-registry.d.ts +5 -1
  30. package/dist/lib/account-registry.js +47 -14
  31. package/dist/lib/accounting/capacity.d.ts +18 -7
  32. package/dist/lib/accounting/capacity.js +19 -8
  33. package/dist/lib/accounting/usage-sync.d.ts +29 -1
  34. package/dist/lib/accounting/usage-sync.js +76 -2
  35. package/dist/lib/accounting/usage.js +7 -1
  36. package/dist/lib/auth-mint.d.ts +11 -1
  37. package/dist/lib/auth-mint.js +21 -6
  38. package/dist/lib/auto-pull-worker.js +7 -2
  39. package/dist/lib/browser/ipc.d.ts +8 -0
  40. package/dist/lib/browser/ipc.js +87 -0
  41. package/dist/lib/browser/service.d.ts +19 -0
  42. package/dist/lib/browser/service.js +96 -11
  43. package/dist/lib/browser/sessions-list.js +10 -1
  44. package/dist/lib/cloud/rush.d.ts +7 -0
  45. package/dist/lib/cloud/rush.js +29 -1
  46. package/dist/lib/daemon/daemon.d.ts +22 -0
  47. package/dist/lib/daemon/daemon.js +39 -0
  48. package/dist/lib/daemon/runner.d.ts +3 -0
  49. package/dist/lib/daemon/runner.js +86 -45
  50. package/dist/lib/daemon/session-index-service.js +9 -1
  51. package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
  52. package/dist/lib/daemon/usage-sync-service.js +14 -8
  53. package/dist/lib/daemon-services.js +1 -1
  54. package/dist/lib/daemon-ticks.d.ts +15 -0
  55. package/dist/lib/daemon-ticks.js +26 -0
  56. package/dist/lib/device-config.d.ts +5 -1
  57. package/dist/lib/device-config.js +2 -2
  58. package/dist/lib/devices/connect.d.ts +17 -8
  59. package/dist/lib/devices/connect.js +31 -14
  60. package/dist/lib/devices/health.js +5 -1
  61. package/dist/lib/devices/pool.d.ts +25 -2
  62. package/dist/lib/devices/pool.js +32 -2
  63. package/dist/lib/devices/stats-cache.d.ts +0 -6
  64. package/dist/lib/devices/stats-cache.js +2 -9
  65. package/dist/lib/doctor-diff.d.ts +14 -0
  66. package/dist/lib/doctor-diff.js +120 -9
  67. package/dist/lib/fleet/manifest.d.ts +17 -0
  68. package/dist/lib/fleet/manifest.js +26 -0
  69. package/dist/lib/git.d.ts +38 -0
  70. package/dist/lib/git.js +58 -0
  71. package/dist/lib/hooks/install.d.ts +27 -11
  72. package/dist/lib/hooks/install.js +42 -17
  73. package/dist/lib/hosts/ready.d.ts +8 -0
  74. package/dist/lib/hosts/ready.js +13 -2
  75. package/dist/lib/hosts/reconnect.d.ts +52 -203
  76. package/dist/lib/hosts/reconnect.js +64 -284
  77. package/dist/lib/installations/migrate.d.ts +6 -120
  78. package/dist/lib/installations/migrate.js +27 -259
  79. package/dist/lib/installations/shims.d.ts +13 -95
  80. package/dist/lib/installations/shims.js +22 -139
  81. package/dist/lib/installations/store.js +1 -1
  82. package/dist/lib/installations/versions.d.ts +43 -133
  83. package/dist/lib/installations/versions.js +94 -206
  84. package/dist/lib/monitors/config.d.ts +71 -3
  85. package/dist/lib/monitors/config.js +100 -12
  86. package/dist/lib/monitors/pid-watch.d.ts +35 -0
  87. package/dist/lib/monitors/pid-watch.js +45 -0
  88. package/dist/lib/monitors/remote.d.ts +18 -0
  89. package/dist/lib/monitors/remote.js +11 -0
  90. package/dist/lib/permissions.js +7 -2
  91. package/dist/lib/plugins/plugins.d.ts +17 -3
  92. package/dist/lib/plugins/plugins.js +84 -9
  93. package/dist/lib/plugins/skills.d.ts +8 -1
  94. package/dist/lib/plugins/skills.js +18 -2
  95. package/dist/lib/pty-server.d.ts +14 -0
  96. package/dist/lib/pty-server.js +49 -5
  97. package/dist/lib/refresh.d.ts +9 -0
  98. package/dist/lib/refresh.js +3 -1
  99. package/dist/lib/routine-readiness.d.ts +15 -1
  100. package/dist/lib/routine-readiness.js +41 -0
  101. package/dist/lib/sandbox.d.ts +4 -1
  102. package/dist/lib/sandbox.js +30 -1
  103. package/dist/lib/secrets/agent.d.ts +80 -225
  104. package/dist/lib/secrets/agent.js +139 -401
  105. package/dist/lib/secrets/bundles.d.ts +73 -222
  106. package/dist/lib/secrets/bundles.js +168 -467
  107. package/dist/lib/secrets/drivers/rush.js +5 -0
  108. package/dist/lib/secrets/reaper.d.ts +28 -70
  109. package/dist/lib/secrets/reaper.js +30 -85
  110. package/dist/lib/secrets/remote.d.ts +42 -129
  111. package/dist/lib/secrets/remote.js +55 -173
  112. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  113. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  114. package/dist/lib/self-heal/registry.js +2 -0
  115. package/dist/lib/self-heal/types.d.ts +1 -1
  116. package/dist/lib/self-update.d.ts +65 -0
  117. package/dist/lib/self-update.js +138 -0
  118. package/dist/lib/session/active.d.ts +13 -1
  119. package/dist/lib/session/active.js +2 -0
  120. package/dist/lib/session/cloud.js +5 -0
  121. package/dist/lib/session/db.d.ts +51 -6
  122. package/dist/lib/session/db.js +266 -20
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/tool-calls.d.ts +43 -1
  126. package/dist/lib/session/tool-calls.js +74 -44
  127. package/dist/lib/session/tool-store.d.ts +33 -2
  128. package/dist/lib/session/tool-store.js +56 -3
  129. package/dist/lib/smart-launch.d.ts +6 -0
  130. package/dist/lib/smart-launch.js +5 -2
  131. package/dist/lib/staleness/writers/plugins.js +5 -2
  132. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  133. package/dist/lib/staleness/writers/sources.js +2 -1
  134. package/dist/lib/staleness/writers/subagents.js +13 -3
  135. package/dist/lib/state.d.ts +7 -4
  136. package/dist/lib/state.js +7 -4
  137. package/dist/lib/subagents.js +8 -2
  138. package/dist/lib/sync-status.d.ts +22 -0
  139. package/dist/lib/sync-status.js +27 -0
  140. package/dist/lib/sync-umbrella.d.ts +9 -0
  141. package/dist/lib/sync-umbrella.js +21 -2
  142. package/dist/lib/teams/scheduler.d.ts +10 -0
  143. package/dist/lib/teams/scheduler.js +8 -0
  144. package/dist/lib/traces/insights.d.ts +47 -14
  145. package/dist/lib/traces/insights.js +92 -21
  146. package/dist/lib/traces/phenotype.d.ts +23 -3
  147. package/dist/lib/traces/phenotype.js +72 -24
  148. package/dist/lib/traces/sync.d.ts +128 -6
  149. package/dist/lib/traces/sync.js +294 -35
  150. package/dist/lib/traces/worker-template.js +154 -1
  151. package/dist/lib/view-types.d.ts +12 -0
  152. package/package.json +2 -2
@@ -15,8 +15,8 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
15
15
  import { query as queryEvents, queryToolUsageForSessions } from '../feed/events.js';
16
16
  import { machineForSessionFile } from '../origin-machine.js';
17
17
  import { loadSessionActorIndex, readSessionActorRecord } from './actor-sidecar.js';
18
- import { toolCallsFromEvents } from './tool-calls.js';
19
- import { persistToolCalls, toolEvidenceSourcePath } from './tool-store.js';
18
+ import { scanEventToolCalls } from './tool-calls.js';
19
+ import { persistToolCalls, planEventToolResume, toolEvidenceSourcePath } from './tool-store.js';
20
20
  import { buildClaudeAccountIndex, resolveClaudeAccount } from './claude-accounts.js';
21
21
  import { extractBackgroundShells, extractSkills, extractSlashCommands, harnessTracksBackgroundShells, isSubAgentTool, } from './highlights.js';
22
22
  import { resolveResource } from '../resources.js';
@@ -27,7 +27,7 @@ const DB_PATH = getSessionsDbPath();
27
27
  /** Current schema version; bumped when migrations are added. Exported so tests
28
28
  * assert against the constant instead of hardcoding a number that every bump
29
29
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
30
- export const SCHEMA_VERSION = 42;
30
+ export const SCHEMA_VERSION = 44;
31
31
  /**
32
32
  * Bump to force the content extractor (assistant-answer text, alongside the
33
33
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -207,6 +207,11 @@ CREATE TABLE IF NOT EXISTS tool_calls (
207
207
  ordinal INTEGER NOT NULL,
208
208
  source_call_id TEXT,
209
209
  timestamp TEXT NOT NULL,
210
+ -- When the call's RESULT record arrived (its own end time). end_timestamp
211
+ -- minus timestamp is the call's own blocking duration, which the traces
212
+ -- insight engine attributes as a failed call's wasted time (PHNX-3437). NULL
213
+ -- for a call that never produced a result and for rows an older extractor stored.
214
+ end_timestamp TEXT,
210
215
  tool TEXT NOT NULL,
211
216
  input TEXT NOT NULL,
212
217
  outcome TEXT NOT NULL,
@@ -352,6 +357,26 @@ CREATE TABLE IF NOT EXISTS session_topics (
352
357
  topic_json TEXT NOT NULL
353
358
  );
354
359
 
360
+ -- Derived failure phenotype for traces sync (PHNX-3327). Like session_topics /
361
+ -- session_insights, this is a lazy, stamp-validated cache keyed on
362
+ -- (file_mtime_ms, file_size) and intentionally independent of SCHEMA_VERSION.
363
+ -- Classifying a phenotype needs the full derived SessionTrajectory (ordered
364
+ -- steps, gaps), which buildIndexShard does NOT have from flat tool_calls rows —
365
+ -- so it is computed per-session ONCE (parse -> trajectory -> classify) and cached
366
+ -- here, then read for the WHOLE corpus on every sync. That is what lets the
367
+ -- phenotype grouping dimension fold two identically-signatured sessions into one
368
+ -- cluster regardless of which incremental batch each was first synced in, without
369
+ -- re-parsing transcripts at 10k+ session scale. phenotype_json holds
370
+ -- { phenotype: FailurePhenotype | null } (null = no failure phenotype matched).
371
+ CREATE TABLE IF NOT EXISTS session_phenotypes (
372
+ session_id TEXT PRIMARY KEY,
373
+ file_mtime_ms INTEGER,
374
+ file_size INTEGER,
375
+ extractor_version INTEGER NOT NULL,
376
+ computed_at INTEGER NOT NULL,
377
+ phenotype_json TEXT NOT NULL
378
+ );
379
+
355
380
  -- Normalized data behind sessions preview. Like session_insights this is a
356
381
  -- lazy, stamp-validated cache: opening one session parses only that transcript,
357
382
  -- while subsequent processes reuse the derived preview until its bytes change.
@@ -437,7 +462,12 @@ CREATE INDEX IF NOT EXISTS idx_computer_sessions_started ON computer_sessions(st
437
462
  export const INSIGHTS_EXTRACTOR_VERSION = 7;
438
463
  /** Bump when classifyTopic's output changes so cached topics recompute (human task taxonomy v2). */
439
464
  export const SESSION_TOPIC_EXTRACTOR_VERSION = 2;
440
- const PREVIEW_EXTRACTOR_VERSION = 1;
465
+ // Bumped to 2 (PHNX-2973): the digest now carries `changedFiles` (per-file
466
+ // paths). Bumping invalidates v1 cache rows so a fresh recompute populates the
467
+ // new field instead of serving a stale digest that predates it.
468
+ const PREVIEW_EXTRACTOR_VERSION = 2;
469
+ /** Bump when classifyPhenotype's output changes so cached phenotypes recompute (PHNX-3327 v1). */
470
+ export const SESSION_PHENOTYPE_EXTRACTOR_VERSION = 1;
441
471
  let dbInstance = null;
442
472
  /**
443
473
  * Apply schema migrations from `fromVersion` → SCHEMA_VERSION. The new
@@ -1152,6 +1182,55 @@ function migrateSchema(db, fromVersion) {
1152
1182
  // Claude/Codex resumable continuation) stay intact for rows that don't
1153
1183
  // need a full reparse for any OTHER reason.
1154
1184
  }
1185
+ if (fromVersion < 43) {
1186
+ // v42 -> v43: persist a per-tool-call END timestamp (PHNX-3437). The traces
1187
+ // insight engine could only book a failed call's wasted time from the
1188
+ // bounded gap to the NEXT call — so a call that BLOCKED for minutes and was
1189
+ // the last in its session (or was followed quickly by an unrelated call)
1190
+ // registered as ~0 waste. The call's result record already carried its own
1191
+ // timestamp at ingestion; this column persists it so `end_timestamp -
1192
+ // timestamp` (the call's own blocking duration) becomes the primary
1193
+ // attribution, with the gap heuristic kept as the fallback for NULL rows.
1194
+ //
1195
+ // Additive, nullable column — no ledger flush (the v33->v34 contract that
1196
+ // adding a column keeps warm session ledgers warm). Pre-upgrade rows stay
1197
+ // NULL until re-indexed, and `insights.ts` degrades a NULL end back to the
1198
+ // bounded-gap behavior, so nothing crashes or yields NaN. The paired
1199
+ // TOOL_INDEX_VERSION bump (7 -> 8) is what re-derives it on a re-index; the
1200
+ // tool index is deliberately independent of SCHEMA_VERSION and is never
1201
+ // force-rescanned by a migration (only by the explicit tool backfill or a
1202
+ // normal incremental append), so a bare ALTER here would otherwise leave
1203
+ // existing rows dark forever.
1204
+ const cols = new Set(db.prepare(`PRAGMA table_info(tool_calls)`).all().map((c) => c.name));
1205
+ if (!cols.has('end_timestamp'))
1206
+ db.exec(`ALTER TABLE tool_calls ADD COLUMN end_timestamp TEXT`);
1207
+ }
1208
+ if (fromVersion < 44) {
1209
+ // v43 -> v44: backfill duration_ms for sessions whose harness scan extractor
1210
+ // never derived it (PHNX-3457). rush/grok/kimi/cursor/muse/antigravity/hermes/
1211
+ // openclaw left duration_ms NULL — 52% of the corpus, 100% of rush — so the
1212
+ // console median was computed over only the ~48% that carried it, skewing it
1213
+ // short. Going forward resolveDurationMs() populates it at every upsert; this
1214
+ // repairs already-indexed rows in place from the timestamps they already store
1215
+ // (last_activity, itself resolved from the last-message time else file mtime,
1216
+ // minus the creation timestamp), so no transcript is re-parsed. julianday is
1217
+ // avoided because it does not accept a trailing 'Z'; the arithmetic is done in
1218
+ // JS with the exact Date.parse resolveDurationMs uses, keeping the backfill and
1219
+ // the live path consistent. Only rows with a positive span are touched; a NULL
1220
+ // that cannot be resolved stays NULL rather than becoming a fabricated 0.
1221
+ const nullDurationRows = db.prepare(`SELECT id, timestamp, last_activity FROM sessions
1222
+ WHERE duration_ms IS NULL AND last_activity IS NOT NULL`).all();
1223
+ // Runs inside migrateSchema's own transaction (db.ts:1468), so no nested
1224
+ // db.transaction() here — that would raise "transaction within a transaction".
1225
+ const update = db.prepare(`UPDATE sessions SET duration_ms = ? WHERE id = ?`);
1226
+ for (const row of nullDurationRows) {
1227
+ const startMs = Date.parse(row.timestamp);
1228
+ const lastMs = Date.parse(row.last_activity);
1229
+ if (Number.isFinite(startMs) && Number.isFinite(lastMs) && lastMs > startMs) {
1230
+ update.run(lastMs - startMs, row.id);
1231
+ }
1232
+ }
1233
+ }
1155
1234
  }
1156
1235
  /**
1157
1236
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its
@@ -2022,7 +2101,7 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
2022
2101
  cache_write_tokens: meta.cacheWriteTokens ?? null,
2023
2102
  cost_usd: meta.costUsd ?? null,
2024
2103
  cost_usd_nocache: meta.costUsdNoCache ?? null,
2025
- duration_ms: meta.durationMs ?? null,
2104
+ duration_ms: resolveDurationMs(meta, scan),
2026
2105
  model: meta.model ?? null,
2027
2106
  tool_call_count: meta.toolCallCount ?? null,
2028
2107
  file_path: meta.filePath,
@@ -2110,6 +2189,23 @@ export function upsertSessionsBatch(entries) {
2110
2189
  ? { ...entry, meta: { ...entry.meta, ...fanOutCounts(entry.events, entry.meta.agent) } }
2111
2190
  : entry;
2112
2191
  }
2192
+ // Harnesses whose scanners produce no events AND whose parseSession reads a
2193
+ // potentially large flat transcript file (not a compact SQLite DB). Calling
2194
+ // parseSession on the warm tick for an active large session wedges the Node
2195
+ // event loop for seconds, making browser IPC miss its connection window
2196
+ // (PHNX-3411). Defer their tool-call indexing to runDeferredToolIndex, which
2197
+ // uses ensureToolIndex with tool_scan_ledger stamps and byte/file budget caps.
2198
+ // NOT opencode — parseOpenCode issues a targeted SQLite query, so it is fast
2199
+ // even for large sessions and its results populate recentDirectoriesTouched.
2200
+ const LARGE_TRANSCRIPT_AGENTS = new Set(['kimi', 'grok']);
2201
+ if (!entry.events && LARGE_TRANSCRIPT_AGENTS.has(entry.meta.agent)) {
2202
+ return entry;
2203
+ }
2204
+ // Enrich the entry. When the scanner already provided events, use them
2205
+ // directly (no transcript re-read). When it didn't — e.g. OpenCode whose
2206
+ // scanner produces only metadata but whose parseOpenCode is a fast SQLite
2207
+ // query — fall back to parseSession. Agents that would call an expensive
2208
+ // flat-file parse already returned above.
2113
2209
  try {
2114
2210
  const toolSourcePath = toolEvidenceSourcePath(entry.meta.filePath, entry.meta.agent);
2115
2211
  const toolScan = toolSourcePath === entry.meta.filePath
@@ -2118,11 +2214,23 @@ export function upsertSessionsBatch(entries) {
2118
2214
  const stat = fs.statSync(toolSourcePath);
2119
2215
  return { fileMtimeMs: stat.mtimeMs, fileSize: stat.size };
2120
2216
  })();
2121
- // Some non-resumable scanners already normalized the transcript while
2122
- // deriving metadata. Reuse those events; scanners that only read summary
2123
- // metadata fall back to exactly one normalized parse here.
2124
2217
  const events = entry.events ?? parseSession(entry.meta.filePath, entry.meta.agent);
2125
2218
  writeResourceUsage(entry.meta.id, events, entry.meta.cwd);
2219
+ // Resume the tool index from the last scan of this append-only stream when
2220
+ // it is safe to (PHNX-3411). A live session's transcript grows every tick,
2221
+ // so a full re-derive re-sanitizes its ENTIRE tool history each time — the
2222
+ // synchronous O(session) work that wedged the daemon event loop for the
2223
+ // 11 non-streaming harnesses. `planEventToolResume` returns the prior
2224
+ // snapshot only when the file is still an append of what was scanned
2225
+ // before; otherwise `prior` is null and this is a full replace from
2226
+ // event 0 (identical index, just re-derived).
2227
+ // No tool stamp (a scanner that carried no scan record) means the resume
2228
+ // point cannot be size-guarded, so full-scan rather than trust a stale
2229
+ // offset. persistToolCalls below also skips a resume-less write.
2230
+ const prior = toolScan
2231
+ ? planEventToolResume(db, entry.meta.id, toolSourcePath, toolScan, events.length)
2232
+ : null;
2233
+ const scanned = scanEventToolCalls(events, prior ?? undefined);
2126
2234
  return {
2127
2235
  ...entry,
2128
2236
  meta: {
@@ -2131,11 +2239,14 @@ export function upsertSessionsBatch(entries) {
2131
2239
  recentDirectoriesTouched: extractRecentDirectoriesTouched(events, entry.meta.cwd),
2132
2240
  ...fanOutCounts(events, entry.meta.agent),
2133
2241
  },
2134
- toolCalls: toolCallsFromEvents(events),
2242
+ // The CHANGED calls only. On a resume these are the newly appended tail
2243
+ // (append-safe upsert); on a full scan they are the whole history.
2244
+ toolCalls: scanned.calls,
2135
2245
  toolScan,
2136
- // These are complete event arrays, not an appended tail. Append would
2137
- // duplicate existing evidence even when persistToolCalls supports it.
2138
- toolIndexMode: 'replace',
2246
+ toolIndexMode: (prior ? 'append' : 'replace'),
2247
+ // Persist where the NEXT scan resumes: the collector snapshot + how many
2248
+ // events this scan folded.
2249
+ toolResume: { parserState: JSON.stringify(scanned.snapshot), parsedOffset: scanned.eventCount },
2139
2250
  };
2140
2251
  }
2141
2252
  catch {
@@ -2231,7 +2342,7 @@ export function upsertSessionsBatch(entries) {
2231
2342
  cache_write_tokens: meta.cacheWriteTokens ?? null,
2232
2343
  cost_usd: meta.costUsd ?? null,
2233
2344
  cost_usd_nocache: meta.costUsdNoCache ?? null,
2234
- duration_ms: meta.durationMs ?? null,
2345
+ duration_ms: resolveDurationMs(meta, scan),
2235
2346
  model: meta.model ?? null,
2236
2347
  tool_call_count: meta.toolCallCount ?? null,
2237
2348
  file_path: meta.filePath,
@@ -2284,7 +2395,13 @@ export function upsertSessionsBatch(entries) {
2284
2395
  if (!toolScan || !entry.toolCalls)
2285
2396
  continue;
2286
2397
  try {
2287
- persistToolCalls(db, entry.meta, entry.toolCalls, toolScan, { mode: entry.toolIndexMode ?? 'replace' });
2398
+ // `resume` is set only by the full-file harness path above; claude/codex
2399
+ // pass none, so their tool ledger keeps carrying no event-offset resume
2400
+ // point (their resume rides the content-scan ledger instead) — unchanged.
2401
+ persistToolCalls(db, entry.meta, entry.toolCalls, toolScan, {
2402
+ mode: entry.toolIndexMode ?? 'replace',
2403
+ resume: entry.toolResume,
2404
+ });
2288
2405
  }
2289
2406
  catch {
2290
2407
  // Boundary is intentionally retryable via tool_scan_ledger.
@@ -2522,6 +2639,36 @@ function resolveLastActivity(meta, scan) {
2522
2639
  return new Date(scan.fileMtimeMs).toISOString();
2523
2640
  return meta.timestamp;
2524
2641
  }
2642
+ /**
2643
+ * The persisted wall-clock span for a session (PHNX-3457).
2644
+ *
2645
+ * `durationMs` is canonically `lastTs − firstTs`. Some harness scan extractors
2646
+ * derive it themselves from per-event timestamps (claude/codex/droid/gemini/
2647
+ * opencode set `meta.durationMs`); the rest (rush/grok/kimi/cursor/muse/
2648
+ * antigravity/hermes/openclaw) never did, so `duration_ms` landed NULL for them —
2649
+ * 52% of the corpus, including 100% of the dominant `rush` usage — and the
2650
+ * console median was computed over only the ~48% that happened to carry it,
2651
+ * skewing it short and misleading.
2652
+ *
2653
+ * This closes the gap at the single write boundary every harness funnels
2654
+ * through, so parity is automatic rather than per-extractor: when the extractor
2655
+ * already computed a precise span, keep it; otherwise derive it from the same
2656
+ * `timestamp` (creation) and `resolveLastActivity` (last event, itself resolved
2657
+ * from the harness's last-message time, else file mtime) that the row already
2658
+ * stores. Returns null only when no positive span can be established (a single
2659
+ * timestamped event, or a clock that runs backwards), which reads as NULL rather
2660
+ * than a fabricated 0.
2661
+ */
2662
+ function resolveDurationMs(meta, scan) {
2663
+ if (meta.durationMs != null)
2664
+ return meta.durationMs;
2665
+ const startMs = Date.parse(meta.timestamp);
2666
+ const lastMs = Date.parse(resolveLastActivity(meta, scan));
2667
+ if (Number.isFinite(startMs) && Number.isFinite(lastMs) && lastMs > startMs) {
2668
+ return lastMs - startMs;
2669
+ }
2670
+ return null;
2671
+ }
2525
2672
  export function isSessionActivityFresh(row, maxAgeMs, nowMs) {
2526
2673
  const parsedActivityMs = Date.parse(row.last_activity ?? row.timestamp);
2527
2674
  const activityMs = Number.isFinite(parsedActivityMs) ? parsedActivityMs : row.file_mtime_ms ?? undefined;
@@ -2815,6 +2962,26 @@ export function querySessions(options = {}) {
2815
2962
  const trimmed = options.limit ? live.slice(0, options.limit) : live;
2816
2963
  return trimmed.map(rowToMeta);
2817
2964
  }
2965
+ /**
2966
+ * Cheap query for the daemon's deferred tool-index pass (PHNX-3411).
2967
+ *
2968
+ * Returns the most-recently-active sessions whose parseSession reads a large
2969
+ * flat transcript (kimi: wire.jsonl, grok: chat_history.jsonl). Their scanners
2970
+ * produce no events, so upsertSessionsBatch skips them in the warm tick to
2971
+ * avoid wedging the event loop. ensureToolIndex uses tool_scan_ledger stamps
2972
+ * to skip already-current rows and applies byte/file budget caps.
2973
+ */
2974
+ export function querySessionsForDeferredToolIndex(limit) {
2975
+ const db = getDB();
2976
+ const rows = db.prepare(`
2977
+ SELECT * FROM sessions
2978
+ WHERE file_path IS NOT NULL
2979
+ AND agent IN ('kimi', 'grok')
2980
+ ORDER BY last_activity DESC, timestamp DESC
2981
+ LIMIT ?
2982
+ `).all(limit);
2983
+ return rows.map(rowToMeta);
2984
+ }
2818
2985
  /** Count sessions matching the given filter options. */
2819
2986
  export function countSessions(options = {}) {
2820
2987
  const db = getDB();
@@ -2950,6 +3117,59 @@ export function writeSessionTopics(entries) {
2950
3117
  }
2951
3118
  })();
2952
3119
  }
3120
+ /** Read cached failure phenotypes only when their transcript byte stamps still match. */
3121
+ export function readSessionPhenotypes(ids) {
3122
+ const db = getDB();
3123
+ const out = new Map();
3124
+ if (ids.length === 0)
3125
+ return out;
3126
+ const CHUNK = 400;
3127
+ for (let i = 0; i < ids.length; i += CHUNK) {
3128
+ const chunk = ids.slice(i, i + CHUNK);
3129
+ const placeholders = chunk.map(() => '?').join(',');
3130
+ const rows = db.prepare(`
3131
+ SELECT sp.session_id AS id, sp.phenotype_json AS phenotypeJson
3132
+ FROM session_phenotypes sp
3133
+ JOIN sessions s ON s.id = sp.session_id
3134
+ WHERE sp.session_id IN (${placeholders})
3135
+ AND sp.extractor_version = ?
3136
+ AND sp.file_mtime_ms IS s.file_mtime_ms
3137
+ AND sp.file_size IS s.file_size
3138
+ `).all(...chunk, SESSION_PHENOTYPE_EXTRACTOR_VERSION);
3139
+ for (const row of rows) {
3140
+ try {
3141
+ out.set(row.id, JSON.parse(row.phenotypeJson));
3142
+ }
3143
+ catch {
3144
+ // Invalid derived cache data is a miss and self-heals on the next write.
3145
+ }
3146
+ }
3147
+ }
3148
+ return out;
3149
+ }
3150
+ /** Persist failure phenotypes against the exact transcript bytes used to classify them. */
3151
+ export function writeSessionPhenotypes(entries) {
3152
+ if (entries.length === 0)
3153
+ return;
3154
+ const db = getDB();
3155
+ const stmt = db.prepare(`
3156
+ INSERT INTO session_phenotypes
3157
+ (session_id, file_mtime_ms, file_size, extractor_version, computed_at, phenotype_json)
3158
+ VALUES (?, ?, ?, ?, ?, ?)
3159
+ ON CONFLICT(session_id) DO UPDATE SET
3160
+ file_mtime_ms = excluded.file_mtime_ms,
3161
+ file_size = excluded.file_size,
3162
+ extractor_version = excluded.extractor_version,
3163
+ computed_at = excluded.computed_at,
3164
+ phenotype_json = excluded.phenotype_json
3165
+ `);
3166
+ const now = Date.now();
3167
+ db.transaction(() => {
3168
+ for (const entry of entries) {
3169
+ stmt.run(entry.id, entry.fileMtimeMs, entry.fileSize, SESSION_PHENOTYPE_EXTRACTOR_VERSION, now, JSON.stringify(entry.phenotype));
3170
+ }
3171
+ })();
3172
+ }
2953
3173
  /** Read one derived preview only when it matches the transcript bytes on disk. */
2954
3174
  export function readSessionPreviewCache(id, sourceStamp) {
2955
3175
  const row = getDB().prepare(`
@@ -3203,17 +3423,43 @@ export function queryResourceUsageStats(options) {
3203
3423
  return db.prepare(sql).all(...allParams);
3204
3424
  }
3205
3425
  /**
3206
- * Coverage of the resource-usage signal: how many distinct sessions carry any
3207
- * row in session_resource_usage vs. the total indexed. A low ratio means the
3208
- * historical backfill (`agents sessions backfill resources`) hasn't run — the
3209
- * stats surface uses this to tell the user their zero-counts may just be
3210
- * un-scanned history, not genuine non-use.
3426
+ * Coverage of the resource-usage signal, as three honest facts:
3427
+ *
3428
+ * - `scanned` — sessions the resource extractor has actually processed, i.e.
3429
+ * those carrying a `resource_scan_ledger` row at the current
3430
+ * `RESOURCE_INDEX_VERSION`. This is the true "has the historical backfill run"
3431
+ * signal: the ledger is stamped for EVERY scanned session, including ones that
3432
+ * invoked nothing (`resource_count = 0`), so `scanned/total` rises to ~1 after
3433
+ * `agents sessions backfill resources` regardless of how sparse explicit
3434
+ * invocations are.
3435
+ * - `covered` — distinct sessions that carry AT LEAST ONE row in
3436
+ * `session_resource_usage`, i.e. that actually recorded an explicit invocation.
3437
+ * This is an ABSOLUTE signal count, not a coverage ratio: it stays small even
3438
+ * at full scan coverage because most sessions invoke no skill/command, and a
3439
+ * non-recording harness contributes none by construction.
3440
+ * - `total` — sessions indexed.
3441
+ *
3442
+ * The two were previously conflated: `covered/total` was framed as coverage and
3443
+ * read ~1.2% even after a full backfill (most sessions genuinely invoke nothing),
3444
+ * so the "run the backfill" hint never cleared. Keying the hint on `scanned/total`
3445
+ * fixes that — see `commands/sessions-stats.ts` (PHNX-2301).
3211
3446
  */
3212
3447
  export function resourceUsageCoverage() {
3213
3448
  const db = getDB();
3214
3449
  const covered = db.prepare(`SELECT COUNT(DISTINCT session_id) AS n FROM session_resource_usage`).get().n;
3450
+ // JOIN sessions so a ledger row for a since-vanished transcript (dropped from
3451
+ // `sessions` but not yet from the ledger) can't inflate scan coverage past the
3452
+ // indexed set. Only rows at the current extractor version count as scanned —
3453
+ // a stale-version row is re-derived on the next backfill, so it is not yet
3454
+ // "covered" for this extractor.
3455
+ const scanned = db.prepare(`
3456
+ SELECT COUNT(*) AS n
3457
+ FROM resource_scan_ledger l
3458
+ JOIN sessions s ON s.id = l.session_id
3459
+ WHERE l.extractor_version = ?
3460
+ `).get(RESOURCE_INDEX_VERSION).n;
3215
3461
  const total = db.prepare(`SELECT COUNT(*) AS n FROM sessions`).get().n;
3216
- return { covered, total };
3462
+ return { covered, scanned, total };
3217
3463
  }
3218
3464
  /** Has this session's resource usage been derived at the current extractor version for this exact file? */
3219
3465
  function needsResourceIndex(db, sessionId, stamp) {
@@ -1,32 +1,51 @@
1
+ /**
2
+ * Session forking — branch an existing conversation into a new, independent
3
+ * sibling that continues the work, leaving the original untouched.
4
+ *
5
+ * `resume` continues the SAME conversation (same id, same file — it appends).
6
+ * `fork` launches a NEW same-harness session, load-balanced, seeded with a
7
+ * recap of the source so it picks up where the original left off. This is the
8
+ * "git branch" of conversations.
9
+ *
10
+ * The recap — not a transcript copy — is what makes fork work across every
11
+ * device and every REPL harness: the sibling is handed plain text as its opening
12
+ * input, so it never has to reach a transcript that may live on another box. The
13
+ * source is resolved cross-fleet by the same resolver `preview` uses; this module
14
+ * owns only the pure recap text the resolved data folds into.
15
+ */
1
16
  import type { SessionMeta } from './types.js';
2
- /** Agents that `fork` can branch today (see the module doc for why). */
3
- export declare const FORKABLE_AGENTS: readonly ["claude"];
4
- /** Whether a session's agent can be forked by {@link forkSession}. */
5
- export declare function isForkableAgent(agent: string): boolean;
6
- /** Outcome of a successful fork. */
7
- export interface ForkResult {
8
- /** The new session's full id. */
9
- newId: string;
10
- /** The new session's short id (first 8 chars), for display/resume. */
11
- shortId: string;
12
- /** Absolute path of the copied transcript. */
13
- filePath: string;
14
- /** The label applied to the fork. */
17
+ /** File-change tally as `sessions preview --json` serializes it (digest.changes). */
18
+ export interface ForkRecapChanges {
19
+ created: number;
20
+ modified: number;
21
+ deleted: number;
22
+ }
23
+ /** Everything the recap seed is built from — resolved cross-fleet before launch. */
24
+ export interface ForkRecapInput {
25
+ /** Source harness id — the sibling launches the same one. */
26
+ agent: string;
27
+ /** Display label for the source (label → topic → short id, resolved by the caller). */
15
28
  label: string;
29
+ /** Source working directory, so the sibling re-roots itself. */
30
+ cwd?: string;
31
+ /** Linear/GitHub ticket the source was bound to, if any. */
32
+ ticketId?: string;
33
+ /** Device that owns the source transcript, for the `/continue` escape hatch. */
34
+ machine?: string;
35
+ /** Short + full id, so the sibling can pull full history with `/continue <id>`. */
36
+ shortId: string;
37
+ id: string;
38
+ /** The source's last assistant line — the single best "where it left off" signal. */
39
+ lastAssistant?: string;
40
+ /** Changed-files tally so far. */
41
+ changes?: ForkRecapChanges;
16
42
  }
43
+ /** Resolve the human display label the caller passes in from a raw SessionMeta. */
44
+ export declare function forkLabelFor(session: Pick<SessionMeta, 'label' | 'topic' | 'shortId'>): string;
17
45
  /**
18
- * Fork a Claude session into a new, independent one.
19
- *
20
- * Copies `source.filePath` to a new `<uuid>.jsonl` beside it, rewrites the
21
- * embedded session id, registers the new session in the index, and records a
22
- * `--name`-style label. Returns the new ids/path. Throws if the source
23
- * transcript is missing.
46
+ * Build the recap-seed prompt handed to the forked sibling as its opening input.
24
47
  *
25
- * @param source The resolved metadata of the session being forked.
26
- * @param opts.name Optional explicit label; defaults to `fork of <original>`.
27
- * @param now ISO timestamp to stamp the fork with (injectable for tests).
48
+ * Pure and deterministic (unit-tested) — no filesystem, no spawn — so the launch
49
+ * orchestration in `commands/fork.ts` stays the only side-effecting layer.
28
50
  */
29
- export declare function forkSession(source: SessionMeta, opts?: {
30
- name?: string;
31
- now?: string;
32
- }): ForkResult;
51
+ export declare function buildForkRecap(input: ForkRecapInput): string;
@@ -1,102 +1,39 @@
1
- /**
2
- * Session forking — branch an existing conversation into a new, independent
3
- * session that can be continued separately, leaving the original untouched.
4
- *
5
- * `resume` continues the SAME conversation (same id, same file — it appends).
6
- * `fork` copies the transcript under a FRESH session id, so continuing the fork
7
- * diverges from the original instead of mutating it. This is the "git branch"
8
- * of conversations.
9
- *
10
- * v1 supports Claude, whose session id IS its `<id>.jsonl` filename and which
11
- * resumes natively via `--resume`. A fork is therefore: copy the transcript to
12
- * a new-uuid filename in the same directory, rewrite the embedded `sessionId`
13
- * on each line, register the new session in the index, and label it. Other
14
- * agents (codex single-file; grok/kimi multi-file; opencode DB-only) are a
15
- * natural follow-up and are refused up front for now.
16
- */
17
- import { randomUUID } from 'crypto';
18
- import * as fs from 'fs';
19
- import * as path from 'path';
20
- import { upsertSession } from './db.js';
21
- import { recordRunName } from './run-names.js';
22
- import { deriveShortId } from '../text/short-id.js';
23
- /** Agents that `fork` can branch today (see the module doc for why). */
24
- export const FORKABLE_AGENTS = ['claude'];
25
- /** Whether a session's agent can be forked by {@link forkSession}. */
26
- export function isForkableAgent(agent) {
27
- return FORKABLE_AGENTS.includes(agent);
1
+ /** Longest last-assistant excerpt carried into the seed — enough to convey intent
2
+ * without pasting a wall of text (decision: Recap, not Full digest). */
3
+ const LAST_LINE_CAP = 400;
4
+ /** Collapse whitespace and cap length so a multi-paragraph final message becomes
5
+ * one scannable recap line. */
6
+ function trimLastLine(text) {
7
+ const collapsed = text.replace(/\s+/g, ' ').trim();
8
+ if (collapsed.length <= LAST_LINE_CAP)
9
+ return collapsed;
10
+ return `${collapsed.slice(0, LAST_LINE_CAP).trimEnd()}…`;
28
11
  }
29
- /**
30
- * Rewrite the per-line `sessionId` field of a Claude JSONL transcript to a new
31
- * id. Claude resolves a conversation by its filename, so this is belt-and-braces
32
- * (keeps the in-file id consistent with the new filename); malformed lines are
33
- * passed through untouched.
34
- */
35
- function rewriteSessionId(transcript, newId) {
36
- return transcript
37
- .split('\n')
38
- .map((line) => {
39
- if (!line.trim())
40
- return line;
41
- try {
42
- const obj = JSON.parse(line);
43
- if (typeof obj.sessionId === 'string') {
44
- obj.sessionId = newId;
45
- return JSON.stringify(obj);
46
- }
47
- return line;
48
- }
49
- catch {
50
- return line;
51
- }
52
- })
53
- .join('\n');
12
+ /** Resolve the human display label the caller passes in from a raw SessionMeta. */
13
+ export function forkLabelFor(session) {
14
+ return session.label || session.topic || session.shortId;
54
15
  }
55
16
  /**
56
- * Fork a Claude session into a new, independent one.
57
- *
58
- * Copies `source.filePath` to a new `<uuid>.jsonl` beside it, rewrites the
59
- * embedded session id, registers the new session in the index, and records a
60
- * `--name`-style label. Returns the new ids/path. Throws if the source
61
- * transcript is missing.
17
+ * Build the recap-seed prompt handed to the forked sibling as its opening input.
62
18
  *
63
- * @param source The resolved metadata of the session being forked.
64
- * @param opts.name Optional explicit label; defaults to `fork of <original>`.
65
- * @param now ISO timestamp to stamp the fork with (injectable for tests).
19
+ * Pure and deterministic (unit-tested) — no filesystem, no spawn — so the launch
20
+ * orchestration in `commands/fork.ts` stays the only side-effecting layer.
66
21
  */
67
- export function forkSession(source, opts = {}) {
68
- if (!fs.existsSync(source.filePath)) {
69
- throw new Error(`transcript not found for session ${source.shortId}: ${source.filePath}`);
22
+ export function buildForkRecap(input) {
23
+ const lines = [];
24
+ lines.push(`Continue a prior ${input.agent} session ("${input.label}"). Pick up where it left off — do not restart it.`);
25
+ if (input.cwd)
26
+ lines.push(`Working directory: ${input.cwd}`);
27
+ if (input.ticketId)
28
+ lines.push(`Ticket: ${input.ticketId}`);
29
+ const last = input.lastAssistant ? trimLastLine(input.lastAssistant) : '';
30
+ if (last)
31
+ lines.push(`It last said: "${last}"`);
32
+ const chg = input.changes;
33
+ if (chg && (chg.created || chg.modified || chg.deleted)) {
34
+ lines.push(`Changes so far: +${chg.created} ~${chg.modified} -${chg.deleted}.`);
70
35
  }
71
- const newId = randomUUID();
72
- const shortId = deriveShortId(newId);
73
- const dir = path.dirname(source.filePath);
74
- const filePath = path.join(dir, `${newId}.jsonl`);
75
- const transcript = fs.readFileSync(source.filePath, 'utf-8');
76
- const rewritten = rewriteSessionId(transcript, newId);
77
- fs.writeFileSync(filePath, rewritten);
78
- const original = source.label || source.topic || source.shortId;
79
- const label = opts.name || `fork of ${original}`;
80
- // Label sidecar (seeds the DB label; survives rescans until an agent title
81
- // supersedes it), mirroring `agents run --name`.
82
- recordRunName({ sessionId: newId, name: label, agent: source.agent, cwd: source.cwd });
83
- // Register the new session so it resolves immediately (by `agents sessions resume`,
84
- // `agents sessions`, etc.) without waiting for the next scan.
85
- const stamp = opts.now ?? new Date().toISOString();
86
- const meta = {
87
- ...source,
88
- id: newId,
89
- shortId,
90
- filePath,
91
- label,
92
- timestamp: stamp,
93
- lastActivity: stamp,
94
- // The fork has not opened its own PR / team; drop origin-specific refs.
95
- prUrl: undefined,
96
- prNumber: undefined,
97
- teamOrigin: undefined,
98
- spawnedTeam: undefined,
99
- };
100
- upsertSession(meta, rewritten);
101
- return { newId, shortId, filePath, label };
36
+ const origin = input.machine ? ` on ${input.machine}` : '';
37
+ lines.push(`Source session ${input.shortId}${origin} — run \`/continue ${input.id}\` if you need the full transcript.`);
38
+ return lines.join('\n');
102
39
  }