@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
@@ -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
  }
@@ -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[];
@@ -1125,6 +1125,40 @@ export function parseAntigravity(dbPath) {
1125
1125
  return events;
1126
1126
  }
1127
1127
  // ---------------------------------------------------------------------------
1128
+ // Composite session file paths
1129
+ // ---------------------------------------------------------------------------
1130
+ /**
1131
+ * Separator between a container file and the id it holds inside a *composite*
1132
+ * session `file_path`. Some harnesses keep every session in ONE file (OpenCode's
1133
+ * single `opencode.db`), so the index stores `<container>#<session-id>` rather
1134
+ * than one path per session — e.g.
1135
+ * `/home/u/.local/share/opencode/opencode.db#ses_02410a2c…`.
1136
+ */
1137
+ export const SESSION_FILE_PATH_SEP = '#';
1138
+ /**
1139
+ * Split a stored session `file_path` into its on-disk container and the optional
1140
+ * in-container fragment. For a plain per-session file the container is the path
1141
+ * itself and `fragment` is undefined; for a composite path it is the part before
1142
+ * the first `#` and the id after it.
1143
+ */
1144
+ export function splitSessionFilePath(filePath) {
1145
+ const hash = filePath.indexOf(SESSION_FILE_PATH_SEP);
1146
+ if (hash < 0)
1147
+ return { container: filePath, fragment: undefined };
1148
+ return { container: filePath.slice(0, hash), fragment: filePath.slice(hash + 1) || undefined };
1149
+ }
1150
+ /**
1151
+ * The filesystem path whose existence/stat decides whether a session row is
1152
+ * stale. For a composite `file_path` this is the CONTAINER file — the row is a
1153
+ * record inside it, never a filesystem entry of its own — so a composite session
1154
+ * is stale only when its container is gone. For a plain path it is the path
1155
+ * itself. Keying off the composite FORM (a `#` fragment), not any harness name,
1156
+ * means any future single-file-DB harness inherits the correct behavior.
1157
+ */
1158
+ export function sessionFilePathContainer(filePath) {
1159
+ return splitSessionFilePath(filePath).container;
1160
+ }
1161
+ // ---------------------------------------------------------------------------
1128
1162
  // OpenCode parser
1129
1163
  // ---------------------------------------------------------------------------
