@phnx-labs/agents-cli 1.22.56 → 1.22.58

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 (146) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +4 -4
  3. package/dist/bootstrap.js +11 -2
  4. package/dist/cli/command-registry.d.ts +0 -1
  5. package/dist/cli/command-registry.js +0 -3
  6. package/dist/commands/accounts.js +7 -3
  7. package/dist/commands/apply.js +10 -2
  8. package/dist/commands/exec.js +1 -1
  9. package/dist/commands/fork.d.ts +23 -10
  10. package/dist/commands/fork.js +115 -58
  11. package/dist/commands/hooks.js +4 -4
  12. package/dist/commands/insights.d.ts +7 -5
  13. package/dist/commands/insights.js +16 -9
  14. package/dist/commands/monitors.js +11 -0
  15. package/dist/commands/perf.d.ts +16 -7
  16. package/dist/commands/perf.js +29 -20
  17. package/dist/commands/prune.js +5 -3
  18. package/dist/commands/routines.d.ts +8 -0
  19. package/dist/commands/routines.js +57 -3
  20. package/dist/commands/rules.js +1 -1
  21. package/dist/commands/sessions-picker.d.ts +11 -0
  22. package/dist/commands/sessions-picker.js +16 -0
  23. package/dist/commands/sessions.js +1 -0
  24. package/dist/commands/share.d.ts +14 -0
  25. package/dist/commands/share.js +43 -2
  26. package/dist/commands/ssh.js +24 -14
  27. package/dist/commands/status.js +1 -1
  28. package/dist/commands/sync.js +83 -7
  29. package/dist/commands/traces.js +7 -0
  30. package/dist/commands/trash.d.ts +2 -2
  31. package/dist/commands/trash.js +2 -6
  32. package/dist/commands/versions.d.ts +2 -2
  33. package/dist/commands/versions.js +1 -10
  34. package/dist/commands/view.d.ts +2 -2
  35. package/dist/commands/view.js +7 -6
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.js +14 -0
  38. package/dist/lib/account-registry.d.ts +5 -1
  39. package/dist/lib/account-registry.js +47 -14
  40. package/dist/lib/accounting/capacity.d.ts +18 -7
  41. package/dist/lib/accounting/capacity.js +19 -8
  42. package/dist/lib/accounting/usage-ingest.d.ts +1 -0
  43. package/dist/lib/accounting/usage-ingest.js +75 -0
  44. package/dist/lib/accounting/usage-sync.d.ts +97 -0
  45. package/dist/lib/accounting/usage-sync.js +203 -0
  46. package/dist/lib/accounting/usage.d.ts +48 -2
  47. package/dist/lib/accounting/usage.js +79 -2
  48. package/dist/lib/agent-spec/agents.js +1 -1
  49. package/dist/lib/analytics/mix-commands.d.ts +8 -7
  50. package/dist/lib/analytics/mix-commands.js +50 -73
  51. package/dist/lib/auth-mint.d.ts +11 -1
  52. package/dist/lib/auth-mint.js +21 -6
  53. package/dist/lib/browser/ipc.d.ts +8 -0
  54. package/dist/lib/browser/ipc.js +87 -0
  55. package/dist/lib/browser/service.d.ts +19 -0
  56. package/dist/lib/browser/service.js +96 -11
  57. package/dist/lib/browser/sessions-list.js +10 -1
  58. package/dist/lib/daemon/daemon.js +5 -0
  59. package/dist/lib/daemon/runner.d.ts +3 -0
  60. package/dist/lib/daemon/runner.js +95 -53
  61. package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
  62. package/dist/lib/daemon/usage-sync-service.js +42 -0
  63. package/dist/lib/daemon-services.d.ts +1 -1
  64. package/dist/lib/daemon-services.js +5 -0
  65. package/dist/lib/device-config.d.ts +17 -6
  66. package/dist/lib/device-config.js +25 -11
  67. package/dist/lib/devices/connect.d.ts +17 -8
  68. package/dist/lib/devices/connect.js +31 -14
  69. package/dist/lib/devices/pool.d.ts +4 -3
  70. package/dist/lib/devices/pool.js +13 -5
  71. package/dist/lib/doctor-diff.js +77 -7
  72. package/dist/lib/exec.d.ts +6 -41
  73. package/dist/lib/exec.js +6 -41
  74. package/dist/lib/fleet/manifest.d.ts +17 -0
  75. package/dist/lib/fleet/manifest.js +26 -0
  76. package/dist/lib/git.d.ts +13 -1
  77. package/dist/lib/git.js +36 -7
  78. package/dist/lib/harness/adapter.d.ts +7 -7
  79. package/dist/lib/harness/adapters/claude.js +3 -2
  80. package/dist/lib/hooks/install.d.ts +27 -11
  81. package/dist/lib/hooks/install.js +42 -17
  82. package/dist/lib/hosts/reconnect.d.ts +52 -203
  83. package/dist/lib/hosts/reconnect.js +64 -284
  84. package/dist/lib/hosts/remote-cmd.d.ts +9 -0
  85. package/dist/lib/hosts/remote-cmd.js +22 -0
  86. package/dist/lib/installations/migrate.d.ts +6 -120
  87. package/dist/lib/installations/migrate.js +27 -259
  88. package/dist/lib/installations/shims.d.ts +13 -95
  89. package/dist/lib/installations/shims.js +22 -139
  90. package/dist/lib/installations/store.js +1 -1
  91. package/dist/lib/installations/versions.d.ts +26 -133
  92. package/dist/lib/installations/versions.js +41 -204
  93. package/dist/lib/perf/db.d.ts +1 -1
  94. package/dist/lib/perf/db.js +1 -1
  95. package/dist/lib/plugins/skills.d.ts +8 -1
  96. package/dist/lib/plugins/skills.js +18 -2
  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/reaper.d.ts +28 -70
  108. package/dist/lib/secrets/reaper.js +30 -85
  109. package/dist/lib/secrets/remote.d.ts +42 -129
  110. package/dist/lib/secrets/remote.js +55 -173
  111. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  112. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  113. package/dist/lib/self-heal/registry.js +2 -0
  114. package/dist/lib/self-heal/types.d.ts +1 -1
  115. package/dist/lib/self-update.d.ts +23 -0
  116. package/dist/lib/self-update.js +50 -0
  117. package/dist/lib/session/active.d.ts +16 -32
  118. package/dist/lib/session/active.js +10 -68
  119. package/dist/lib/session/db.d.ts +24 -36
  120. package/dist/lib/session/db.js +143 -44
  121. package/dist/lib/session/discover.d.ts +6 -58
  122. package/dist/lib/session/discover.js +5 -43
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/parse.d.ts +1 -19
  126. package/dist/lib/session/parse.js +2 -15
  127. package/dist/lib/session/tool-calls.d.ts +43 -1
  128. package/dist/lib/session/tool-calls.js +74 -44
  129. package/dist/lib/session/tool-store.d.ts +33 -2
  130. package/dist/lib/session/tool-store.js +56 -3
  131. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  132. package/dist/lib/staleness/writers/sources.js +2 -1
  133. package/dist/lib/startup/command-registry.d.ts +8 -2
  134. package/dist/lib/startup/command-registry.js +12 -4
  135. package/dist/lib/sync-status.d.ts +22 -0
  136. package/dist/lib/sync-status.js +27 -0
  137. package/dist/lib/sync-umbrella.d.ts +9 -0
  138. package/dist/lib/sync-umbrella.js +21 -2
  139. package/dist/lib/traces/insights.d.ts +47 -14
  140. package/dist/lib/traces/insights.js +92 -21
  141. package/dist/lib/traces/phenotype.d.ts +23 -3
  142. package/dist/lib/traces/phenotype.js +72 -24
  143. package/dist/lib/traces/sync.d.ts +15 -0
  144. package/dist/lib/traces/sync.js +104 -19
  145. package/dist/lib/traces/worker-template.js +154 -1
  146. package/package.json +1 -1
