@phnx-labs/agents-cli 1.22.29 → 1.22.31

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 (82) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +44 -5
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.d.ts +13 -0
  5. package/dist/commands/accounts.js +32 -0
  6. package/dist/commands/daemon.d.ts +18 -0
  7. package/dist/commands/daemon.js +581 -0
  8. package/dist/commands/exec.js +66 -20
  9. package/dist/commands/focus.d.ts +4 -1
  10. package/dist/commands/focus.js +19 -4
  11. package/dist/commands/routines.js +29 -11
  12. package/dist/commands/secrets.d.ts +37 -0
  13. package/dist/commands/secrets.js +86 -105
  14. package/dist/commands/sessions-bookmark.d.ts +20 -0
  15. package/dist/commands/{sessions-favorite.js → sessions-bookmark.js} +42 -42
  16. package/dist/commands/sessions-browser.d.ts +10 -8
  17. package/dist/commands/sessions-browser.js +61 -32
  18. package/dist/commands/sessions-picker.d.ts +33 -1
  19. package/dist/commands/sessions-picker.js +102 -27
  20. package/dist/commands/sessions-stats.js +1 -1
  21. package/dist/commands/sessions.d.ts +21 -8
  22. package/dist/commands/sessions.js +328 -74
  23. package/dist/commands/view.d.ts +11 -0
  24. package/dist/commands/view.js +56 -29
  25. package/dist/index.js +37 -2
  26. package/dist/lib/account-labels.d.ts +24 -0
  27. package/dist/lib/account-labels.js +72 -0
  28. package/dist/lib/agents.d.ts +32 -1
  29. package/dist/lib/agents.js +96 -31
  30. package/dist/lib/daemon-health.d.ts +24 -0
  31. package/dist/lib/daemon-health.js +84 -0
  32. package/dist/lib/daemon-ticks.d.ts +81 -0
  33. package/dist/lib/daemon-ticks.js +190 -0
  34. package/dist/lib/daemon.d.ts +68 -18
  35. package/dist/lib/daemon.js +303 -338
  36. package/dist/lib/device-config.d.ts +10 -0
  37. package/dist/lib/device-config.js +27 -0
  38. package/dist/lib/exec.d.ts +27 -0
  39. package/dist/lib/exec.js +49 -2
  40. package/dist/lib/hosts/dispatch.d.ts +4 -0
  41. package/dist/lib/hosts/dispatch.js +4 -0
  42. package/dist/lib/hosts/remote-cmd.js +1 -0
  43. package/dist/lib/hosts/run-target.d.ts +1 -0
  44. package/dist/lib/hosts/run-target.js +1 -0
  45. package/dist/lib/import.js +7 -6
  46. package/dist/lib/memory-cache.d.ts +19 -0
  47. package/dist/lib/memory-cache.js +31 -0
  48. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  49. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  50. package/dist/lib/migrate.d.ts +1 -1
  51. package/dist/lib/migrate.js +13 -2
  52. package/dist/lib/picker.d.ts +6 -3
  53. package/dist/lib/picker.js +7 -2
  54. package/dist/lib/routine-activation.d.ts +2 -0
  55. package/dist/lib/routine-activation.js +16 -0
  56. package/dist/lib/runner.d.ts +18 -0
  57. package/dist/lib/runner.js +52 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/agent.d.ts +19 -1
  61. package/dist/lib/secrets/agent.js +32 -6
  62. package/dist/lib/secrets/scope.d.ts +3 -3
  63. package/dist/lib/secrets/scope.js +3 -3
  64. package/dist/lib/secrets/session-store.d.ts +0 -4
  65. package/dist/lib/secrets/session-store.js +0 -5
  66. package/dist/lib/session/{favorites.d.ts → bookmarks.d.ts} +15 -15
  67. package/dist/lib/session/{favorites.js → bookmarks.js} +23 -23
  68. package/dist/lib/session/db.d.ts +15 -0
  69. package/dist/lib/session/db.js +90 -15
  70. package/dist/lib/session/discover.js +91 -39
  71. package/dist/lib/session/parse.d.ts +63 -0
  72. package/dist/lib/session/parse.js +165 -20
  73. package/dist/lib/session/session-cache.d.ts +9 -6
  74. package/dist/lib/session/session-cache.js +23 -6
  75. package/dist/lib/shims.js +12 -0
  76. package/dist/lib/startup/command-registry.d.ts +15 -1
  77. package/dist/lib/startup/command-registry.js +49 -0
  78. package/dist/lib/usage-refresh.js +3 -2
  79. package/dist/lib/usage.d.ts +12 -10
  80. package/dist/lib/usage.js +63 -144
  81. package/package.json +4 -1
  82. package/dist/commands/sessions-favorite.d.ts +0 -20