1130
1164
  /**
@@ -1279,36 +1313,124 @@ export function parseGrok(filePath) {
1279
1313
  }
1280
1314
  return events;
1281
1315
  }
1316
+ /**
1317
+ * Byte/char caps for the OpenCode tool-part projection below. Numeric
1318
+ * constants, interpolated into the SQL — never user input.
1319
+ */
1320
+ const OPENCODE_OUTPUT_MAX_CHARS = 2000;
1321
+ const OPENCODE_INPUT_MAX_BYTES = 4000;
1322
+ /**
1323
+ * The transcript query {@link parseOpenCode} runs, exported so a test can assert
1324
+ * the projection's real cost against a database instead of re-typing the SQL.
1325
+ *
1326
+ * A tool part is PROJECTED to exactly the fields the `case 'tool'` branch below
1327
+ * reads — `tool`, `callID`, `state.status`, `state.input`, `state.output` —
1328
+ * rather than carried whole. Two things this has to get right at once:
1329
+ *
1330
+ * - Keep `state.input`. It carries `filePath` / `command`, and losing it is
1331
+ * what left `recentDirectoriesTouched` empty (RUSH-2358).
1332
+ * - Stay bounded. A tool part is not bounded by its output: on a real
1333
+ * `opencode.db` the largest part is 1,346,068 bytes of which
1334
+ * `state.attachments` (a base64 data URL from `read`) is 1,345,674, while
1335
+ * `state.output` is 23. Truncating only `state.output` therefore bounded
1336
+ * nothing — it took one session's loaded tool payload from 299,365 to
1337
+ * 1,911,209 bytes. The projection drops `attachments` (and every other
1338
+ * unread key) outright, caps `state.output`, and collapses an oversized
1339
+ * `state.input` to just its addressing fields. Those are the keys the
1340
+ * enrichment reads — `filePath`/`path` for an edit and `cwd`/`workdir`/
1341
+ * `working_directory` for a shell (`extractRecentDirectoriesTouched` in
1342
+ * state.ts), plus `command`/`description`, themselves capped since a command
1343
+ * can be arbitrarily long. Above the cap every other input key (an `edit`'s
1344
+ * `oldString`/`newString`) is gone, deliberately — nothing downstream reads
1345
+ * them, and they are the weight.
1346
+ *
1347
+ * `json_valid` guards every `json_extract`: SQLite raises "malformed JSON" on a
1348
+ * non-JSON value, which aborts the WHOLE query, so one bad `part` row would
1349
+ * otherwise cost the entire transcript. Note the single-argument form is
1350
+ * RFC-8259-strict — it rejects a JSONB blob that `json_extract` would accept.
1351
+ * That is correct for today's schema (`part.data` / `message.data` are TEXT on
1352
+ * a real database); if OpenCode ever migrates them to JSONB, these guards must
1353
+ * move to `json_valid(data, 6)` or they will drop every row.
1354
+ *
1355
+ * The session id is bound as a parameter; the two caps are numeric literals.
1356
+ */
1357
+ export const OPENCODE_TRANSCRIPT_QUERY = `
1358
+ SELECT
1359
+ CASE WHEN json_valid(m.data) THEN json_extract(m.data, '$.role') END AS role,
1360
+ CASE WHEN json_valid(p.data) THEN json_extract(p.data, '$.type') END AS part_type,
1361
+ CASE
1362
+ WHEN json_valid(p.data) AND json_extract(p.data, '$.type') = 'tool'
1363
+ THEN json_object(
1364
+ 'type', 'tool',
1365
+ 'tool', json_extract(p.data, '$.tool'),
1366
+ 'callID', json_extract(p.data, '$.callID'),
1367
+ 'state', json_object(
1368
+ 'status', json_extract(p.data, '$.state.status'),
1369
+ 'input', CASE
1370
+ WHEN LENGTH(CAST(COALESCE(json_extract(p.data, '$.state.input'), '') AS BLOB)) > ${OPENCODE_INPUT_MAX_BYTES}
1371
+ THEN json_object(
1372
+ 'filePath', json_extract(p.data, '$.state.input.filePath'),
1373
+ 'path', json_extract(p.data, '$.state.input.path'),
1374
+ 'command', substr(COALESCE(json_extract(p.data, '$.state.input.command'), ''), 1, ${OPENCODE_OUTPUT_MAX_CHARS}),
1375
+ 'description', substr(COALESCE(json_extract(p.data, '$.state.input.description'), ''), 1, ${OPENCODE_OUTPUT_MAX_CHARS}),
1376
+ 'cwd', json_extract(p.data, '$.state.input.cwd'),
1377
+ 'workdir', json_extract(p.data, '$.state.input.workdir'),
1378
+ 'working_directory', json_extract(p.data, '$.state.input.working_directory')
1379
+ )
1380
+ ELSE json_extract(p.data, '$.state.input')
1381
+ END,
1382
+ 'output', substr(COALESCE(json_extract(p.data, '$.state.output'), ''), 1, ${OPENCODE_OUTPUT_MAX_CHARS})
1383
+ )
1384
+ )
1385
+ ELSE p.data
1386
+ END AS part_data,
1387
+ m.time_created AS time_created
1388
+ FROM message m
1389
+ JOIN part p ON p.message_id = m.id AND p.session_id = m.session_id
1390
+ WHERE m.session_id = ?
1391
+ ORDER BY m.time_created ASC, p.time_created ASC;
1392
+ `.replace(/\n/g, ' ');
1282
1393
  export function parseOpenCode(filePath) {
1283
- const [dbPath, sessionId] = filePath.split('#');
1394
+ const { container: dbPath, fragment: sessionId } = splitSessionFilePath(filePath);
1284
1395
  if (!dbPath || !sessionId)
1285
1396
  return [];
1286
1397
  const events = [];
1287
1398
  // Read through the node/bun SQLite wrapper (not the `sqlite3` CLI) so this
1288
1399
  // works on every OS — the CLI is absent on Windows.
1289
1400
  let rows;
1401
+ // OpenCode stores the session's checklist in its own `todo` table (the current
1402
+ // snapshot, not a history). Emitted below as one `todo_write` tool_use event so
1403
+ // the shared enrichment (`extractTodoProgressFromEvents`) computes `todos`
1404
+ // uniformly with every other harness (RUSH-2358).
1405
+ let todoRows = [];
1290
1406
  let db;
1291
1407
  try {
1292
- // Query messages with their parts, ordered chronologically. Tool parts are
1293
- // truncated to keep large tool outputs from bloating memory; the session id
1294
- // is bound as a parameter rather than interpolated.
1295
- const query = `
1296
- SELECT
1297
- json_extract(m.data, '$.role') AS role,
1298
- json_extract(p.data, '$.type') AS part_type,
1299
- CASE
1300
- WHEN json_extract(p.data, '$.type') = 'tool'
1301
- THEN substr(p.data, 1, 2000)
1302
- ELSE p.data
1303
- END AS part_data,
1304
- m.time_created AS time_created
1305
- FROM message m
1306
- JOIN part p ON p.message_id = m.id AND p.session_id = m.session_id
1307
- WHERE m.session_id = ?
1308
- ORDER BY m.time_created ASC, p.time_created ASC;
1309
- `.replace(/\n/g, ' ');
1408
+ // Messages with their parts, ordered chronologically. The query — and why
1409
+ // the tool part is projected rather than carried whole is documented on
1410
+ // OPENCODE_TRANSCRIPT_QUERY above.
1310
1411
  db = new Database(dbPath);
1311
- rows = db.prepare(query).all(sessionId);
1412
+ rows = db.prepare(OPENCODE_TRANSCRIPT_QUERY).all(sessionId);
1413
+ // The `todo` table is a newer OpenCode addition. Probe for it the same way
1414
+ // the scanner probes newer `session` columns, rather than wrapping the read
1415
+ // in a blanket catch — a catch there reported a locked, corrupt, or
1416
+ // permission-denied database as "this session has no todos".
1417
+ //
1418
+ // Row COUNT, not an empty-`get()` sentinel: the two production runtimes
1419
+ // disagree on what `get()` returns for no row — node:sqlite gives
1420
+ // `undefined`, bun:sqlite gives `null` (both ship; see sqlite.ts). A check
1421
+ // written against either sentinel is always-true on the other runtime,
1422
+ // which would run the `todo` SELECT on a schema that has no such table,
1423
+ // throw, and hand the whole transcript to the outer catch — an empty
1424
+ // session, silently, and only in the shipped Bun binary. `.all().length`
1425
+ // cannot express that disagreement.
1426
+ const hasTodoTable = db
1427
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'todo';`)
1428
+ .all().length > 0;
1429
+ if (hasTodoTable) {
1430
+ todoRows = db
1431
+ .prepare('SELECT content, status, time_updated FROM todo WHERE session_id = ? ORDER BY position ASC;')
1432
+ .all(sessionId);
1433
+ }
1312
1434
  }
1313
1435
  catch {
1314
1436
  /* DB not accessible, sqlite module unavailable, or query failed */
@@ -1400,6 +1522,29 @@ export function parseOpenCode(filePath) {
1400
1522
  catch {
1401
1523
  /* malformed row payload — return what we parsed so far */
1402
1524
  }
1525
+ // Emit the OpenCode `todo` table as one `todo_write` snapshot so the shared
1526
+ // enrichment layer derives `todos` the same way it does for every harness that
1527
+ // records a checklist tool. `todo_write` is in SNAPSHOT_TODO_TOOLS, and the
1528
+ // enrichment reads `args.todos`, so the shape matches without special-casing.
1529
+ const todos = todoRows
1530
+ .map(t => ({
1531
+ content: typeof t.content === 'string' ? t.content : '',
1532
+ status: typeof t.status === 'string' ? t.status : 'pending',
1533
+ }))
1534
+ .filter(t => t.content.trim());
1535
+ if (todos.length) {
1536
+ const lastTodoMs = todoRows.reduce((max, t) => {
1537
+ const ms = typeof t.time_updated === 'number' ? t.time_updated : parseInt(String(t.time_updated), 10);
1538
+ return Number.isFinite(ms) && ms > max ? ms : max;
1539
+ }, 0);
1540
+ events.push({
1541
+ type: 'tool_use',
1542
+ agent: 'opencode',
1543
+ timestamp: lastTodoMs > 0 ? new Date(lastTodoMs).toISOString() : new Date().toISOString(),
1544
+ tool: 'todo_write',
1545
+ args: { todos },
1546
+ });
1547
+ }
1403
1548
  return events;
1404
1549
  }
1405
1550
  // ---------------------------------------------------------------------------
@@ -2,13 +2,14 @@ import type { ActiveSession } from './active.js';
2
2
  /**
3
3
  * How long a snapshot may be served before a reader re-gathers.
4
4
  * Short on purpose: live status (running/idle/waiting) must not go stale.
5
- * The daemon warm tick uses the same cadence (see {@link SESSION_CACHE_WARM_INTERVAL_MS}).
5
+ * The daemon warms more frequently than this ceiling so normal readers hit a
6
+ * fresh snapshot while expiry still forces a live gather after a missed tick.
6
7
  */
7
- export declare const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS: number;
8
- /** Daemon warm interval — keep in sync with the setInterval in `lib/daemon.ts`. */
9
- export declare const SESSION_CACHE_WARM_INTERVAL_MS: number;
10
- /** Kick off the first warm 30s after daemon start (staggered off other ticks). */
11
- export declare const SESSION_CACHE_WARM_KICKOFF_MS = 30000;
8
+ export declare const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS = 15000;
9
+ /** Daemon warm interval — below the freshness ceiling to avoid an expiry gap. */
10
+ export declare const SESSION_CACHE_WARM_INTERVAL_MS = 10000;
11
+ /** Kick off the first warm shortly after daemon start, staggered off bootstrap. */
12
+ export declare const SESSION_CACHE_WARM_KICKOFF_MS = 5000;
12
13
  /** Snapshot scope: this host only, or a fleet-wide merge written by a reader. */
13
14
  export type ActiveCacheScope = 'local' | 'fleet';
14
15
  export interface ActiveSessionsSnapshot {
@@ -56,6 +57,8 @@ export declare const IMMUTABLE_FIELD_KEYS: readonly ["topic", "label", "name", "
56
57
  export declare const LIVE_STATUS_KEYS: readonly ["status", "activity", "preview", "tokPerSec", "awaitingReason", "question", "todos", "tail", "lastActivityMs", "hostLink", "presence", "pidAlive", "tmuxClients", "windowHeartbeatMs", "provenance", "rateLimited", "plan"];
57
58
  /** Test seam: redirect the snapshot file. Returns the previous override. */
58
59
  export declare function setActiveSessionsSnapshotPathForTest(p: string | null): string | null;
60
+ /** Test seam: isolate process-local entries between fixtures. */
61
+ export declare function clearActiveSnapshotMemoryForTest(): void;
59
62
  /** Test seam: redirect the immutable-memo file. Returns the previous override. */
60
63
  export declare function setImmutableMemoPathForTest(p: string | null): string | null;
61
64
  /** Read one scope from the snapshot file (best-effort; missing/corrupt → null). */
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import * as fs from 'fs';
26
26
  import * as path from 'path';
27
+ import { createMemoryCache } from '../memory-cache.js';
27
28
  import { getCacheDir } from '../state.js';
28
29
  /** Snapshot file under `getCacheDir()` (regenerable, gitignored). */
29
30
  const SNAPSHOT_FILE = '.active-sessions.json';
@@ -32,13 +33,19 @@ const IMMUTABLE_FILE = '.active-session-immutable.json';
32
33
  /**
33
34
  * How long a snapshot may be served before a reader re-gathers.
34
35
  * Short on purpose: live status (running/idle/waiting) must not go stale.
35
- * The daemon warm tick uses the same cadence (see {@link SESSION_CACHE_WARM_INTERVAL_MS}).
36
+ * The daemon warms more frequently than this ceiling so normal readers hit a
37
+ * fresh snapshot while expiry still forces a live gather after a missed tick.
36
38
  */
37
- export const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS = 4 * 60_000;
38
- /** Daemon warm interval — keep in sync with the setInterval in `lib/daemon.ts`. */
39
- export const SESSION_CACHE_WARM_INTERVAL_MS = 3 * 60_000;
40
- /** Kick off the first warm 30s after daemon start (staggered off other ticks). */
41
- export const SESSION_CACHE_WARM_KICKOFF_MS = 30_000;
39
+ export const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS = 15_000;
40
+ /** Daemon warm interval — below the freshness ceiling to avoid an expiry gap. */
41
+ export const SESSION_CACHE_WARM_INTERVAL_MS = 10_000;
42
+ /** Kick off the first warm shortly after daemon start, staggered off bootstrap. */
43
+ export const SESSION_CACHE_WARM_KICKOFF_MS = 5_000;
44
+ /** Process-local L1. The atomic snapshot remains the cross-process source. */
45
+ const activeSnapshotMemory = createMemoryCache({
46
+ max: 2,
47
+ ttlMs: DEFAULT_ACTIVE_CACHE_MAX_AGE_MS,
48
+ });
42
49
  /** Keys stored in the immutable memo (transcript-stable). */
43
50
  export const IMMUTABLE_FIELD_KEYS = [
44
51
  'topic',
@@ -92,8 +99,13 @@ let immutablePathOverride = null;
92
99
  export function setActiveSessionsSnapshotPathForTest(p) {
93
100
  const prev = snapshotPathOverride;
94
101
  snapshotPathOverride = p;
102
+ activeSnapshotMemory.clear();
95
103
  return prev;
96
104
  }
105
+ /** Test seam: isolate process-local entries between fixtures. */
106
+ export function clearActiveSnapshotMemoryForTest() {
107
+ activeSnapshotMemory.clear();
108
+ }
97
109
  /** Test seam: redirect the immutable-memo file. Returns the previous override. */
98
110
  export function setImmutableMemoPathForTest(p) {
99
111
  const prev = immutablePathOverride;
@@ -109,6 +121,9 @@ function immutablePath() {
109
121
  // ── snapshot read / write ──────────────────────────────────────────────────
110
122
  /** Read one scope from the snapshot file (best-effort; missing/corrupt → null). */
111
123
  export function readActiveSessionsCache(scope) {
124
+ const memory = activeSnapshotMemory.get(scope);
125
+ if (memory)
126
+ return memory;
112
127
  try {
113
128
  const parsed = JSON.parse(fs.readFileSync(snapshotPath(), 'utf-8'));
114
129
  if (!parsed || parsed.version !== 1 || !parsed.entries)
@@ -116,6 +131,7 @@ export function readActiveSessionsCache(scope) {
116
131
  const entry = parsed.entries[scope];
117
132
  if (!entry || !Array.isArray(entry.sessions) || typeof entry.capturedAt !== 'number')
118
133
  return null;
134
+ activeSnapshotMemory.set(scope, entry);
119
135
  return entry;
120
136
  }
121
137
  catch {
@@ -153,6 +169,7 @@ export function writeActiveSessionsCache(scope, sessions, opts = {}) {
153
169
  const tmp = `${snapshotPath()}.tmp`;
154
170
  fs.writeFileSync(tmp, JSON.stringify(body));
155
171
  fs.renameSync(tmp, snapshotPath());
172
+ activeSnapshotMemory.set(scope, snap);
156
173
  }
157
174
  catch {
158
175
  // best-effort
package/dist/lib/shims.js CHANGED
@@ -600,6 +600,18 @@ else
600
600
  BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
601
601
  fi
602
602
 
603
+ # A managed binary must never resolve back into this dispatcher. This can
604
+ # happen when an install-script launcher was imported before adoption and was
605
+ # later repointed at the agents shim. Use the durable native target recorded by
606
+ # adoption instead of recursively exec-ing this script.
607
+ if [ -x "$BINARY" ]; then
608
+ RESOLVED_BINARY=$(realpath "$BINARY" 2>/dev/null || readlink -f "$BINARY" 2>/dev/null || echo "")
609
+ RESOLVED_SHIM=$(realpath "$AGENTS_USER_DIR/.cache/shims/$CLI_COMMAND" 2>/dev/null || readlink -f "$AGENTS_USER_DIR/.cache/shims/$CLI_COMMAND" 2>/dev/null || echo "")
610
+ if [ -n "$RESOLVED_BINARY" ] && [ "$RESOLVED_BINARY" = "$RESOLVED_SHIM" ]; then
611
+ BINARY=$(adopted_original_bin || echo "")
612
+ fi
613
+ fi
614
+
603
615
  # Auto-install if not present
604
616
  if [ ! -x "$BINARY" ]; then
605
617
  if [ "$VERSION_SOURCE" = "project" ]; then