@@ -8,14 +8,7 @@ import { type SessionProvenance } from './provenance.js';
8
8
  import { type DeviceRegistry } from '../devices/registry.js';
9
9
  import { type Presence } from './detached.js';
10
10
  import { type HostLink } from './host-link.js';
11
- /**
12
- * The owner (actor id) to show for a session in `--active`. Prefers the actor
13
- * recorded on the live-attribution source (the pid registry / teammate record),
14
- * but falls back to the durable per-session actor sidecar — written at spawn and,
15
- * unlike the pid entry, NOT overwritten by the SessionStart hook's own by-pid
16
- * write. Without this fallback a real `agents run` shows no owner whenever the
17
- * hook's actor-less entry wins the by-pid file (RUSH-2018 fix).
18
- */
11
+ /** Prefer live actor attribution; the durable sidecar survives actor-less hook rewrites. */
19
12
  export declare function resolveOwner(pidActor: string | null | undefined, sessionId: string | undefined): string | undefined;
20
13
  /**
21
14
  * Per-PID `lsof` probes run bounded and staggered rather than as one parallel
@@ -62,7 +55,7 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
62
55
  export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
63
56
  type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
64
57
  /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
65
- export type BackfillMeta = Pick<SessionMeta, 'version' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness'>;
58
+ export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness'>;
66
59
  export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
67
60
  export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
68
61
  export declare function isRunningLiveSession(s: ActiveSession): boolean;
@@ -208,6 +201,18 @@ export interface ActiveSession {
208
201
  * id (RUSH-2205), never asserted by a source.
209
202
  */
