@phnx-labs/agents-cli 1.22.28 → 1.22.30

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 (75) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +39 -1
  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/routines.js +29 -11
  10. package/dist/commands/secrets.d.ts +17 -0
  11. package/dist/commands/secrets.js +30 -15
  12. package/dist/commands/sessions-browser.js +6 -6
  13. package/dist/commands/sessions-favorite.d.ts +7 -7
  14. package/dist/commands/sessions-favorite.js +30 -30
  15. package/dist/commands/sessions-picker.d.ts +33 -1
  16. package/dist/commands/sessions-picker.js +102 -27
  17. package/dist/commands/sessions.d.ts +12 -1
  18. package/dist/commands/sessions.js +259 -20
  19. package/dist/commands/view.d.ts +11 -0
  20. package/dist/commands/view.js +56 -29
  21. package/dist/index.js +37 -2
  22. package/dist/lib/account-labels.d.ts +24 -0
  23. package/dist/lib/account-labels.js +72 -0
  24. package/dist/lib/agents.d.ts +32 -1
  25. package/dist/lib/agents.js +96 -31
  26. package/dist/lib/daemon-health.d.ts +24 -0
  27. package/dist/lib/daemon-health.js +84 -0
  28. package/dist/lib/daemon-ticks.d.ts +81 -0
  29. package/dist/lib/daemon-ticks.js +190 -0
  30. package/dist/lib/daemon.d.ts +68 -18
  31. package/dist/lib/daemon.js +303 -338
  32. package/dist/lib/device-config.d.ts +10 -0
  33. package/dist/lib/device-config.js +27 -0
  34. package/dist/lib/exec.d.ts +27 -0
  35. package/dist/lib/exec.js +49 -2
  36. package/dist/lib/hosts/dispatch.d.ts +4 -0
  37. package/dist/lib/hosts/dispatch.js +4 -0
  38. package/dist/lib/hosts/remote-cmd.js +1 -0
  39. package/dist/lib/hosts/run-target.d.ts +1 -0
  40. package/dist/lib/hosts/run-target.js +1 -0
  41. package/dist/lib/import.js +7 -6
  42. package/dist/lib/memory-cache.d.ts +19 -0
  43. package/dist/lib/memory-cache.js +31 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  46. package/dist/lib/menubar/snapshot.d.ts +16 -0
  47. package/dist/lib/menubar/snapshot.js +22 -1
  48. package/dist/lib/migrate.d.ts +1 -1
  49. package/dist/lib/migrate.js +7 -2
  50. package/dist/lib/routine-activation.d.ts +2 -0
  51. package/dist/lib/routine-activation.js +16 -0
  52. package/dist/lib/runner.d.ts +18 -0
  53. package/dist/lib/runner.js +52 -0
  54. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  55. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  56. package/dist/lib/secrets/agent.d.ts +19 -0
  57. package/dist/lib/secrets/agent.js +32 -2
  58. package/dist/lib/secrets/scope.d.ts +3 -3
  59. package/dist/lib/secrets/scope.js +3 -3
  60. package/dist/lib/session/db.d.ts +15 -0
  61. package/dist/lib/session/db.js +90 -15
  62. package/dist/lib/session/discover.js +91 -39
  63. package/dist/lib/session/favorites.d.ts +2 -2
  64. package/dist/lib/session/favorites.js +2 -2
  65. package/dist/lib/session/parse.d.ts +63 -0
  66. package/dist/lib/session/parse.js +165 -20
  67. package/dist/lib/session/session-cache.d.ts +9 -6
  68. package/dist/lib/session/session-cache.js +23 -6
  69. package/dist/lib/shims.js +12 -0
  70. package/dist/lib/startup/command-registry.d.ts +15 -1
  71. package/dist/lib/startup/command-registry.js +49 -0
  72. package/dist/lib/usage-refresh.js +3 -2
  73. package/dist/lib/usage.d.ts +12 -10
  74. package/dist/lib/usage.js +81 -154
  75. package/package.json +4 -1