@@ -142,6 +142,27 @@ export function shouldSelfHealForUpgrade(persistent, storeSize, runningVersion,
142
142
  export function shouldTeardownVersionSkewedBroker(realHeldBundles) {
143
143
  return realHeldBundles === 0;
144
144
  }
145
+ /**
146
+ * Whether a version-skewed client may evict the reachable broker at all. The
147
+ * held-bundle gate above is necessary but not sufficient: a broker the always-on
148
+ * daemon is hosting must NEVER be client-evicted, even when it holds zero
149
+ * unlocks. teardownStaleBroker() recognizes only the standalone broker's
150
+ * pidPath() O_EXCL claim (the daemon writes ownerPath(), never pidPath()), so
151
+ * evicting a daemon-hosted broker unlinks its socket WITHOUT stopping the daemon;
152
+ * the daemon then keeps hostedBroker != null and shouldTakeOverBroker() refuses
153
+ * to re-host, orphaning its broker until the daemon restarts while every reader
154
+ * falls onto cold one-off brokers that re-prompt Touch ID — the storm. Deferring
155
+ * is safe: daemon code-version upgrades are handled by postinstall.js restarting
156
+ * it, and agentPing() already gated on PROTOCOL_VERSION, so a code-skewed daemon
157
+ * broker is still wire-compatible. Only when NO daemon owns the broker (churning
158
+ * dev installs with a dead/absent daemon — the case #435's client twin was built
159
+ * for) does the zero-held-bundles teardown apply, exactly as before.
160
+ */
161
+ export function shouldClientEvictSkewedBroker(daemonRunning, realHeldBundles) {
162
+ if (daemonRunning)
163
+ return false;
164
+ return shouldTeardownVersionSkewedBroker(realHeldBundles);
165
+ }
145
166
  function onDarwin() {
146
167
  return process.platform === 'darwin';
147
168
  }
@@ -175,6 +196,10 @@ function agentDir() {
175
196
  function socketPath() {
176
197
  return path.join(agentDir(), 'agent.sock');
177
198
  }
199
+ /** Public accessor for the broker's socket path — `agents daemon status`/`services` reads it for display. */
200
+ export function secretsBrokerSocketPath() {
201
+ return socketPath();
202
+ }
178
203
  function pidPath() {
179
204
  return path.join(agentDir(), 'agent.pid');
180
205
  }
@@ -345,7 +370,7 @@ export function handleAgentRequest(store, req, now = Date.now()) {
345
370
  // the broker is running pre-upgrade code and should be restarted.
346
371
  return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION, cliVersion: getCliVersion() };
347
372
  case 'get': {
348
- // Walk own-harness → global so a `--for` grant wins over a global one and
373
+ // Walk own-harness → global so an `--agent` grant wins over a global one and
349
374
  // an unscoped unlock serves every harness (bundleScopeChain).
350
375
  for (const scope of bundleScopeChain(req.harness)) {
351
376
  const key = scopedBundleKey(req.name, scope);
@@ -1285,10 +1310,6 @@ export async function agentLock(name) {
1285
1310
  const r = await request({ cmd: 'lock', name });
1286
1311
  return r?.ok === true && r.cmd === 'lock' ? r.wiped : 0;
1287
1312
  }
1288
- export async function agentRevoke(leaseId) {
1289
- const r = await request({ cmd: 'revoke', leaseId });
1290
- return r?.ok === true && r.cmd === 'revoke' ? r.wiped : 0;
1291
- }
1292
1313
  /** List currently-unlocked bundles, or [] when no broker is running. The
1293
1314
  * internal `secrets list` metadata-cache entry is filtered out here as well as
1294
1315
  * server-side: during a rollout a NEW client can talk to an OLD broker that
@@ -1334,7 +1355,12 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
1334
1355
  if (ping.reachable) {
1335
1356
  if (ping.cliVersion === undefined || ping.cliVersion === getCliVersionFresh())
1336
1357
  return true;
1337
- if (!shouldTeardownVersionSkewedBroker((await agentStatus()).length))
1358
+ // A reachable but version-skewed broker: tear it down ONLY when no daemon
1359
+ // hosts it and it holds no unlocks. Evicting a daemon-hosted broker orphans
1360
+ // the daemon's socket and starts the Touch ID storm — see
1361
+ // shouldClientEvictSkewedBroker.
1362
+ const { isDaemonRunning } = await import('../daemon.js');
1363
+ if (!shouldClientEvictSkewedBroker(isDaemonRunning(), (await agentStatus()).length))
1338
1364
  return true;
1339
1365
  await teardownStaleBroker();
1340
1366
  }
@@ -4,7 +4,7 @@
4
4
  * (bundles.ts).
5
5
  *
6
6
  * A grant is stored under a scope and read under a scope; the two must agree or
7
- * the bundle is invisible. `agents secrets unlock --for <agent>` exists to NARROW
7
+ * the bundle is invisible. `agents secrets unlock --agent <agent>` exists to NARROW
8
8
  * a grant to one harness, so an unlock without it is global by definition.
9
9
  *
10
10
  * This module deliberately has NO imports: agent.ts and session-store.ts already
@@ -13,14 +13,14 @@
13
13
  * and throw at runtime even though tsc is happy.
14
14
  */
15
15
  /**
16
- * Scope of an unlock that was not narrowed with `--for`: readable by every
16
+ * Scope of an unlock that was not narrowed with `--agent`: readable by every
17
17
  * harness. Not a valid harness name, so it can never collide with one.
18
18
  */
19
19
  export declare const GLOBAL_HARNESS = "*";
20
20
  /**
21
21
  * Scopes a reader consults, most specific first: its own harness, then the global
22
22
  * grant. This is the resolution order of the scoped-grant model — a narrow
23
- * `--for claude` unlock stays claude-only while an unscoped unlock serves
23
+ * `--agent claude` unlock stays claude-only while an unscoped unlock serves
24
24
  * everyone — not a fallback papering over a miss.
25
25
  */
26
26
  export declare function bundleScopeChain(harness: string | undefined): string[];
@@ -4,7 +4,7 @@
4
4
  * (bundles.ts).
5
5
  *
6
6
  * A grant is stored under a scope and read under a scope; the two must agree or
7
- * the bundle is invisible. `agents secrets unlock --for <agent>` exists to NARROW
7
+ * the bundle is invisible. `agents secrets unlock --agent <agent>` exists to NARROW
8
8
  * a grant to one harness, so an unlock without it is global by definition.
9
9
  *
10
10
  * This module deliberately has NO imports: agent.ts and session-store.ts already
@@ -13,14 +13,14 @@
13
13
  * and throw at runtime even though tsc is happy.
14
14
  */
15
15
  /**
16
- * Scope of an unlock that was not narrowed with `--for`: readable by every
16
+ * Scope of an unlock that was not narrowed with `--agent`: readable by every
17
17
  * harness. Not a valid harness name, so it can never collide with one.
18
18
  */
19
19
  export const GLOBAL_HARNESS = '*';
20
20
  /**
21
21
  * Scopes a reader consults, most specific first: its own harness, then the global
22
22
  * grant. This is the resolution order of the scoped-grant model — a narrow
23
- * `--for claude` unlock stays claude-only while an unscoped unlock serves
23
+ * `--agent claude` unlock stays claude-only while an unscoped unlock serves
24
24
  * everyone — not a fallback papering over a miss.
25
25
  */
26
26
  export function bundleScopeChain(harness) {
@@ -93,10 +93,6 @@ export declare function deleteBundleSessions(name: string): void;
93
93
  /** Delete one bundle's session blob and prune it from the index. */
94
94
  export declare function deleteSession(name: string, harness?: string): void;
95
95
  export declare function deleteLeaseSession(leaseId: string): number;
96
- export declare function activeLeaseSessions(now?: number): Array<{
97
- name: string;
98
- lease: SecretLease;
99
- }>;
100
96
  /** Delete every session blob + the index (for `secrets lock --all`). */
101
97
  export declare function deleteAllSessions(): void;
102
98
  /** Rehydrate every unexpired session into `[name, entry]` pairs for the broker to
@@ -208,11 +208,6 @@ export function deleteLeaseSession(leaseId) {
208
208
  }
209
209
  return deleted;
210
210
  }
211
- export function activeLeaseSessions(now = Date.now()) {
212
- return rehydrateSessions(now)
213
- .filter((item) => Boolean(item.entry.lease))
214
- .map(({ name, entry }) => ({ name, lease: entry.lease }));
215
- }
216
211
  /** Delete every session blob + the index (for `secrets lock --all`). */
217
212
  export function deleteAllSessions() {
218
213
  if (!shouldPersist())
@@ -1,39 +1,39 @@
1
1
  /**
2
- * Favorited (starred) sessions — the durable "keep this one handy" mark a human
3
- * puts on a session, deliberately kept OUT of the session index.
2
+ * Bookmarked sessions — the durable "keep this one handy" mark a human puts on a
3
+ * session, deliberately kept OUT of the session index.
4
4
  *
5
5
  * `sessions.db` is a rebuildable CACHE: a reindex or a schema bump throws its
6
- * rows away and re-derives them from the transcripts on disk. A favorite is not
6
+ * rows away and re-derives them from the transcripts on disk. A bookmark is not
7
7
  * derivable from a transcript — it is a human's choice — so a column there would
8
8
  * be silently lost on the next rebuild. It lives in `~/.agents/.history/` instead,
9
9
  * next to the actor sidecars, which is never pruned.
10
10
  *
11
11
  * One flat set of session ids. The id is the transcript's own uuid, so it is
12
12
  * stable and machine-independent — but the file is NOT synced today: session sync
13
- * carries `.history/backups/` (`lib/session/sync/agents.ts`), not this. Favorites
13
+ * carries `.history/backups/` (`lib/session/sync/agents.ts`), not this. Bookmarks
14
14
  * are therefore per-machine; carrying them across the fleet would mean adding
15
15
  * them to the sync manifest, which this does not do.
16
16
  *
17
- * Reads are memoized against the file's mtime — the picker asks `isFavorite` once
17
+ * Reads are memoized against the file's mtime — the picker asks `isBookmarked` once
18
18
  * per rendered row, and re-reading a JSON file per row on every keystroke is the
19
19
  * kind of cost that makes a TUI feel broken.
20
20
  */
21
- export declare function favoritesFilePath(): string;
21
+ export declare function bookmarksFilePath(): string;
22
22
  /** Drop the memoized read. Tests that write the file directly need this; nothing
23
23
  * in the CLI does, because every mutation here refreshes the cache itself. */
24
- export declare function clearFavoritesCache(): void;
24
+ export declare function clearBookmarksCache(): void;
25
25
  /**
26
- * Every favorited session id. Empty (never throws) when the file is absent,
27
- * unreadable, or malformed — a corrupt favorites file must not take down
26
+ * Every bookmarked session id. Empty (never throws) when the file is absent,
27
+ * unreadable, or malformed — a corrupt bookmarks file must not take down
28
28
  * `agents sessions`.
29
29
  */
30
- export declare function listFavorites(): Set<string>;
31
- export declare function isFavorite(sessionId: string | undefined): boolean;
30
+ export declare function listBookmarks(): Set<string>;
31
+ export declare function isBookmarked(sessionId: string | undefined): boolean;
32
32
  /**
33
- * Set (or clear) the favorite mark on a session. Returns the resulting state, so
33
+ * Set (or clear) the bookmark mark on a session. Returns the resulting state, so
34
34
  * a caller can report it without a second read. A no-op write is skipped, which
35
35
  * keeps the file's mtime — and every other process's memoized read — untouched.
36
36
  */
37
- export declare function setFavorite(sessionId: string, on: boolean): boolean;
38
- /** Flip the mark; returns the new state (`true` = now favorited). */
39
- export declare function toggleFavorite(sessionId: string): boolean;
37
+ export declare function setBookmark(sessionId: string, on: boolean): boolean;
38
+ /** Flip the mark; returns the new state (`true` = now bookmarked). */
39
+ export declare function toggleBookmark(sessionId: string): boolean;
@@ -1,28 +1,28 @@
1
1
  /**
2
- * Favorited (starred) sessions — the durable "keep this one handy" mark a human
3
- * puts on a session, deliberately kept OUT of the session index.
2
+ * Bookmarked sessions — the durable "keep this one handy" mark a human puts on a
3
+ * session, deliberately kept OUT of the session index.
4
4
  *
5
5
  * `sessions.db` is a rebuildable CACHE: a reindex or a schema bump throws its
6
- * rows away and re-derives them from the transcripts on disk. A favorite is not
6
+ * rows away and re-derives them from the transcripts on disk. A bookmark is not
7
7
  * derivable from a transcript — it is a human's choice — so a column there would
8
8
  * be silently lost on the next rebuild. It lives in `~/.agents/.history/` instead,
9
9
  * next to the actor sidecars, which is never pruned.
10
10
  *
11
11
  * One flat set of session ids. The id is the transcript's own uuid, so it is
12
12
  * stable and machine-independent — but the file is NOT synced today: session sync
13
- * carries `.history/backups/` (`lib/session/sync/agents.ts`), not this. Favorites
13
+ * carries `.history/backups/` (`lib/session/sync/agents.ts`), not this. Bookmarks
14
14
  * are therefore per-machine; carrying them across the fleet would mean adding
15
15
  * them to the sync manifest, which this does not do.
16
16
  *
17
- * Reads are memoized against the file's mtime — the picker asks `isFavorite` once
17
+ * Reads are memoized against the file's mtime — the picker asks `isBookmarked` once
18
18
  * per rendered row, and re-reading a JSON file per row on every keystroke is the
19
19
  * kind of cost that makes a TUI feel broken.
20
20
  */
21
21
  import fs from 'node:fs';
22
22
  import path from 'node:path';
23
23
  import { getHistoryDir } from '../state.js';
24
- export function favoritesFilePath() {
25
- return path.join(getHistoryDir(), 'favorites.json');
24
+ export function bookmarksFilePath() {
25
+ return path.join(getHistoryDir(), 'bookmarks.json');
26
26
  }
27
27
  /** Memoized parse, invalidated by the file's mtime+size (another process — or
28
28
  * another machine's sync — can rewrite it under us). */
@@ -38,16 +38,16 @@ function statKey(file) {
38
38
  }
39
39
  /** Drop the memoized read. Tests that write the file directly need this; nothing
40
40
  * in the CLI does, because every mutation here refreshes the cache itself. */
41
- export function clearFavoritesCache() {
41
+ export function clearBookmarksCache() {
42
42
  cache = null;
43
43
  }
44
44
  /**
45
- * Every favorited session id. Empty (never throws) when the file is absent,
46
- * unreadable, or malformed — a corrupt favorites file must not take down
45
+ * Every bookmarked session id. Empty (never throws) when the file is absent,
46
+ * unreadable, or malformed — a corrupt bookmarks file must not take down
47
47
  * `agents sessions`.
48
48
  */
49
- export function listFavorites() {
50
- const file = favoritesFilePath();
49
+ export function listBookmarks() {
50
+ const file = bookmarksFilePath();
51
51
  const key = statKey(file);
52
52
  if (cache && cache.key === key)
53
53
  return cache.ids;
@@ -64,14 +64,14 @@ export function listFavorites() {
64
64
  cache = { key, ids };
65
65
  return ids;
66
66
  }
67
- export function isFavorite(sessionId) {
67
+ export function isBookmarked(sessionId) {
68
68
  if (!sessionId)
69
69
  return false;
70
- return listFavorites().has(sessionId);
70
+ return listBookmarks().has(sessionId);
71
71
  }
72
72
  /** Atomic write (tmp + rename) so a concurrent reader never sees a half file. */
73
- function writeFavorites(ids) {
74
- const file = favoritesFilePath();
73
+ function writeBookmarks(ids) {
74
+ const file = bookmarksFilePath();
75
75
  fs.mkdirSync(path.dirname(file), { recursive: true });
76
76
  const body = { version: 1, sessionIds: [...ids].sort() };
77
77
  const tmp = `${file}.${process.pid}.tmp`;
@@ -80,22 +80,22 @@ function writeFavorites(ids) {
80
80
  cache = { key: statKey(file), ids };
81
81
  }
82
82
  /**
83
- * Set (or clear) the favorite mark on a session. Returns the resulting state, so
83
+ * Set (or clear) the bookmark mark on a session. Returns the resulting state, so
84
84
  * a caller can report it without a second read. A no-op write is skipped, which
85
85
  * keeps the file's mtime — and every other process's memoized read — untouched.
86
86
  */
87
- export function setFavorite(sessionId, on) {
88
- const ids = new Set(listFavorites());
87
+ export function setBookmark(sessionId, on) {
88
+ const ids = new Set(listBookmarks());
89
89
  if (ids.has(sessionId) === on)
90
90
  return on;
91
91
  if (on)
92
92
  ids.add(sessionId);
93
93
  else
94
94
  ids.delete(sessionId);
95
- writeFavorites(ids);
95
+ writeBookmarks(ids);
96
96
  return on;
97
97
  }
98
- /** Flip the mark; returns the new state (`true` = now favorited). */
99
- export function toggleFavorite(sessionId) {
100
- return setFavorite(sessionId, !isFavorite(sessionId));
98
+ /** Flip the mark; returns the new state (`true` = now bookmarked). */
99
+ export function toggleBookmark(sessionId) {
100
+ return setBookmark(sessionId, !isBookmarked(sessionId));
101
101
  }
@@ -28,6 +28,7 @@ export declare const RESOURCE_INDEX_VERSION = 1;
28
28
  */
29
29
  /** Bump when facet extraction changes so cached rows recompute (stalls-by-model v6). */
30
30
  export declare const INSIGHTS_EXTRACTOR_VERSION = 6;
31
+ export declare const PREVIEW_EXTRACTOR_VERSION = 1;
31
32
  /** Raw row shape returned from the sessions table. */
32
33
  export interface SessionRow {
33
34
  id: string;
@@ -390,6 +391,20 @@ export declare function writeSessionInsights<T>(entries: Array<{
390
391
  }>): void;
391
392
  /** Drop every cached facet row. Backs `agents insights --refresh`. */
392
393
  export declare function clearSessionInsights(): void;
394
+ /** Read one derived preview only when it matches the transcript bytes on disk. */
395
+ export declare function readSessionPreviewCache<T>(id: string, sourceStamp: {
396
+ fileMtimeMs: number | null;
397
+ fileSize: number | null;
398
+ }): T | undefined;
399
+ /** Persist normalized preview data against the exact transcript bytes parsed. */
400
+ export declare function writeSessionPreviewCache<T>(entry: {
401
+ id: string;
402
+ fileMtimeMs: number | null;
403
+ fileSize: number | null;
404
+ preview: T;
405
+ }): void;
406
+ /** Plugin provenance already indexed for resources used by one session. */
407
+ export declare function getSessionPlugins(id: string): string[];
393
408
  export type UsageRollupGroup = 'agent' | 'project' | 'day' | 'account';
394
409
  /**
395
410
  * Smart-launch affinity priors: group sessions by origin machine, harness, or
@@ -9,7 +9,7 @@
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import Database from '../sqlite.js';
12
- import { parseSession } from './parse.js';
12
+ import { parseSession, sessionFilePathContainer } from './parse.js';
13
13
  import { extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
14
14
  import { getSessionsDir, getSessionsDbPath } from '../state.js';
15
15
  import { query as queryEvents, queryToolUsageForSessions } from '../events.js';
@@ -305,6 +305,18 @@ CREATE TABLE IF NOT EXISTS session_insights (
305
305
  computed_at INTEGER NOT NULL,
306
306
  facets TEXT NOT NULL
307
307
  );
308
+
309
+ -- Normalized data behind sessions preview. Like session_insights this is a
310
+ -- lazy, stamp-validated cache: opening one session parses only that transcript,
311
+ -- while subsequent processes reuse the derived preview until its bytes change.
312
+ CREATE TABLE IF NOT EXISTS session_preview_cache (
313
+ session_id TEXT PRIMARY KEY,
314
+ file_mtime_ms INTEGER,
315
+ file_size INTEGER,
316
+ extractor_version INTEGER NOT NULL,
317
+ computed_at INTEGER NOT NULL,
318
+ preview_json TEXT NOT NULL
319
+ );
308
320
  `;
309
321
  /**
310
322
  * Bumping this invalidates every cached facet row without touching the schema
@@ -314,6 +326,7 @@ CREATE TABLE IF NOT EXISTS session_insights (
314
326
  */
315
327
  /** Bump when facet extraction changes so cached rows recompute (stalls-by-model v6). */
316
328
  export const INSIGHTS_EXTRACTOR_VERSION = 6;
329
+ export const PREVIEW_EXTRACTOR_VERSION = 1;
317
330
  let dbInstance = null;
318
331
  /**
319
332
  * Apply schema migrations from `fromVersion` → SCHEMA_VERSION. The new
@@ -2291,18 +2304,36 @@ function clearSessionExistenceCache() {
2291
2304
  directoryMembershipSweepCount = 0;
2292
2305
  }
2293
2306
  function findMissingFilePaths(filePaths) {
2307
+ // Existence is decided on the CONTAINER file, never the raw stored path. A
2308
+ // composite `file_path` (`<container>#<id>`, e.g. OpenCode's `opencode.db#ses_…`)
2309
+ // names a row INSIDE a shared file — its basename is never a directory entry,
2310
+ // so a dirname/basename membership check on the composite string classified
2311
+ // every such row as deleted and pruned it (RUSH-2357). Group the original
2312
+ // paths under the container we actually stat, and map the verdict back so the
2313
+ // returned set still holds the original `file_path` strings the caller keys on.
2294
2314
  const byDir = new Map();
2295
2315
  for (const p of filePaths) {
2296
- const dir = path.dirname(p);
2297
- let basenames = byDir.get(dir);
2298
- if (!basenames) {
2299
- basenames = new Set();
2300
- byDir.set(dir, basenames);
2316
+ const container = sessionFilePathContainer(p);
2317
+ const dir = path.dirname(container);
2318
+ const base = path.basename(container);
2319
+ let bases = byDir.get(dir);
2320
+ if (!bases) {
2321
+ bases = new Map();
2322
+ byDir.set(dir, bases);
2301
2323
  }
2302
- basenames.add(path.basename(p));
2324
+ let originals = bases.get(base);
2325
+ if (!originals) {
2326
+ originals = [];
2327
+ bases.set(base, originals);
2328
+ }
2329
+ originals.push(p);
2303
2330
  }
2304
2331
  const missing = new Set();
2305
- for (const [dir, basenames] of byDir) {
2332
+ const markMissing = (originals) => {
2333
+ for (const original of originals)
2334
+ missing.add(original);
2335
+ };
2336
+ for (const [dir, bases] of byDir) {
2306
2337
  let entries;
2307
2338
  try {
2308
2339
  const stat = fs.statSync(dir);
@@ -2324,17 +2355,16 @@ function findMissingFilePaths(filePaths) {
2324
2355
  // Directory itself is gone (or unreadable) — every file in it is missing.
2325
2356
  // Also covers the race where readdir loses to a concurrent delete: fall
2326
2357
  // back to a direct stat rather than assuming existence.
2327
- for (const base of basenames) {
2358
+ for (const [base, originals] of bases) {
2328
2359
  const filePath = path.join(dir, base);
2329
2360
  if (!fs.existsSync(filePath))
2330
- missing.add(filePath);
2361
+ markMissing(originals);
2331
2362
  }
2332
2363
  continue;
2333
2364
  }
2334
- for (const base of basenames) {
2335
- const filePath = path.join(dir, base);
2365
+ for (const [base, originals] of bases) {
2336
2366
  if (!entries.has(base))
2337
- missing.add(filePath);
2367
+ markMissing(originals);
2338
2368
  }
2339
2369
  }
2340
2370
  return missing;
@@ -2471,6 +2501,49 @@ export function writeSessionInsights(entries) {
2471
2501
  export function clearSessionInsights() {
2472
2502
  getDB().exec(`DELETE FROM session_insights`);
2473
2503
  }
2504
+ /** Read one derived preview only when it matches the transcript bytes on disk. */
2505
+ export function readSessionPreviewCache(id, sourceStamp) {
2506
+ const row = getDB().prepare(`
2507
+ SELECT pc.preview_json AS previewJson
2508
+ FROM session_preview_cache pc
2509
+ WHERE pc.session_id = ?
2510
+ AND pc.extractor_version = ?
2511
+ AND pc.file_mtime_ms IS ?
2512
+ AND pc.file_size IS ?
2513
+ `).get(id, PREVIEW_EXTRACTOR_VERSION, sourceStamp.fileMtimeMs, sourceStamp.fileSize);
2514
+ if (!row)
2515
+ return undefined;
2516
+ try {
2517
+ return JSON.parse(row.previewJson);
2518
+ }
2519
+ catch {
2520
+ return undefined;
2521
+ }
2522
+ }
2523
+ /** Persist normalized preview data against the exact transcript bytes parsed. */
2524
+ export function writeSessionPreviewCache(entry) {
2525
+ getDB().prepare(`
2526
+ INSERT INTO session_preview_cache
2527
+ (session_id, file_mtime_ms, file_size, extractor_version, computed_at, preview_json)
2528
+ VALUES (?, ?, ?, ?, ?, ?)
2529
+ ON CONFLICT(session_id) DO UPDATE SET
2530
+ file_mtime_ms = excluded.file_mtime_ms,
2531
+ file_size = excluded.file_size,
2532
+ extractor_version = excluded.extractor_version,
2533
+ computed_at = excluded.computed_at,
2534
+ preview_json = excluded.preview_json
2535
+ `).run(entry.id, entry.fileMtimeMs, entry.fileSize, PREVIEW_EXTRACTOR_VERSION, Date.now(), JSON.stringify(entry.preview));
2536
+ }
2537
+ /** Plugin provenance already indexed for resources used by one session. */
2538
+ export function getSessionPlugins(id) {
2539
+ const rows = getDB().prepare(`
2540
+ SELECT DISTINCT plugin
2541
+ FROM session_resource_usage
2542
+ WHERE session_id = ? AND plugin IS NOT NULL AND plugin <> ''
2543
+ ORDER BY plugin COLLATE NOCASE
2544
+ `).all(id);
2545
+ return rows.map(row => row.plugin);
2546
+ }
2474
2547
  export function queryAffinityRollup(options) {
2475
2548
  const db = getDB();
2476
2549
  const where = [];
@@ -2708,7 +2781,9 @@ export function backfillResourceUsage(filter = {}, onProgress) {
2708
2781
  result.scanned++;
2709
2782
  let stamp;
2710
2783
  try {
2711
- const st = fs.statSync(meta.filePath);
2784
+ // Composite rows (`<container>#<id>`) stat their container file — the
2785
+ // per-session bytes/mtime stamp is derived during discovery, not here.
2786
+ const st = fs.statSync(sessionFilePathContainer(meta.filePath));
2712
2787
  stamp = { fileMtimeMs: st.mtimeMs, fileSize: st.size };
2713
2788
  }
2714
2789
  catch {
@@ -2787,7 +2862,7 @@ export function topSessionsByCost(n, options = {}) {
2787
2862
  // Over-fetch a small buffer to survive the on-disk liveness filter below.
2788
2863
  const sql = `SELECT * FROM sessions ${whereCost} ORDER BY cost_usd DESC, timestamp DESC LIMIT ${limit + 16}`;
2789
2864
  const rows = db.prepare(sql).all(...params);
2790
- const live = rows.filter(r => !r.file_path || fs.existsSync(r.file_path));
2865
+ const live = rows.filter(r => !r.file_path || fs.existsSync(sessionFilePathContainer(r.file_path)));
2791
2866
  return live.slice(0, limit).map(r => ({
2792
2867
  meta: rowToMeta(r),
2793
2868
  costUsd: r.cost_usd ?? 0,