210
203
  version?: string;
204
+ /**
205
+ * Email of the account that produced the session (display-only). Like
206
+ * {@link version}, a running process does not report which account a
207
+ * `--strategy balanced` launch selected, so it is backfilled at render time
208
+ * from the indexed {@link SessionMeta} by session id (PHNX-3184). This is what
209
+ * the AGI EXT status bar renders as the session's account — it reads it off the
210
+ * `sessions watch --json` row instead of spawning a per-tab `agents sessions
211
+ * <id> --device <host> --json` (the 2026-08-25 CPU incident, agi-cli#3019).
212
+ * Never group on this — two orgs can share one email; group on the index's
213
+ * `accountKey`.
214
+ */
215
+ account?: string;
211
216
  /**
212
217
  * Last-activity epoch — the transcript's last write (mtime). Distinct from
213
218
  * {@link startedAtMs} (session START): a session begun 3h ago but last touched
@@ -830,18 +835,7 @@ export declare function sessionProcessIsLocal(s: Pick<ActiveSession, 'machine' |
830
835
  */
831
836
  export declare function sessionProcessHost(s: Pick<ActiveSession, 'machine' | 'offloadedFrom'>, self: string): string | undefined;
832
837
  export declare function foldHostLink(rows: ActiveSession[]): void;
833
- /**
834
- * The recap ladder (RUSH-3011): compute a row's shown {@link ActiveSession.title}
835
- * + {@link RecapSource} from the best available source, plus the cleaned first
836
- * prompt (`userPromptClean`/`userPromptKind`) and the `lastAgentLine`. Pure over
837
- * one row; exported for tests and folded in by {@link foldRecap}.
838
- *
839
- * Ladder, best-first: a `/rename`/harness `label` → the last assistant line →
840
- * the first-prompt topic. The `last` rung is agent-derived, so a session that
841
- * produced work stops showing its stale first prompt as the title. (`topic` is
842
- * the row's already-extracted first line, so image detection here is
843
- * path-based; a pure-attachment turn with no first-line text stays on `prompt`.)
844
- */
838
+ /** Labels win, then the last assistant line, then the first-prompt topic. */
845
839
  export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 'topic' | 'tail'>): {
846
840
  title?: string;
847
841
  recapSource?: RecapSource;
@@ -851,17 +845,7 @@ export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 't
851
845
  };
852
846
  /** Fold the recap ladder onto every row (see {@link deriveSessionRecap}). */
853
847
  export declare function foldRecap(rows: ActiveSession[]): void;
854
- /**
855
- * True when a crash-leaked orphan is genuinely DEAD and should be reaped from the
856
- * reconnectable set rather than shown as resumable forever (RUSH-3011 / issue #3b).
857
- *
858
- * The gate is `abandoned` (no transcript write in {@link ABANDONED_STALE_MS}) AND
859
- * a dead pid — exactly "past the stale threshold whose pid is gone". A live pid
860
- * (an idle-but-unfinished session, the highest-risk state) is NEVER reaped, and
861
- * neither is a recently-`closed`/`crashed` session that just exited (still
862
- * resumable). `pidAlive` absent (a cloud row or an older peer that can't prove
863
- * death) also stays un-reaped — reaping is fail-safe, never a guess.
864
- */
848
+ /** Reap only stale sessions with proven-dead pids; unknown or live processes remain recoverable. */
865
849
  export declare function isReapableOrphan(row: Pick<ActiveSession, 'status' | 'pidAlive'>): boolean;
866
850
  /**
867
851
  * Resolve each teams row's `orchestratorLabel` from the orchestrator's own row,
@@ -47,14 +47,7 @@ import { linearIssueUrl } from './linear.js';
47
47
  import { viewingInLabel } from './viewing-in.js';
48
48
  import { claudeProjectDirName } from '../project-key.js';
49
49
  const execFileAsync = promisify(execFile);
50
- /**
51
- * The owner (actor id) to show for a session in `--active`. Prefers the actor
52
- * recorded on the live-attribution source (the pid registry / teammate record),
53
- * but falls back to the durable per-session actor sidecar — written at spawn and,
54
- * unlike the pid entry, NOT overwritten by the SessionStart hook's own by-pid
55
- * write. Without this fallback a real `agents run` shows no owner whenever the
56
- * hook's actor-less entry wins the by-pid file (RUSH-2018 fix).
57
- */
50
+ /** Prefer live actor attribution; the durable sidecar survives actor-less hook rewrites. */
58
51
  export function resolveOwner(pidActor, sessionId) {
59
52
  return pidActor ?? (sessionId ? readSessionActorRecord(sessionId)?.actor : undefined) ?? undefined;
60
53
  }
@@ -152,6 +145,8 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
152
145
  continue;