@@ -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);
@@ -1334,7 +1359,12 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
1334
1359
  if (ping.reachable) {
1335
1360
  if (ping.cliVersion === undefined || ping.cliVersion === getCliVersionFresh())
1336
1361
  return true;
1337
- if (!shouldTeardownVersionSkewedBroker((await agentStatus()).length))
1362
+ // A reachable but version-skewed broker: tear it down ONLY when no daemon
1363
+ // hosts it and it holds no unlocks. Evicting a daemon-hosted broker orphans
1364
+ // the daemon's socket and starts the Touch ID storm — see
1365
+ // shouldClientEvictSkewedBroker.
1366
+ const { isDaemonRunning } = await import('../daemon.js');
1367
+ if (!shouldClientEvictSkewedBroker(isDaemonRunning(), (await agentStatus()).length))
1338
1368
  return true;
1339
1369
  await teardownStaleBroker();
1340
1370
  }
@@ -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) {
@@ -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,
@@ -18,7 +18,7 @@ import { getAgentsDir, getUserAgentsDir, getHistoryDir, getRunsDir } from '../st
18
18
  import { shortCodexHome } from '../codex-home.js';
19
19
  import { parseTimeFilter } from './relative-time.js';
20
20
  const execFileAsync = promisify(execFile);
21
- import { AGENTS, agentConfigDirName, getCliVersion } from '../agents.js';
21
+ import { AGENTS, agentConfigDirName, getCliVersion, resolveOpenCodeAccountId } from '../agents.js';
22
22
  import { walkForFilesWithStat } from '../fs-walk.js';
23
23
  import { hasCommand } from '../cli-resources.js';
24
24
  import { execFileShellSpec } from '../platform/exec.js';
@@ -28,7 +28,7 @@ import { deriveShortId } from './short-id.js';
28
28
  import { buildClaudeAccountIndex, resolveClaudeAccount } from './claude-accounts.js';
29
29
  import { extractSessionTopic, extractSlashCommandName, extractSlashCommandFromToolInput } from './prompt.js';
30
30
  import { isSkillInvocation, extractSkills, extractSlashCommands } from './highlights.js';
31
- import { parseAntigravity, parseCursor } from './parse.js';
31
+ import { parseAntigravity, parseCursor, splitSessionFilePath } from './parse.js';
32
32
  import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
33
33
  import { costOfUsage, costOfUsageNoCache } from '../pricing/index.js';
34
34
  import { machineForSessionFile } from './origin-machine.js';
@@ -335,6 +335,15 @@ export function isManagedSessionFile(filePath) {
335
335
  // someone's dotfile dir, so scoping must not silently swallow them.
336
336
  if (!filePath || !path.isAbsolute(filePath))
337
337
  return true;
338
+ // A composite file_path (`<container>#<id>`) names a row inside a single shared
339
+ // DB the scanner reads from one fixed location (OpenCode's `opencode.db`) — that
340
+ // store is never a per-install dotfile under a version home, so the managed-vs-
341
+ // unmanaged split does not apply: there is exactly one store, not a "your own"
342
+ // copy to hide. Classifying it as unmanaged hid every OpenCode row from default
343
+ // listings once any agent was managed (RUSH-2357). Keyed off the composite FORM,
344
+ // so any future single-DB harness inherits this.
345
+ if (splitSessionFilePath(filePath).fragment !== undefined)
346
+ return true;
338
347
  const roots = [
339
348
  ...VERSIONS_ROOTS.map((root) => path.join(root, 'versions')),
340
349
  path.join(getHistoryDir(), 'backups'),
@@ -1838,39 +1847,24 @@ function readAntigravityMeta(filePath, currentVersion) {
1838
1847
  const OPENCODE_DB = path.join(HOME, '.local', 'share', 'opencode', 'opencode.db');
1839
1848
  let cachedOpenCodeAccount;
1840
1849
  /**
1841
- * Query the active OpenCode account email from its SQLite database.
1850
+ * The active OpenCode account: the provider ids with a valid credential in
1851
+ * `auth.json`, joined (e.g. `"anthropic+muse-spark"`) — see
1852
+ * `resolveOpenCodeAccountId` in `../agents.js`, the single source of truth
1853
+ * `agents view`/`agents doctor` also read.
1842
1854
  *
1843
- * Takes the scan's already-open handle so a scan opens `opencode.db` exactly
1844
- * once (RUSH-2210); opens its own only when called without one.
1855
+ * OpenCode's `opencode.db` also carries `account`/`account_state`/
1856
+ * `control_account` tables that look like the obvious source, but on a real,
1857
+ * actively-used install (yosemite-s1, 1.16.0, 35 applied migrations) all
1858
+ * three are permanently empty — no migration populates them and no session
1859
+ * has ever written a row. Querying them (the pre-fix behavior here) always
1860
+ * returned undefined, credential or not; `auth.json` is what OpenCode
1861
+ * actually reads at runtime.
1845
1862
  */
1846
- function getOpenCodeAccount(handle) {
1863
+ function getOpenCodeAccount() {
1847
1864
  if (cachedOpenCodeAccount !== undefined)
1848
1865
  return cachedOpenCodeAccount || undefined;
1849
- // Read through the node/bun SQLite wrapper (not the `sqlite3` CLI) so this
1850
- // works on every OS — the CLI is absent on Windows.
1851
- let owned;
1852
- try {
1853
- const db = handle ?? (fs.existsSync(OPENCODE_DB) ? (owned = new Database(OPENCODE_DB)) : undefined);
1854
- if (db) {
1855
- const row = db
1856
- .prepare('SELECT email FROM control_account WHERE active=1 LIMIT 1;')
1857
- .get();
1858
- const out = typeof row?.email === 'string' ? row.email.trim() : '';
1859
- if (out) {
1860
- cachedOpenCodeAccount = out;
1861
- return out;
1862
- }
1863
- }
1864
- }
1865
- catch { /* DB not accessible, sqlite module unavailable, or query failed */ }
1866
- finally {
1867
- try {
1868
- owned?.close();
1869
- }
1870
- catch { /* best-effort close */ }
1871
- }
1872
- cachedOpenCodeAccount = '';
1873
- return undefined;
1866
+ cachedOpenCodeAccount = resolveOpenCodeAccountId(HOME) ?? '';
1867
+ return cachedOpenCodeAccount || undefined;
1874
1868
  }
1875
1869
  /**
1876
1870
  * The per-session ledger stamp for an OpenCode row: the newest write time across
@@ -1945,12 +1939,28 @@ async function scanOpenCodeIncremental() {
1945
1939
  // works on every OS — the CLI is absent on Windows.
1946
1940
  let db;
1947
1941
  try {
1942
+ db = new Database(OPENCODE_DB);
1943
+ // OpenCode's `session` schema varies by version: `cost` and `model` are newer
1944
+ // columns. Probe once and select NULL where absent, so an older opencode.db
1945
+ // still scans instead of throwing "no such column" and dropping every session.
1946
+ const sessionCols = new Set(db.prepare('PRAGMA table_info(session);').all()
1947
+ .map(c => (typeof c.name === 'string' ? c.name : '')));
1948
+ const costExpr = sessionCols.has('cost') ? 's.cost' : 'NULL';
1949
+ const modelExpr = sessionCols.has('model') ? 's.model' : 'NULL';
1950
+ // Every `json_extract` / `json_type` below is guarded by `json_valid`.
1951
+ // SQLite raises "malformed JSON" on a non-JSON value, and that aborts the
1952
+ // WHOLE query — so a single unparseable `part`/`message` row anywhere in the
1953
+ // shared DB would drop EVERY OpenCode session from the index, silently in a
1954
+ // non-TTY run (the handler below only prints when stderr is a TTY). A
1955
+ // poisoned row must cost that row, not the harness.
1948
1956
  const query = `
1949
1957
  SELECT
1950
1958
  s.id AS id,
1951
1959
  s.title AS title,
1952
1960
  s.directory AS directory,
1953
1961
  s.version AS version,
1962
+ ${costExpr} AS cost,
1963
+ ${modelExpr} AS model,
1954
1964
  s.time_created AS time_created,
1955
1965
  s.time_updated AS time_updated,
1956
1966
  COALESCE(stats.message_count, 0) AS message_count,
@@ -1958,15 +1968,20 @@ async function scanOpenCodeIncremental() {
1958
1968
  COALESCE(stats.message_bytes, 0) AS message_bytes,
1959
1969
  COALESCE(parts.last_part_at, 0) AS last_part_at,
1960
1970
  COALESCE(parts.part_bytes, 0) AS part_bytes,
1971
+ COALESCE(parts.tool_call_count, 0) AS tool_call_count,
1961
1972
  stats.token_count AS token_count,
1962
1973
  stats.output_tokens AS output_tokens,
1974
+ stats.input_tokens AS input_tokens,
1975
+ stats.cache_read_tokens AS cache_read_tokens,
1976
+ stats.cache_write_tokens AS cache_write_tokens,
1963
1977
  COALESCE(stats.has_token_data, 0) AS has_token_data
1964
1978
  FROM session s
1965
1979
  LEFT JOIN (
1966
1980
  SELECT
1967
1981
  session_id,
1968
1982
  MAX(time_created) AS last_part_at,
1969
- SUM(LENGTH(CAST(data AS BLOB))) AS part_bytes
1983
+ SUM(LENGTH(CAST(data AS BLOB))) AS part_bytes,
1984
+ SUM(CASE WHEN json_valid(data) AND json_extract(data, '$.type') = 'tool' THEN 1 ELSE 0 END) AS tool_call_count
1970
1985
  FROM part
1971
1986
  GROUP BY session_id
1972
1987
  ) parts ON parts.session_id = s.id
@@ -1976,15 +1991,18 @@ async function scanOpenCodeIncremental() {
1976
1991
  COUNT(*) AS message_count,
1977
1992
  MAX(time_created) AS last_message_at,
1978
1993
  SUM(LENGTH(CAST(data AS BLOB))) AS message_bytes,
1979
- SUM(
1994
+ SUM(CASE WHEN json_valid(data) THEN
1980
1995
  COALESCE(json_extract(data, '$.tokens.input'), 0) +
1981
1996
  COALESCE(json_extract(data, '$.tokens.output'), 0) +
1982
1997
  COALESCE(json_extract(data, '$.tokens.reasoning'), 0) +
1983
1998
  COALESCE(json_extract(data, '$.tokens.cache.read'), 0) +
1984
1999
  COALESCE(json_extract(data, '$.tokens.cache.write'), 0)
1985
- ) AS token_count,
1986
- SUM(COALESCE(json_extract(data, '$.tokens.output'), 0)) AS output_tokens,
1987
- MAX(CASE WHEN json_type(data, '$.tokens') IS NOT NULL THEN 1 ELSE 0 END) AS has_token_data
2000
+ ELSE 0 END) AS token_count,
2001
+ SUM(CASE WHEN json_valid(data) THEN COALESCE(json_extract(data, '$.tokens.output'), 0) ELSE 0 END) AS output_tokens,
2002
+ SUM(CASE WHEN json_valid(data) THEN COALESCE(json_extract(data, '$.tokens.input'), 0) ELSE 0 END) AS input_tokens,
2003
+ SUM(CASE WHEN json_valid(data) THEN COALESCE(json_extract(data, '$.tokens.cache.read'), 0) ELSE 0 END) AS cache_read_tokens,
2004
+ SUM(CASE WHEN json_valid(data) THEN COALESCE(json_extract(data, '$.tokens.cache.write'), 0) ELSE 0 END) AS cache_write_tokens,
2005
+ MAX(CASE WHEN json_valid(data) AND json_type(data, '$.tokens') IS NOT NULL THEN 1 ELSE 0 END) AS has_token_data
1988
2006
  FROM message
1989
2007
  GROUP BY session_id
1990
2008
  ) stats ON stats.session_id = s.id
@@ -1992,8 +2010,7 @@ async function scanOpenCodeIncremental() {
1992
2010
  ORDER BY time_created DESC
1993
2011
  LIMIT 1000;
1994
2012
  `.replace(/\n/g, ' ');
1995
- db = new Database(OPENCODE_DB);
1996
- const account = getOpenCodeAccount(db);
2013
+ const account = getOpenCodeAccount();
1997
2014
  const rows = db.prepare(query).all();
1998
2015
  // Two passes. First derive each row's identity + per-session stamp, then
1999
2016
  // bulk-load the prior stamps in ONE query and keep only the rows that
@@ -2034,13 +2051,40 @@ async function scanOpenCodeIncremental() {
2034
2051
  const messageCount = asInt(row.message_count);
2035
2052
  const tokenCount = asInt(row.token_count);
2036
2053
  const outputTokens = asInt(row.output_tokens);
2054
+ const inputTokens = asInt(row.input_tokens);
2055
+ const cacheReadTokens = asInt(row.cache_read_tokens);
2056
+ const cacheWriteTokens = asInt(row.cache_write_tokens);
2037
2057
  const hasTokenData = asInt(row.has_token_data) === 1;
2058
+ const toolCallCount = asInt(row.tool_call_count);
2038
2059
  const timestamp = isNaN(timeCreated) ? new Date().toISOString() : new Date(timeCreated).toISOString();
2039
2060
  // OpenCode is one shared DB, not one file per session — its row carries a
2040
2061
  // per-session updated time. Set lastActivity explicitly (falling back to
2041
2062
  // creation, never the whole-DB mtime the ScanStamp would otherwise supply).
2042
2063
  const lastActivity = Number.isNaN(timeUpdated) ? timestamp : new Date(timeUpdated).toISOString();
2043
2064
  const topic = title || undefined;
2065
+ // Duration is the session-row span; a missing/degenerate pair yields no value
2066
+ // rather than a negative or NaN.
2067
+ const durationMs = Number.isFinite(timeCreated) && Number.isFinite(timeUpdated) && timeUpdated > timeCreated
2068
+ ? timeUpdated - timeCreated
2069
+ : undefined;
2070
+ // OpenCode stores the model as JSON (`{"id":"…","providerID":"…"}`); the
2071
+ // index tracks the model id.
2072
+ let model;
2073
+ if (typeof row.model === 'string' && row.model.trim()) {
2074
+ try {
2075
+ const parsed = JSON.parse(row.model);
2076
+ if (typeof parsed?.id === 'string' && parsed.id.trim())
2077
+ model = parsed.id;
2078
+ }
2079
+ catch { /* non-JSON model string — leave unset */ }
2080
+ }
2081
+ // `cost` is a REAL rollup OpenCode maintains on the session row (0 for a
2082
+ // zero-priced provider, which is a real value, not "unknown").
2083
+ const costUsd = typeof row.cost === 'number' ? row.cost : undefined;
2084
+ // Worktree slug is a pure function of cwd (`.agents/worktrees/<slug>/`),
2085
+ // so it derives for OpenCode exactly as it does for every other harness.
2086
+ const cwd = directory ? normalizeCwd(directory) : undefined;
2087
+ const worktreeSlug = detectWorktree(cwd)?.slug;
2044
2088
  const meta = {
2045
2089
  id,
2046
2090
  shortId: deriveShortId(id, /^ses_/),
@@ -2048,14 +2092,22 @@ async function scanOpenCodeIncremental() {
2048
2092
  timestamp,
2049
2093
  lastActivity,
2050
2094
  project: directory ? path.basename(directory) : undefined,
2051
- cwd: directory ? normalizeCwd(directory) : undefined,
2095
+ cwd,
2052
2096
  filePath,
2053
2097
  version: resolveSessionVersion('opencode', OPENCODE_DB, version || undefined, currentVersion),
2054
2098
  account,
2055
2099
  topic,
2100
+ model,
2101
+ costUsd,
2102
+ durationMs,
2103
+ worktreeSlug,
2104
+ toolCallCount: Number.isNaN(toolCallCount) ? undefined : toolCallCount,
2056
2105
  messageCount: Number.isNaN(messageCount) ? undefined : messageCount,
2057
2106
  tokenCount: hasTokenData && !Number.isNaN(tokenCount) ? tokenCount : undefined,
2058
2107
  outputTokens: hasTokenData && !Number.isNaN(outputTokens) ? outputTokens : undefined,
2108
+ inputTokens: hasTokenData && !Number.isNaN(inputTokens) ? inputTokens : undefined,
2109
+ cacheReadTokens: hasTokenData && !Number.isNaN(cacheReadTokens) ? cacheReadTokens : undefined,
2110
+ cacheWriteTokens: hasTokenData && !Number.isNaN(cacheWriteTokens) ? cacheWriteTokens : undefined,
2059
2111
  };
2060
2112
  entries.push({ meta, content: topic || '', scan });
2061
2113
  }
@@ -1,6 +1,6 @@
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
+ * Favorited 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
6
  * rows away and re-derives them from the transcripts on disk. A favorite is not
@@ -1,6 +1,6 @@
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
+ * Favorited 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
6
  * rows away and re-derives them from the transcripts on disk. A favorite is not
@@ -103,6 +103,33 @@ export declare function parseGemini(filePath: string): SessionEvent[];
103
103
  * request step and a completion step that share the id).
104
104
  */
105
105
  export declare function parseAntigravity(dbPath: string): SessionEvent[];
106
+ /**
107
+ * Separator between a container file and the id it holds inside a *composite*
108
+ * session `file_path`. Some harnesses keep every session in ONE file (OpenCode's
109
+ * single `opencode.db`), so the index stores `<container>#<session-id>` rather
110
+ * than one path per session — e.g.
111
+ * `/home/u/.local/share/opencode/opencode.db#ses_02410a2c…`.
112
+ */
113
+ export declare const SESSION_FILE_PATH_SEP = "#";
114
+ /**
115
+ * Split a stored session `file_path` into its on-disk container and the optional
116
+ * in-container fragment. For a plain per-session file the container is the path
117
+ * itself and `fragment` is undefined; for a composite path it is the part before
118
+ * the first `#` and the id after it.
119
+ */
120
+ export declare function splitSessionFilePath(filePath: string): {
121
+ container: string;
122
+ fragment: string | undefined;
123
+ };
124
+ /**
125
+ * The filesystem path whose existence/stat decides whether a session row is
126
+ * stale. For a composite `file_path` this is the CONTAINER file — the row is a
127
+ * record inside it, never a filesystem entry of its own — so a composite session
128
+ * is stale only when its container is gone. For a plain path it is the path
129
+ * itself. Keying off the composite FORM (a `#` fragment), not any harness name,
130
+ * means any future single-file-DB harness inherits the correct behavior.
131
+ */
132
+ export declare function sessionFilePathContainer(filePath: string): string;
106
133
  /**
107
134
  * Parse an OpenCode session from its SQLite database.
108
135
  * filePath format: "/path/to/opencode.db#session_id"
@@ -130,6 +157,42 @@ export declare function parseAntigravity(dbPath: string): SessionEvent[];
130
157
  * (from summary.json), falling back to the transcript's mtime.
131
158
  */
132
159
  export declare function parseGrok(filePath: string): SessionEvent[];
160
+ /**
161
+ * The transcript query {@link parseOpenCode} runs, exported so a test can assert
162
+ * the projection's real cost against a database instead of re-typing the SQL.
163
+ *
164
+ * A tool part is PROJECTED to exactly the fields the `case 'tool'` branch below
165
+ * reads — `tool`, `callID`, `state.status`, `state.input`, `state.output` —
166
+ * rather than carried whole. Two things this has to get right at once:
167
+ *
168
+ * - Keep `state.input`. It carries `filePath` / `command`, and losing it is
169
+ * what left `recentDirectoriesTouched` empty (RUSH-2358).
170
+ * - Stay bounded. A tool part is not bounded by its output: on a real
171
+ * `opencode.db` the largest part is 1,346,068 bytes of which
172
+ * `state.attachments` (a base64 data URL from `read`) is 1,345,674, while
173
+ * `state.output` is 23. Truncating only `state.output` therefore bounded
174
+ * nothing — it took one session's loaded tool payload from 299,365 to
175
+ * 1,911,209 bytes. The projection drops `attachments` (and every other
176
+ * unread key) outright, caps `state.output`, and collapses an oversized
177
+ * `state.input` to just its addressing fields. Those are the keys the
178
+ * enrichment reads — `filePath`/`path` for an edit and `cwd`/`workdir`/
179
+ * `working_directory` for a shell (`extractRecentDirectoriesTouched` in
180
+ * state.ts), plus `command`/`description`, themselves capped since a command
181
+ * can be arbitrarily long. Above the cap every other input key (an `edit`'s
182
+ * `oldString`/`newString`) is gone, deliberately — nothing downstream reads
183
+ * them, and they are the weight.
184
+ *
185
+ * `json_valid` guards every `json_extract`: SQLite raises "malformed JSON" on a
186
+ * non-JSON value, which aborts the WHOLE query, so one bad `part` row would
187
+ * otherwise cost the entire transcript. Note the single-argument form is
188
+ * RFC-8259-strict — it rejects a JSONB blob that `json_extract` would accept.
189
+ * That is correct for today's schema (`part.data` / `message.data` are TEXT on
190
+ * a real database); if OpenCode ever migrates them to JSONB, these guards must
191
+ * move to `json_valid(data, 6)` or they will drop every row.
192
+ *
193
+ * The session id is bound as a parameter; the two caps are numeric literals.
194
+ */
195
+ export declare const OPENCODE_TRANSCRIPT_QUERY: string;
133
196
  export declare function parseOpenCode(filePath: string): SessionEvent[];
134
197
  /** Parse a Rush JSONL session file into normalized events. */
135
198
  export declare function parseRush(filePath: string): SessionEvent[];