153
146
  if (!s.version && m.version)
154
147
  s.version = m.version;
148
+ if (!s.account && m.account)
149
+ s.account = m.account;
155
150
  if (!s.label && m.label)
156
151
  s.label = m.label;
157
152
  if (!s.ticket && m.ticketId)
@@ -476,18 +471,8 @@ export function isPidAlive(pid, startedAtMs) {
476
471
  return true;
477
472
  }
478
473
  /**
479
- * Read the live-terminals registry, dedupe by sessionId.
480
- *
481
- * A pid-alive entry is a live session. A pid-DEAD entry is normally noise — a
482
- * terminal that closed a moment ago, before its window republished — and is
483
- * dropped. But a dead pid whose owning window ALSO stopped republishing is the
484
- * signature of a crash: the window went down hard and never ran the teardown that
485
- * would have removed this entry. Those are KEPT, so the session reaches the
486
- * listing at all — it used to vanish outright, a VS Code crash simply erasing its
487
- * agents from `--active`. Such a row arrives as `closed` (dead pid) carrying the
488
- * stale `windowHeartbeatMs`, which is what {@link foldHostLink} promotes to
489
- * `crashed`. `pidDead` is local to the dedupe below: a live entry must win a dead
490
- * one for the same session.
474
+ * Keep dead entries only when their window heartbeat also stopped, proving a crash;
475
+ * a live duplicate always wins.
491
476
  */
492
477
  function readLiveTerminals() {
493
478
  let raw;
@@ -541,20 +526,8 @@ function readLiveTerminals() {
541
526
  const CLAUDE_SESSION_FILE_CACHE_MAX = 256;
542
527
  const claudeSessionFileCache = new Map();
543
528
  /**
544
- * Locate the active Claude session file for a process. If we know the session
545
- * UUID (from terminal env or team parent), prefer the exact match. Otherwise
546
- * fall back to the most-recent-mtime .jsonl in the project's folder.
547
- *
548
- * Searches EVERY version-home project root, not just the live `~/.claude`
549
- * symlink. `~/.claude` points at the currently-installed agent version; a
550
- * session launched under an EARLIER version keeps its transcript under that
551
- * version's home (`…/.history/versions/claude/<ver>/home/.claude/projects/`).
552
- * Resolving only `~/.claude/projects` meant that the instant a newer version
553
- * was installed, every still-running older-version session lost its transcript
554
- * here — no `sessionFile`, so no start/activity time, so `agents sessions`
555
- * rendered it `unknown` and the watchdog skipped it as "no activity timestamp".
556
- * `getAgentSessionDirs('claude','projects')` is the same version-aware enumerator
557
- * the rest of the CLI uses, so this stays in lockstep with discovery.
529
+ * Search every version home because the live ~/.claude symlink moves after upgrades
530
+ * while older running sessions keep writing to their original home.
558
531
  */
559
532
  function findClaudeSessionFile(cwd, sessionId) {
560
533
  // Only memoize when the exact session UUID is known. Without an id the
@@ -2080,17 +2053,7 @@ export function foldHostLink(rows) {
2080
2053
  }
2081
2054
  if (link === 'host-gone' && s.status === 'closed')
2082
2055
  s.status = 'crashed';
2083
- // Only idle/input_required are promoted — a `running` session keeps its
2084
- // status (SES-18a). Extending this to a running agent with no client was
2085
- // tried and reverted: since RUSH-3125 wraps every remote interactive run in
2086
- // a detached tmux pane, "running with zero attached clients" is the NORMAL
2087
- // steady state between check-ins, and that path writes no detach record, so
2088
- // `deliberatelyDetached` is false for it. Promoting it would relabel every
2089
- // remote agent as orphaned whenever nobody is looking — the over-reporting
2090
- // this file's header calls worthless. Telling a stranded agent from a
2091
- // healthy unattended one needs to know a client was EXPECTED and LOST, which
2092
- // no signal available here carries; that belongs with the peer-side pane
2093
- // ownership work, not this function.
2056
+ // A clientless running remote pane is normal; only stopped work can be called orphaned.
2094
2057
  else if (link === 'no-client' && (s.status === 'idle' || s.status === 'input_required')) {
2095
2058
  s.status = 'orphaned';
2096
2059
  }
@@ -2103,18 +2066,7 @@ function recapLine(s, max = 120) {
2103
2066
  return undefined;
2104
2067
  return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t;
2105
2068
  }
2106
- /**
2107
- * The recap ladder (RUSH-3011): compute a row's shown {@link ActiveSession.title}
2108
- * + {@link RecapSource} from the best available source, plus the cleaned first
2109
- * prompt (`userPromptClean`/`userPromptKind`) and the `lastAgentLine`. Pure over
2110
- * one row; exported for tests and folded in by {@link foldRecap}.
2111
- *
2112
- * Ladder, best-first: a `/rename`/harness `label` → the last assistant line →
2113
- * the first-prompt topic. The `last` rung is agent-derived, so a session that
2114
- * produced work stops showing its stale first prompt as the title. (`topic` is
2115
- * the row's already-extracted first line, so image detection here is
2116
- * path-based; a pure-attachment turn with no first-line text stays on `prompt`.)
2117
- */
2069
+ /** Labels win, then the last assistant line, then the first-prompt topic. */
2118
2070
  export function deriveSessionRecap(row) {
2119
2071
  const lastAgentLine = recapLine(row.tail?.length ? row.tail[row.tail.length - 1] : undefined);
2120
2072
  const { clean: userPromptClean, kind: userPromptKind } = classifyUserPrompt(row.topic ?? '');
@@ -2147,17 +2099,7 @@ export function foldRecap(rows) {
2147
2099
  s.lastAgentLine = recap.lastAgentLine;
2148
2100
  }
2149
2101
  }
2150
- /**
2151
- * True when a crash-leaked orphan is genuinely DEAD and should be reaped from the
2152
- * reconnectable set rather than shown as resumable forever (RUSH-3011 / issue #3b).
2153
- *
2154
- * The gate is `abandoned` (no transcript write in {@link ABANDONED_STALE_MS}) AND
2155
- * a dead pid — exactly "past the stale threshold whose pid is gone". A live pid
2156
- * (an idle-but-unfinished session, the highest-risk state) is NEVER reaped, and
2157
- * neither is a recently-`closed`/`crashed` session that just exited (still
2158
- * resumable). `pidAlive` absent (a cloud row or an older peer that can't prove
2159
- * death) also stays un-reaped — reaping is fail-safe, never a guess.
2160
- */
2102
+ /** Reap only stale sessions with proven-dead pids; unknown or live processes remain recoverable. */
2161
2103
  export function isReapableOrphan(row) {
2162
2104
  return row.status === 'abandoned' && row.pidAlive === false;
2163
2105
  }
@@ -9,10 +9,11 @@
9
9
  import Database from '../sqlite.js';
10
10
  import type { SessionAgentId, SessionEvent, SessionMeta } from './types.js';
11
11
  import { type IndexedToolCall } from './tool-calls.js';
12
+ import { type ToolScanResumePoint } from './tool-store.js';
12
13
  /** Current schema version; bumped when migrations are added. Exported so tests
13
14
  * assert against the constant instead of hardcoding a number that every bump
14
15
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
15
- export declare const SCHEMA_VERSION = 42;
16
+ export declare const SCHEMA_VERSION = 43;
16
17
  /**
17
18
  * Bump to force the content extractor (assistant-answer text, alongside the
18
19
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -35,6 +36,8 @@ export declare const CONTENT_INDEX_VERSION = 1;
35
36
  export declare const INSIGHTS_EXTRACTOR_VERSION = 7;
36
37
  /** Bump when classifyTopic's output changes so cached topics recompute (human task taxonomy v2). */
37
38
  export declare const SESSION_TOPIC_EXTRACTOR_VERSION = 2;
39
+ /** Bump when classifyPhenotype's output changes so cached phenotypes recompute (PHNX-3327 v1). */
40
+ export declare const SESSION_PHENOTYPE_EXTRACTOR_VERSION = 1;
38
41
  /** File stat snapshot used to detect changes between scan runs. */
39
42
  export interface ScanStamp {
40
43
  fileMtimeMs: number;
@@ -249,6 +252,13 @@ export declare function upsertSessionsBatch(entries: Array<{
249
252
  toolCalls?: IndexedToolCall[];
250
253
  toolScan?: ScanStamp;
251
254
  toolIndexMode?: 'replace' | 'append';
255
+ /**
256
+ * Where the NEXT scan of this full-file-harness session may resume its tool
257
+ * index (PHNX-3411). Computed internally in the enrichment map below; not
258
+ * supplied by callers. Persisted alongside the tool ledger so an active
259
+ * session re-derives only its newly appended tool calls next tick.
260
+ */
261
+ toolResume?: ToolScanResumePoint | null;
252
262
  }>): void;
253
263
  /**
254
264
  * Sync labels for a set of sessions. For each id in the map, if the stored
@@ -377,6 +387,15 @@ export declare function writeSessionTopics<T>(entries: Array<{
377
387
  fileSize: number | null;
378
388
  topic: T;
379
389
  }>): void;
390
+ /** Read cached failure phenotypes only when their transcript byte stamps still match. */
391
+ export declare function readSessionPhenotypes<T>(ids: string[]): Map<string, T>;
392
+ /** Persist failure phenotypes against the exact transcript bytes used to classify them. */
393
+ export declare function writeSessionPhenotypes<T>(entries: Array<{
394
+ id: string;
395
+ fileMtimeMs: number | null;
396
+ fileSize: number | null;
397
+ phenotype: T;
398
+ }>): void;
380
399
  /** Read one derived preview only when it matches the transcript bytes on disk. */
381
400
  export declare function readSessionPreviewCache<T>(id: string, sourceStamp: {
382
401
  fileMtimeMs: number | null;
@@ -567,46 +586,15 @@ interface TopCostSession {
567
586
  * vanished, mirroring querySessions' liveness filter.
568
587
  */
569
588
  export declare function topSessionsByCost(n: number, options?: QueryOptions): TopCostSession[];
570
- /** Look up a single session by its unique ID. */
571
589
  /**
572
- * Batch-resolve session ids to the machine each one runs on, in ONE indexed
573
- * query. `getActiveSessions` needs only this column for every live row, and
574
- * `getSessionById` would re-`prepare` a `SELECT *` and materialize a full
575
- * `SessionMeta` per id to read it — mirrors {@link findSessionsByShortIds}'s
576
- * single-round-trip pattern. Ids absent from the index are simply absent from
577
- * the map. Best-effort: an unavailable DB yields an empty map, so the live view
578
- * still renders (the caller then leaves rows attributed to this box).
590
+ * Read machine attribution in batches without materializing full sessions.
591
+ * Failure is best-effort so the live view can still render local attribution.
579
592
  */
580
593
  export declare function findSessionMachinesByIds(ids: string[]): Map<string, string>;
581
594
  export declare function getSessionById(id: string): SessionMeta | null;
582
- /**
583
- * Resolve a full-or-partial session id against the index, exact-first then
584
- * prefix — the DB-backed equivalent of resolveSessionById() that runs over the
585
- * SQLite table instead of a pre-loaded array. Matches both the full id and the
586
- * short id. An exact hit short-circuits so a complete id never also drags in its
587
- * prefix siblings. `scope` narrows by agent / version / project (cwd) so an
588
- * ambiguous prefix disambiguates against the caller's context.
589
- *
590
- * Routes through the full querySessions existence check (NOT skipExistenceCheck)
591
- * on purpose (RUSH-2436): that check now KEEPS a file-gone session whose user
592
- * turns still live in session_text (flagged archived) and only suppresses a
593
- * contentless phantom — so `agents sessions <id>` resolves an archived session
594
- * instead of failing with "No session found", while a phantom id still misses.
595
- */
595
+ /** Exact ids win over prefixes; the normal existence check preserves archived content but excludes phantoms. */
596
596
  export declare function findSessionsById(idQuery: string, scope?: Pick<QueryOptions, 'agent' | 'version' | 'cwd' | 'project'>): SessionMeta[];
597
- /**
598
- * Batch-resolve many 8-char short ids to their sessions in ONE indexed query.
599
- * The live-scan path (listTmuxAgentSessions) turns every `ag-<agent>-<shortid>`
600
- * tmux pane name back into a full session id this way, so it pays a single
601
- * `short_id IN (…)` round-trip per scan instead of N per-pane lookups.
602
- *
603
- * Returns a map keyed by short_id (lowercased). Short ids are the first 8 chars
604
- * of the lowercase session UUID (deriveShortId), so a lowercased `IN` matches and
605
- * still uses idx_sessions_short_id. When several sessions share a short id — only
606
- * time-ordered ids (ULID/UUIDv7) ever collide; random UUIDv4 short ids are unique
607
- * in practice — the most-recently-active one wins (the caller can further
608
- * disambiguate by cwd).
609
- */
597
+ /** Batch-resolve pane short ids; on collision the most recently active session wins. */
610
598
  export declare function findSessionsByShortIds(shortIds: string[]): Map<string, SessionMeta>;
611
599
  /** A single full-text search result with ranking score. */
612
600
  interface FtsHit {
@@ -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 = 43;
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,29 @@ 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
+ }
1155
1208
  }
1156
1209
  /**
1157
1210
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its
@@ -2123,6 +2176,21 @@ export function upsertSessionsBatch(entries) {
2123
2176
  // metadata fall back to exactly one normalized parse here.
2124
2177
  const events = entry.events ?? parseSession(entry.meta.filePath, entry.meta.agent);
2125
2178
  writeResourceUsage(entry.meta.id, events, entry.meta.cwd);
2179
+ // Resume the tool index from the last scan of this append-only stream when
2180
+ // it is safe to (PHNX-3411). A live session's transcript grows every tick,
2181
+ // so a full re-derive re-sanitizes its ENTIRE tool history each time — the
2182
+ // synchronous O(session) work that wedged the daemon event loop for the
2183
+ // 11 non-streaming harnesses. `planEventToolResume` returns the prior
2184
+ // snapshot only when the file is still an append of what was scanned
2185
+ // before; otherwise `prior` is null and this is a full replace from
2186
+ // event 0 (identical index, just re-derived).
2187
+ // No tool stamp (a scanner that carried no scan record) means the resume
2188
+ // point cannot be size-guarded, so full-scan rather than trust a stale
2189
+ // offset. persistToolCalls below also skips a resume-less write.
2190
+ const prior = toolScan
2191
+ ? planEventToolResume(db, entry.meta.id, toolSourcePath, toolScan, events.length)
2192
+ : null;
2193
+ const scanned = scanEventToolCalls(events, prior ?? undefined);
2126
2194
  return {
2127
2195
  ...entry,
2128
2196
  meta: {
@@ -2131,11 +2199,14 @@ export function upsertSessionsBatch(entries) {
2131
2199
  recentDirectoriesTouched: extractRecentDirectoriesTouched(events, entry.meta.cwd),
2132
2200
  ...fanOutCounts(events, entry.meta.agent),
2133
2201
  },
2134
- toolCalls: toolCallsFromEvents(events),
2202
+ // The CHANGED calls only. On a resume these are the newly appended tail
2203
+ // (append-safe upsert); on a full scan they are the whole history.
2204
+ toolCalls: scanned.calls,
2135
2205
  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',
2206
+ toolIndexMode: (prior ? 'append' : 'replace'),
2207
+ // Persist where the NEXT scan resumes: the collector snapshot + how many
2208
+ // events this scan folded.
2209
+ toolResume: { parserState: JSON.stringify(scanned.snapshot), parsedOffset: scanned.eventCount },
2139
2210
  };
2140
2211
  }
2141
2212
  catch {
@@ -2284,7 +2355,13 @@ export function upsertSessionsBatch(entries) {
2284
2355
  if (!toolScan || !entry.toolCalls)
2285
2356
  continue;
2286
2357
  try {
2287
- persistToolCalls(db, entry.meta, entry.toolCalls, toolScan, { mode: entry.toolIndexMode ?? 'replace' });
2358
+ // `resume` is set only by the full-file harness path above; claude/codex
2359
+ // pass none, so their tool ledger keeps carrying no event-offset resume
2360
+ // point (their resume rides the content-scan ledger instead) — unchanged.
2361
+ persistToolCalls(db, entry.meta, entry.toolCalls, toolScan, {
2362
+ mode: entry.toolIndexMode ?? 'replace',
2363
+ resume: entry.toolResume,
2364
+ });
2288
2365
  }
2289
2366
  catch {
2290
2367
  // Boundary is intentionally retryable via tool_scan_ledger.
@@ -2950,6 +3027,59 @@ export function writeSessionTopics(entries) {
2950
3027
  }
2951
3028
  })();
2952
3029
  }
3030
+ /** Read cached failure phenotypes only when their transcript byte stamps still match. */
3031
+ export function readSessionPhenotypes(ids) {
3032
+ const db = getDB();
3033
+ const out = new Map();
3034
+ if (ids.length === 0)
3035
+ return out;
3036
+ const CHUNK = 400;
3037
+ for (let i = 0; i < ids.length; i += CHUNK) {
3038
+ const chunk = ids.slice(i, i + CHUNK);
3039
+ const placeholders = chunk.map(() => '?').join(',');
3040
+ const rows = db.prepare(`
3041
+ SELECT sp.session_id AS id, sp.phenotype_json AS phenotypeJson
3042
+ FROM session_phenotypes sp
3043
+ JOIN sessions s ON s.id = sp.session_id
3044
+ WHERE sp.session_id IN (${placeholders})
3045
+ AND sp.extractor_version = ?
3046
+ AND sp.file_mtime_ms IS s.file_mtime_ms
3047
+ AND sp.file_size IS s.file_size
3048
+ `).all(...chunk, SESSION_PHENOTYPE_EXTRACTOR_VERSION);
3049
+ for (const row of rows) {
3050
+ try {
3051
+ out.set(row.id, JSON.parse(row.phenotypeJson));
3052
+ }
3053
+ catch {
3054
+ // Invalid derived cache data is a miss and self-heals on the next write.
3055
+ }
3056
+ }
3057
+ }
3058
+ return out;
3059
+ }
3060
+ /** Persist failure phenotypes against the exact transcript bytes used to classify them. */
3061
+ export function writeSessionPhenotypes(entries) {
3062
+ if (entries.length === 0)
3063
+ return;
3064
+ const db = getDB();
3065
+ const stmt = db.prepare(`
3066
+ INSERT INTO session_phenotypes
3067
+ (session_id, file_mtime_ms, file_size, extractor_version, computed_at, phenotype_json)
3068
+ VALUES (?, ?, ?, ?, ?, ?)
3069
+ ON CONFLICT(session_id) DO UPDATE SET
3070
+ file_mtime_ms = excluded.file_mtime_ms,
3071
+ file_size = excluded.file_size,
3072
+ extractor_version = excluded.extractor_version,
3073
+ computed_at = excluded.computed_at,
3074
+ phenotype_json = excluded.phenotype_json
3075
+ `);
3076
+ const now = Date.now();
3077
+ db.transaction(() => {
3078
+ for (const entry of entries) {
3079
+ stmt.run(entry.id, entry.fileMtimeMs, entry.fileSize, SESSION_PHENOTYPE_EXTRACTOR_VERSION, now, JSON.stringify(entry.phenotype));
3080
+ }
3081
+ })();
3082
+ }
2953
3083
  /** Read one derived preview only when it matches the transcript bytes on disk. */
2954
3084
  export function readSessionPreviewCache(id, sourceStamp) {
2955
3085
  const row = getDB().prepare(`
@@ -3390,15 +3520,9 @@ export function topSessionsByCost(n, options = {}) {
3390
3520
  durationMs: r.duration_ms ?? 0,
3391
3521
  }));
3392
3522
  }
3393
- /** Look up a single session by its unique ID. */
3394
3523
  /**
3395
- * Batch-resolve session ids to the machine each one runs on, in ONE indexed
3396
- * query. `getActiveSessions` needs only this column for every live row, and
3397
- * `getSessionById` would re-`prepare` a `SELECT *` and materialize a full
3398
- * `SessionMeta` per id to read it — mirrors {@link findSessionsByShortIds}'s
3399
- * single-round-trip pattern. Ids absent from the index are simply absent from
3400
- * the map. Best-effort: an unavailable DB yields an empty map, so the live view
3401
- * still renders (the caller then leaves rows attributed to this box).
3524
+ * Read machine attribution in batches without materializing full sessions.
3525
+ * Failure is best-effort so the live view can still render local attribution.
3402
3526
  */
3403
3527
  export function findSessionMachinesByIds(ids) {
3404
3528
  const out = new Map();
@@ -3429,20 +3553,7 @@ export function getSessionById(id) {
3429
3553
  const row = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id);
3430
3554
  return row ? rowToMeta(row) : null;
3431
3555
  }
3432
- /**
3433
- * Resolve a full-or-partial session id against the index, exact-first then
3434
- * prefix — the DB-backed equivalent of resolveSessionById() that runs over the
3435
- * SQLite table instead of a pre-loaded array. Matches both the full id and the
3436
- * short id. An exact hit short-circuits so a complete id never also drags in its
3437
- * prefix siblings. `scope` narrows by agent / version / project (cwd) so an
3438
- * ambiguous prefix disambiguates against the caller's context.
3439
- *
3440
- * Routes through the full querySessions existence check (NOT skipExistenceCheck)
3441
- * on purpose (RUSH-2436): that check now KEEPS a file-gone session whose user
3442
- * turns still live in session_text (flagged archived) and only suppresses a
3443
- * contentless phantom — so `agents sessions <id>` resolves an archived session
3444
- * instead of failing with "No session found", while a phantom id still misses.
3445
- */
3556
+ /** Exact ids win over prefixes; the normal existence check preserves archived content but excludes phantoms. */
3446
3557
  export function findSessionsById(idQuery, scope = {}) {
3447
3558
  const q = idQuery.trim();
3448
3559
  if (!q)
@@ -3452,19 +3563,7 @@ export function findSessionsById(idQuery, scope = {}) {
3452
3563
  return exact;
3453
3564
  return querySessions({ ...scope, idPrefix: q });
3454
3565
  }
3455
- /**
3456
- * Batch-resolve many 8-char short ids to their sessions in ONE indexed query.
3457
- * The live-scan path (listTmuxAgentSessions) turns every `ag-<agent>-<shortid>`
3458
- * tmux pane name back into a full session id this way, so it pays a single
3459
- * `short_id IN (…)` round-trip per scan instead of N per-pane lookups.
3460
- *
3461
- * Returns a map keyed by short_id (lowercased). Short ids are the first 8 chars
3462
- * of the lowercase session UUID (deriveShortId), so a lowercased `IN` matches and
3463
- * still uses idx_sessions_short_id. When several sessions share a short id — only
3464
- * time-ordered ids (ULID/UUIDv7) ever collide; random UUIDv4 short ids are unique
3465
- * in practice — the most-recently-active one wins (the caller can further
3466
- * disambiguate by cwd).
3467
- */
3566
+ /** Batch-resolve pane short ids; on collision the most recently active session wins. */
3468
3567
  export function findSessionsByShortIds(shortIds) {
3469
3568
  const out = new Map();
3470
3569
  const uniq = [...new Set(shortIds.map((s) => s.trim().toLowerCase()).filter(Boolean))];