@phnx-labs/agents-cli 1.22.25 → 1.22.27

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 (178) hide show
  1. package/CHANGELOG.md +427 -0
  2. package/README.md +35 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/bench.d.ts +2 -0
  7. package/dist/commands/bench.js +101 -0
  8. package/dist/commands/browser.js +35 -0
  9. package/dist/commands/doctor.js +32 -10
  10. package/dist/commands/exec.js +17 -16
  11. package/dist/commands/insights.d.ts +25 -19
  12. package/dist/commands/insights.js +146 -38
  13. package/dist/commands/output.js +100 -25
  14. package/dist/commands/reconnect.d.ts +46 -0
  15. package/dist/commands/reconnect.js +109 -0
  16. package/dist/commands/resume.d.ts +11 -0
  17. package/dist/commands/resume.js +51 -0
  18. package/dist/commands/routines.js +2 -2
  19. package/dist/commands/secrets.d.ts +2 -8
  20. package/dist/commands/secrets.js +29 -105
  21. package/dist/commands/sessions-picker.js +12 -7
  22. package/dist/commands/sessions.d.ts +47 -1
  23. package/dist/commands/sessions.js +349 -37
  24. package/dist/commands/setup-secrets.d.ts +1 -0
  25. package/dist/commands/setup-secrets.js +1 -1
  26. package/dist/commands/setup.d.ts +26 -3
  27. package/dist/commands/setup.js +105 -46
  28. package/dist/commands/ssh.js +184 -11
  29. package/dist/commands/teams.d.ts +6 -0
  30. package/dist/commands/teams.js +46 -3
  31. package/dist/commands/trends.d.ts +8 -0
  32. package/dist/commands/trends.js +10 -156
  33. package/dist/index.js +17 -21
  34. package/dist/lib/agents.d.ts +11 -0
  35. package/dist/lib/agents.js +29 -2
  36. package/dist/lib/analytics/dashboard.d.ts +10 -6
  37. package/dist/lib/analytics/dashboard.js +6 -4
  38. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  39. package/dist/lib/analytics/mix-commands.js +229 -0
  40. package/dist/lib/analytics/recipes.d.ts +19 -14
  41. package/dist/lib/analytics/recipes.js +4 -2
  42. package/dist/lib/auth-health.d.ts +47 -3
  43. package/dist/lib/auth-health.js +74 -15
  44. package/dist/lib/bench/index.d.ts +4 -0
  45. package/dist/lib/bench/index.js +4 -0
  46. package/dist/lib/bench/runner.d.ts +16 -0
  47. package/dist/lib/bench/runner.js +111 -0
  48. package/dist/lib/bench/schema.d.ts +5 -0
  49. package/dist/lib/bench/schema.js +91 -0
  50. package/dist/lib/bench/storage.d.ts +5 -0
  51. package/dist/lib/bench/storage.js +32 -0
  52. package/dist/lib/bench/types.d.ts +40 -0
  53. package/dist/lib/bench/types.js +1 -0
  54. package/dist/lib/browser/ipc.d.ts +26 -0
  55. package/dist/lib/browser/ipc.js +139 -24
  56. package/dist/lib/browser/profiles.d.ts +11 -0
  57. package/dist/lib/browser/profiles.js +1 -1
  58. package/dist/lib/browser/stream.d.ts +14 -0
  59. package/dist/lib/browser/stream.js +71 -0
  60. package/dist/lib/channels/owner-sink.d.ts +27 -0
  61. package/dist/lib/channels/owner-sink.js +93 -0
  62. package/dist/lib/claude-account-token.d.ts +2 -0
  63. package/dist/lib/claude-account-token.js +27 -1
  64. package/dist/lib/crabbox/cli.d.ts +2 -0
  65. package/dist/lib/crabbox/cli.js +2 -0
  66. package/dist/lib/crabbox/lease.js +7 -1
  67. package/dist/lib/daemon.js +20 -0
  68. package/dist/lib/devices/connect.d.ts +2 -0
  69. package/dist/lib/devices/connect.js +7 -0
  70. package/dist/lib/devices/doctor-findings.d.ts +10 -1
  71. package/dist/lib/devices/doctor-findings.js +47 -1
  72. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  73. package/dist/lib/devices/harness-inventory.js +0 -0
  74. package/dist/lib/devices/registry.d.ts +2 -0
  75. package/dist/lib/devices/ssh-config.js +3 -0
  76. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  77. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  78. package/dist/lib/exec.bench.d.ts +1 -0
  79. package/dist/lib/exec.bench.js +186 -0
  80. package/dist/lib/exec.js +18 -8
  81. package/dist/lib/fleet/apply.d.ts +59 -3
  82. package/dist/lib/fleet/apply.js +191 -12
  83. package/dist/lib/fleet/remote-login.d.ts +4 -3
  84. package/dist/lib/fleet/remote-login.js +11 -9
  85. package/dist/lib/fleet/types.d.ts +21 -2
  86. package/dist/lib/gemini-settings.d.ts +0 -1
  87. package/dist/lib/gemini-settings.js +12 -7
  88. package/dist/lib/hooks/cache.js +15 -0
  89. package/dist/lib/hooks.d.ts +8 -5
  90. package/dist/lib/hooks.js +10 -6
  91. package/dist/lib/hosts/dispatch.js +7 -3
  92. package/dist/lib/hosts/passthrough.d.ts +45 -0
  93. package/dist/lib/hosts/passthrough.js +59 -8
  94. package/dist/lib/hosts/progress.d.ts +2 -1
  95. package/dist/lib/hosts/progress.js +4 -3
  96. package/dist/lib/hosts/providers/devices.js +1 -0
  97. package/dist/lib/hosts/ready.d.ts +6 -2
  98. package/dist/lib/hosts/ready.js +33 -8
  99. package/dist/lib/hosts/reconcile.d.ts +1 -1
  100. package/dist/lib/hosts/reconcile.js +16 -7
  101. package/dist/lib/hosts/reconnect.d.ts +56 -21
  102. package/dist/lib/hosts/reconnect.js +128 -71
  103. package/dist/lib/hosts/registry.d.ts +2 -2
  104. package/dist/lib/hosts/registry.js +3 -5
  105. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  106. package/dist/lib/hosts/remote-cmd.js +29 -0
  107. package/dist/lib/hosts/tasks.d.ts +2 -0
  108. package/dist/lib/hosts/types.d.ts +1 -0
  109. package/dist/lib/hosts/types.js +3 -0
  110. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  111. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  112. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  113. package/dist/lib/menubar/install-menubar.js +20 -9
  114. package/dist/lib/models.d.ts +30 -1
  115. package/dist/lib/models.js +42 -144
  116. package/dist/lib/pricing/cost.d.ts +9 -0
  117. package/dist/lib/pricing/cost.js +24 -0
  118. package/dist/lib/pricing/index.d.ts +1 -1
  119. package/dist/lib/pricing/index.js +1 -1
  120. package/dist/lib/redact.js +8 -3
  121. package/dist/lib/remote-agents-json.d.ts +32 -0
  122. package/dist/lib/remote-agents-json.js +47 -16
  123. package/dist/lib/resource-profiles.js +1 -2
  124. package/dist/lib/routine-notify-owner.d.ts +102 -0
  125. package/dist/lib/routine-notify-owner.js +232 -0
  126. package/dist/lib/routines.d.ts +10 -0
  127. package/dist/lib/routines.js +14 -2
  128. package/dist/lib/runner.d.ts +9 -4
  129. package/dist/lib/runner.js +182 -30
  130. package/dist/lib/sandbox.d.ts +0 -2
  131. package/dist/lib/sandbox.js +2 -19
  132. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  133. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  134. package/dist/lib/secrets/agent.js +3 -2
  135. package/dist/lib/secrets/lease.d.ts +25 -0
  136. package/dist/lib/secrets/lease.js +44 -0
  137. package/dist/lib/secrets/push.d.ts +94 -0
  138. package/dist/lib/secrets/push.js +145 -0
  139. package/dist/lib/secrets/reaper.d.ts +15 -1
  140. package/dist/lib/secrets/reaper.js +30 -3
  141. package/dist/lib/self-update.d.ts +20 -5
  142. package/dist/lib/self-update.js +93 -16
  143. package/dist/lib/session/db.d.ts +38 -3
  144. package/dist/lib/session/db.js +273 -16
  145. package/dist/lib/session/discover.d.ts +22 -1
  146. package/dist/lib/session/discover.js +239 -51
  147. package/dist/lib/session/insights.d.ts +37 -0
  148. package/dist/lib/session/insights.js +219 -9
  149. package/dist/lib/session/recovery.d.ts +17 -1
  150. package/dist/lib/session/recovery.js +111 -4
  151. package/dist/lib/session/remote-list.d.ts +10 -9
  152. package/dist/lib/session/remote-list.js +25 -23
  153. package/dist/lib/session/resume-owner.d.ts +55 -0
  154. package/dist/lib/session/resume-owner.js +69 -0
  155. package/dist/lib/session/team-filter.d.ts +65 -0
  156. package/dist/lib/session/team-filter.js +98 -3
  157. package/dist/lib/session/tool-index.js +133 -22
  158. package/dist/lib/session/tool-store.d.ts +26 -2
  159. package/dist/lib/session/tool-store.js +36 -17
  160. package/dist/lib/session/types.d.ts +33 -0
  161. package/dist/lib/smart-launch.d.ts +31 -5
  162. package/dist/lib/smart-launch.js +43 -7
  163. package/dist/lib/ssh-exec.d.ts +27 -0
  164. package/dist/lib/ssh-exec.js +42 -3
  165. package/dist/lib/ssh-tunnel.d.ts +3 -2
  166. package/dist/lib/ssh-tunnel.js +25 -16
  167. package/dist/lib/startup/command-registry.d.ts +2 -0
  168. package/dist/lib/startup/command-registry.js +6 -0
  169. package/dist/lib/teams/agents.d.ts +13 -0
  170. package/dist/lib/teams/agents.js +75 -7
  171. package/dist/lib/teams/placement-probe.d.ts +21 -0
  172. package/dist/lib/teams/placement-probe.js +135 -0
  173. package/dist/lib/teams/scheduler.d.ts +74 -1
  174. package/dist/lib/teams/scheduler.js +187 -10
  175. package/dist/lib/tmux/session.d.ts +8 -0
  176. package/dist/lib/tmux/session.js +22 -0
  177. package/dist/lib/types.d.ts +2 -0
  178. package/package.json +1 -1
@@ -20,6 +20,8 @@ import { parseTimeFilter } from './relative-time.js';
20
20
  const execFileAsync = promisify(execFile);
21
21
  import { AGENTS, agentConfigDirName, getCliVersion } from '../agents.js';
22
22
  import { walkForFilesWithStat } from '../fs-walk.js';
23
+ import { hasCommand } from '../cli-resources.js';
24
+ import { execFileShellSpec } from '../platform/exec.js';
23
25
  import { getConfigSymlinkVersion } from '../shims.js';
24
26
  import { SESSION_AGENTS } from './types.js';
25
27
  import { deriveShortId } from './short-id.js';
@@ -28,7 +30,7 @@ import { extractSessionTopic, extractSlashCommandName, extractSlashCommandFromTo
28
30
  import { isSkillInvocation, extractSkills, extractSlashCommands } from './highlights.js';
29
31
  import { parseAntigravity, parseCursor } from './parse.js';
30
32
  import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
31
- import { costOfUsage } from '../pricing/index.js';
33
+ import { costOfUsage, costOfUsageNoCache } from '../pricing/index.js';
32
34
  import { machineForSessionFile } from './origin-machine.js';
33
35
  export { machineForSessionFile } from './origin-machine.js';
34
36
  import { mapBounded } from '../concurrency.js';
@@ -877,6 +879,7 @@ async function scanRoutineArchivesIncremental(agent, onProgress) {
877
879
  meta: result.meta,
878
880
  content: result.content,
879
881
  scan,
882
+ events: result.events,
880
883
  toolCalls: result.toolCalls,
881
884
  toolIndexMode: result.toolIndexMode,
882
885
  });
@@ -1095,7 +1098,11 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, label) {
1095
1098
  toolCallCount: scan.toolCallCount,
1096
1099
  tokenCount: scan.tokenCount,
1097
1100
  outputTokens: scan.outputTokens,
1101
+ inputTokens: scan.inputTokens,
1102
+ cacheReadTokens: scan.cacheReadTokens,
1103
+ cacheWriteTokens: scan.cacheWriteTokens,
1098
1104
  costUsd: scan.costUsd,
1105
+ costUsdNoCache: scan.costUsdNoCache,
1099
1106
  durationMs: scan.durationMs,
1100
1107
  isTeamOrigin,
1101
1108
  prUrl: scan.prUrl,
@@ -1129,7 +1136,11 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, label) {
1129
1136
  toolCallCount: scan.toolCallCount,
1130
1137
  tokenCount: scan.tokenCount,
1131
1138
  outputTokens: scan.outputTokens,
1139
+ inputTokens: scan.inputTokens,
1140
+ cacheReadTokens: scan.cacheReadTokens,
1141
+ cacheWriteTokens: scan.cacheWriteTokens,
1132
1142
  costUsd: scan.costUsd,
1143
+ costUsdNoCache: scan.costUsdNoCache,
1133
1144
  durationMs: scan.durationMs,
1134
1145
  topic: scan.topic,
1135
1146
  isTeamOrigin,
@@ -1437,7 +1448,11 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion, sc
1437
1448
  messageCount: scan.messageCount,
1438
1449
  tokenCount: scan.tokenCount,
1439
1450
  outputTokens: scan.outputTokens,
1451
+ inputTokens: scan.inputTokens,
1452
+ cacheReadTokens: scan.cacheReadTokens,
1453
+ cacheWriteTokens: scan.cacheWriteTokens,
1440
1454
  costUsd: scan.costUsd,
1455
+ costUsdNoCache: scan.costUsdNoCache,
1441
1456
  durationMs: scan.durationMs,
1442
1457
  account: resolveAccount?.(),
1443
1458
  prUrl: scan.prUrl,
@@ -1575,8 +1590,11 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
1575
1590
  let messageCount = 0;
1576
1591
  let tokenCount = 0;
1577
1592
  let outputTokens = 0;
1593
+ let inputTokens = 0;
1594
+ let cacheReadTokens = 0;
1578
1595
  let sawTokenCount = false;
1579
1596
  let costUsd = 0;
1597
+ let costUsdNoCache = 0;
1580
1598
  let sawCost = false;
1581
1599
  let firstTsMs;
1582
1600
  let lastTsMs;
@@ -1621,21 +1639,28 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
1621
1639
  (typeof gtk.output === 'number' ? gtk.output : 0) +
1622
1640
  (typeof gtk.thoughts === 'number' ? gtk.thoughts : 0) +
1623
1641
  (typeof gtk.tool === 'number' ? gtk.tool : 0);
1642
+ // Burn split (RUSH-2287): Gemini has uncached input + cache-read, no cache-write.
1643
+ if (typeof gtk.input === 'number')
1644
+ inputTokens += gtk.input;
1645
+ if (typeof gtk.cached === 'number')
1646
+ cacheReadTokens += gtk.cached;
1624
1647
  }
1625
1648
  // Per-message cost: directional tokens × this message's model price.
1626
1649
  const msgModel = (typeof message.model === 'string' ? message.model : undefined) || sessionModel;
1627
1650
  const tk = message.tokens;
1628
1651
  if (msgModel && tk && typeof tk === 'object') {
1629
- const c = costOfUsage({
1652
+ const usage = {
1630
1653
  model: msgModel,
1631
1654
  inputTokens: typeof tk.input === 'number' ? tk.input : undefined,
1632
1655
  outputTokens: (typeof tk.output === 'number' ? tk.output : 0) +
1633
1656
  (typeof tk.thoughts === 'number' ? tk.thoughts : 0) +
1634
1657
  (typeof tk.tool === 'number' ? tk.tool : 0),
1635
1658
  cacheReadTokens: typeof tk.cached === 'number' ? tk.cached : undefined,
1636
- });
1659
+ };
1660
+ const c = costOfUsage(usage);
1637
1661
  if (c > 0) {
1638
1662
  costUsd += c;
1663
+ costUsdNoCache += costOfUsageNoCache(usage);
1639
1664
  sawCost = true;
1640
1665
  }
1641
1666
  }
@@ -1658,7 +1683,10 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
1658
1683
  messageCount,
1659
1684
  tokenCount: sawTokenCount ? tokenCount : undefined,
1660
1685
  outputTokens: sawTokenCount ? outputTokens : undefined,
1686
+ inputTokens: sawTokenCount ? inputTokens : undefined,
1687
+ cacheReadTokens: sawTokenCount ? cacheReadTokens : undefined,
1661
1688
  costUsd: sawCost ? costUsd : undefined,
1689
+ costUsdNoCache: sawCost ? costUsdNoCache : undefined,
1662
1690
  durationMs,
1663
1691
  };
1664
1692
  return { meta, content: userTexts.join('\n') };
@@ -1756,7 +1784,7 @@ async function scanAntigravityIncremental(onProgress) {
1756
1784
  const result = readAntigravityMeta(filePath, currentVersion);
1757
1785
  if (result && !seen.has(result.meta.id)) {
1758
1786
  seen.add(result.meta.id);
1759
- entries.push({ meta: result.meta, content: result.content, scan });
1787
+ entries.push({ meta: result.meta, content: result.content, scan, events: result.events });
1760
1788
  }
1761
1789
  else {
1762
1790
  touched.push({ filePath, scan });
@@ -1802,23 +1830,28 @@ function readAntigravityMeta(filePath, currentVersion) {
1802
1830
  topic: topic ? topic.slice(0, 120) : undefined,
1803
1831
  messageCount: events.length,
1804
1832
  };
1805
- return { meta, content: contentParts.join('\n') };
1833
+ return { meta, content: contentParts.join('\n'), events };
1806
1834
  }
1807
1835
  // ---------------------------------------------------------------------------
1808
1836
  // OpenCode
1809
1837
  // ---------------------------------------------------------------------------
1810
1838
  const OPENCODE_DB = path.join(HOME, '.local', 'share', 'opencode', 'opencode.db');
1811
1839
  let cachedOpenCodeAccount;
1812
- /** Query the active OpenCode account email from its SQLite database. */
1813
- async function getOpenCodeAccount() {
1840
+ /**
1841
+ * Query the active OpenCode account email from its SQLite database.
1842
+ *
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.
1845
+ */
1846
+ function getOpenCodeAccount(handle) {
1814
1847
  if (cachedOpenCodeAccount !== undefined)
1815
1848
  return cachedOpenCodeAccount || undefined;
1816
1849
  // Read through the node/bun SQLite wrapper (not the `sqlite3` CLI) so this
1817
1850
  // works on every OS — the CLI is absent on Windows.
1818
- let db;
1851
+ let owned;
1819
1852
  try {
1820
- if (fs.existsSync(OPENCODE_DB)) {
1821
- db = new Database(OPENCODE_DB);
1853
+ const db = handle ?? (fs.existsSync(OPENCODE_DB) ? (owned = new Database(OPENCODE_DB)) : undefined);
1854
+ if (db) {
1822
1855
  const row = db
1823
1856
  .prepare('SELECT email FROM control_account WHERE active=1 LIMIT 1;')
1824
1857
  .get();
@@ -1832,13 +1865,63 @@ async function getOpenCodeAccount() {
1832
1865
  catch { /* DB not accessible, sqlite module unavailable, or query failed */ }
1833
1866
  finally {
1834
1867
  try {
1835
- db?.close();
1868
+ owned?.close();
1836
1869
  }
1837
1870
  catch { /* best-effort close */ }
1838
1871
  }
1839
1872
  cachedOpenCodeAccount = '';
1840
1873
  return undefined;
1841
1874
  }
1875
+ /**
1876
+ * The per-session ledger stamp for an OpenCode row: the newest write time across
1877
+ * the session's own row, its messages, and its parts, paired with the total byte
1878
+ * length of that session's message + part payloads.
1879
+ *
1880
+ * OpenCode keeps every session in ONE shared SQLite file, so that file's
1881
+ * mtime/size changes whenever *any* session is written. Stamping each session
1882
+ * with the whole-DB stat therefore invalidated every indexed session on every
1883
+ * scan: up to `LIMIT 1000` sessions were re-emitted into `upsertSessionsBatch`,
1884
+ * and its enrichment step re-opened `opencode.db` once per re-emitted entry via
1885
+ * `parseSession` (RUSH-2210).
1886
+ *
1887
+ * `s.time_updated` alone is NOT that session's change signal, which is the trap
1888
+ * this shape exists to avoid. On a real database, parts land long after the
1889
+ * session row was last touched — e.g. `ses_3955202dfffe…` carried
1890
+ * `time_updated = 1771316403087` with its newest part at `1771331512162`, over
1891
+ * four hours later. So the stamp maxes `time_updated` with the newest message
1892
+ * and part times, and pairs it with a byte total that also moves when an
1893
+ * existing part's `data` is rewritten in place (a streaming turn) without any
1894
+ * new row or timestamp.
1895
+ *
1896
+ * The byte total is a REAL size, not a repurposed counter, because
1897
+ * `sessions.file_size` is read back as bytes elsewhere: `ensureToolIndex` uses
1898
+ * it as the tool-backfill byte budget and `toolCallsForBackfill` as the 16 MiB
1899
+ * in-memory parser cap (`tool-index.ts`). A message/part byte total is the
1900
+ * honest cost of parsing that session — strictly better than the whole-DB size
1901
+ * this column used to hold for every OpenCode row.
1902
+ *
1903
+ * The size is `LENGTH(CAST(data AS BLOB))`, not `LENGTH(data)`: SQLite's
1904
+ * `LENGTH()` on a TEXT column counts CHARACTERS, so a CJK/emoji-heavy transcript
1905
+ * would under-report its real size by up to 4x — and this number is a byte
1906
+ * budget downstream.
1907
+ *
1908
+ * A degenerate row — nothing but zeros/NaN for every time AND no payload at all
1909
+ * — falls back to the whole-DB stat, keeping the pre-RUSH-2210 always-rescan
1910
+ * behavior for exactly those rows rather than pinning them to a stamp that never
1911
+ * changes.
1912
+ */
1913
+ function openCodeSessionStamp(times, bytes, dbScan) {
1914
+ // A positive time, not merely a finite one: the SQL COALESCEs the aggregates
1915
+ // to 0, so `Number.isFinite` alone would accept a row that told us nothing and
1916
+ // this branch could never fire.
1917
+ const known = [times.timeUpdated, times.lastMessageAt, times.lastPartAt, times.timeCreated]
1918
+ .filter(t => Number.isFinite(t) && t > 0);
1919
+ const size = (Number.isFinite(bytes.messageBytes) ? bytes.messageBytes : 0)
1920
+ + (Number.isFinite(bytes.partBytes) ? bytes.partBytes : 0);
1921
+ if (known.length === 0 && size === 0)
1922
+ return dbScan;
1923
+ return { fileMtimeMs: known.length === 0 ? 0 : Math.floor(Math.max(...known)), fileSize: size };
1924
+ }
1842
1925
  /** Scan OpenCode sessions from its SQLite database when the DB file has changed. */
1843
1926
  async function scanOpenCodeIncremental() {
1844
1927
  if (!fs.existsSync(OPENCODE_DB))
@@ -1846,8 +1929,9 @@ async function scanOpenCodeIncremental() {
1846
1929
  const stat = safeStatSync(OPENCODE_DB);
1847
1930
  if (!stat)
1848
1931
  return;
1849
- // OpenCode is one big DB; we use its mtime/size as the ledger for the
1850
- // entire fleet of OpenCode sessions.
1932
+ // OpenCode is one big DB. Its mtime/size is the cheap "did ANYTHING change"
1933
+ // short-circuit for the whole harness — not the per-session stamp, which each
1934
+ // row carries itself (see openCodeSessionStamp, RUSH-2210).
1851
1935
  const currentScan = {
1852
1936
  fileMtimeMs: Math.floor(stat.mtimeMs),
1853
1937
  fileSize: stat.size,
@@ -1856,7 +1940,6 @@ async function scanOpenCodeIncremental() {
1856
1940
  if (prev && prev.fileMtimeMs === currentScan.fileMtimeMs && prev.fileSize === currentScan.fileSize) {
1857
1941
  return;
1858
1942
  }
1859
- const account = await getOpenCodeAccount();
1860
1943
  const currentVersion = await getCurrentAgentVersion('opencode');
1861
1944
  // Read through the node/bun SQLite wrapper (not the `sqlite3` CLI) so this
1862
1945
  // works on every OS — the CLI is absent on Windows.
@@ -1871,14 +1954,28 @@ async function scanOpenCodeIncremental() {
1871
1954
  s.time_created AS time_created,
1872
1955
  s.time_updated AS time_updated,
1873
1956
  COALESCE(stats.message_count, 0) AS message_count,
1957
+ COALESCE(stats.last_message_at, 0) AS last_message_at,
1958
+ COALESCE(stats.message_bytes, 0) AS message_bytes,
1959
+ COALESCE(parts.last_part_at, 0) AS last_part_at,
1960
+ COALESCE(parts.part_bytes, 0) AS part_bytes,
1874
1961
  stats.token_count AS token_count,
1875
1962
  stats.output_tokens AS output_tokens,
1876
1963
  COALESCE(stats.has_token_data, 0) AS has_token_data
1877
1964
  FROM session s
1965
+ LEFT JOIN (
1966
+ SELECT
1967
+ session_id,
1968
+ MAX(time_created) AS last_part_at,
1969
+ SUM(LENGTH(CAST(data AS BLOB))) AS part_bytes
1970
+ FROM part
1971
+ GROUP BY session_id
1972
+ ) parts ON parts.session_id = s.id
1878
1973
  LEFT JOIN (
1879
1974
  SELECT
1880
1975
  session_id,
1881
1976
  COUNT(*) AS message_count,
1977
+ MAX(time_created) AS last_message_at,
1978
+ SUM(LENGTH(CAST(data AS BLOB))) AS message_bytes,
1882
1979
  SUM(
1883
1980
  COALESCE(json_extract(data, '$.tokens.input'), 0) +
1884
1981
  COALESCE(json_extract(data, '$.tokens.output'), 0) +
@@ -1896,16 +1993,42 @@ async function scanOpenCodeIncremental() {
1896
1993
  LIMIT 1000;
1897
1994
  `.replace(/\n/g, ' ');
1898
1995
  db = new Database(OPENCODE_DB);
1996
+ const account = getOpenCodeAccount(db);
1899
1997
  const rows = db.prepare(query).all();
1900
- const entries = [];
1901
- for (const row of rows) {
1998
+ // Two passes. First derive each row's identity + per-session stamp, then
1999
+ // bulk-load the prior stamps in ONE query and keep only the rows that
2000
+ // actually changed. Skipped rows never reach upsertSessionsBatch, so they
2001
+ // never pay its per-entry `parseSession` re-open of this same DB.
2002
+ const asInt = (v) => typeof v === 'number' ? v : parseInt(String(v), 10);
2003
+ const candidates = rows.flatMap(row => {
1902
2004
  const id = typeof row.id === 'string' ? row.id : '';
1903
2005
  if (!id)
1904
- continue;
2006
+ return [];
2007
+ const filePath = `${OPENCODE_DB}#${id}`;
2008
+ return [{
2009
+ row,
2010
+ id,
2011
+ filePath,
2012
+ scan: openCodeSessionStamp({
2013
+ timeUpdated: asInt(row.time_updated),
2014
+ timeCreated: asInt(row.time_created),
2015
+ lastMessageAt: asInt(row.last_message_at),
2016
+ lastPartAt: asInt(row.last_part_at),
2017
+ }, { messageBytes: asInt(row.message_bytes), partBytes: asInt(row.part_bytes) }, currentScan),
2018
+ }];
2019
+ });
2020
+ const priorStamps = getScanStampsForPaths(candidates.map(c => c.filePath));
2021
+ const changed = candidates.filter(c => {
2022
+ const prevStamp = priorStamps.get(c.filePath);
2023
+ return !prevStamp
2024
+ || prevStamp.fileMtimeMs !== c.scan.fileMtimeMs
2025
+ || prevStamp.fileSize !== c.scan.fileSize;
2026
+ });
2027
+ const entries = [];
2028
+ for (const { row, id, filePath, scan } of changed) {
1905
2029
  const title = typeof row.title === 'string' ? row.title : '';
1906
2030
  const directory = typeof row.directory === 'string' ? row.directory : '';
1907
2031
  const version = typeof row.version === 'string' ? row.version : '';
1908
- const asInt = (v) => typeof v === 'number' ? v : parseInt(String(v), 10);
1909
2032
  const timeCreated = asInt(row.time_created);
1910
2033
  const timeUpdated = asInt(row.time_updated);
1911
2034
  const messageCount = asInt(row.message_count);
@@ -1926,7 +2049,7 @@ async function scanOpenCodeIncremental() {
1926
2049
  lastActivity,
1927
2050
  project: directory ? path.basename(directory) : undefined,
1928
2051
  cwd: directory ? normalizeCwd(directory) : undefined,
1929
- filePath: `${OPENCODE_DB}#${id}`,
2052
+ filePath,
1930
2053
  version: resolveSessionVersion('opencode', OPENCODE_DB, version || undefined, currentVersion),
1931
2054
  account,
1932
2055
  topic,
@@ -1934,7 +2057,7 @@ async function scanOpenCodeIncremental() {
1934
2057
  tokenCount: hasTokenData && !Number.isNaN(tokenCount) ? tokenCount : undefined,
1935
2058
  outputTokens: hasTokenData && !Number.isNaN(outputTokens) ? outputTokens : undefined,
1936
2059
  };
1937
- entries.push({ meta, content: topic || '', scan: currentScan });
2060
+ entries.push({ meta, content: topic || '', scan });
1938
2061
  }
1939
2062
  upsertSessionsBatch(entries);
1940
2063
  // Stamp the OpenCode DB itself so we can short-circuit on the next run.
@@ -1957,13 +2080,12 @@ async function scanOpenCodeIncremental() {
1957
2080
  // ---------------------------------------------------------------------------
1958
2081
  /** Scan active OpenClaw channels and cron jobs via the openclaw CLI. */
1959
2082
  async function scanOpenClawIncremental() {
1960
- // Check if openclaw is installed — silently skip if not.
1961
- try {
1962
- await execFileAsync('which', ['openclaw']);
1963
- }
1964
- catch {
2083
+ // Check if openclaw is installed — silently skip if not. `which` is POSIX-only
2084
+ // (Windows resolves PATH with `where`), so a bare `which` throws ENOENT on every
2085
+ // Windows run and silently disabled the entire OpenClaw scan there — its sessions
2086
+ // never reached the index (RUSH-2286). hasCommand() probes cross-platform.
2087
+ if (!hasCommand('openclaw'))
1965
2088
  return;
1966
- }
1967
2089
  // TTL cache: skip subprocess calls if we scanned recently. Stored in the
1968
2090
  // meta table so we skip even when no channels/cron exist to produce rows.
1969
2091
  const db = getDB();
@@ -1977,8 +2099,13 @@ async function scanOpenClawIncremental() {
1977
2099
  const scan = { fileMtimeMs: now, fileSize: 0 };
1978
2100
  const entries = [];
1979
2101
  try {
1980
- const { stdout: output } = await execFileAsync('openclaw', ['channels', 'status'], {
2102
+ // On Windows `openclaw` resolves to a .cmd/.ps1 shim that execFile can't launch
2103
+ // directly; execFileShellSpec composes a shell-safe invocation there and is a
2104
+ // no-op passthrough on POSIX (RUSH-2286).
2105
+ const channelsSpec = execFileShellSpec('openclaw', ['channels', 'status']);
2106
+ const { stdout: output } = await execFileAsync(channelsSpec.command, channelsSpec.args, {
1981
2107
  encoding: 'utf-8',
2108
+ shell: channelsSpec.shell,
1982
2109
  });
1983
2110
  for (const line of output.split('\n')) {
1984
2111
  const match = line.match(/^-\s+\w+\s+(\S+)\s+\((\w+)\):\s*(.+)/);
@@ -2007,8 +2134,10 @@ async function scanOpenClawIncremental() {
2007
2134
  /* channels command failed */
2008
2135
  }
2009
2136
  try {
2010
- const { stdout: output } = await execFileAsync('openclaw', ['cron', 'list'], {
2137
+ const cronSpec = execFileShellSpec('openclaw', ['cron', 'list']);
2138
+ const { stdout: output } = await execFileAsync(cronSpec.command, cronSpec.args, {
2011
2139
  encoding: 'utf-8',
2140
+ shell: cronSpec.shell,
2012
2141
  });
2013
2142
  const lines = output.split('\n');
2014
2143
  for (let i = 1; i < lines.length; i++) {
@@ -2514,15 +2643,19 @@ async function readDroidMeta(filePath, currentVersion) {
2514
2643
  const settings = readDroidSettings(filePath.replace(/\.jsonl$/, '.settings.json'));
2515
2644
  const model = settings.model || scan.model;
2516
2645
  const tokenCount = settings.tokenCount;
2517
- const costUsd = model && settings.usage
2518
- ? costOfUsage({
2646
+ // Droid records a full split (input / cache-read / cache-write / output) in its
2647
+ // settings sidecar, so both the actual and no-cache cost are derivable (RUSH-2287).
2648
+ const usageForCost = model && settings.usage
2649
+ ? {
2519
2650
  model,
2520
2651
  inputTokens: settings.usage.inputTokens,
2521
2652
  outputTokens: settings.usage.outputTokens,
2522
2653
  cacheReadTokens: settings.usage.cacheReadTokens,
2523
2654
  cacheCreationTokens: settings.usage.cacheCreationTokens,
2524
- })
2525
- : 0;
2655
+ }
2656
+ : undefined;
2657
+ const costUsd = usageForCost ? costOfUsage(usageForCost) : 0;
2658
+ const costUsdNoCache = usageForCost ? costOfUsageNoCache(usageForCost) : 0;
2526
2659
  const stat = safeStatSync(filePath);
2527
2660
  const cwd = normalizeCwd(scan.cwd || '');
2528
2661
  const meta = {
@@ -2540,7 +2673,11 @@ async function readDroidMeta(filePath, currentVersion) {
2540
2673
  messageCount: scan.messageCount,
2541
2674
  tokenCount,
2542
2675
  outputTokens: settings.usage?.outputTokens,
2676
+ inputTokens: settings.usage?.inputTokens,
2677
+ cacheReadTokens: settings.usage?.cacheReadTokens,
2678
+ cacheWriteTokens: settings.usage?.cacheCreationTokens,
2543
2679
  costUsd: costUsd > 0 ? costUsd : undefined,
2680
+ costUsdNoCache: costUsd > 0 ? costUsdNoCache : undefined,
2544
2681
  durationMs: scan.durationMs,
2545
2682
  };
2546
2683
  return { meta, content: scan.contentText || '' };
@@ -2688,8 +2825,12 @@ export function initClaudeParseState() {
2688
2825
  toolCallCount: 0,
2689
2826
  tokenCount: 0,
2690
2827
  outputTokens: 0,
2828
+ inputTokens: 0,
2829
+ cacheReadTokens: 0,
2830
+ cacheWriteTokens: 0,
2691
2831
  sawTokenCount: false,
2692
2832
  costUsd: 0,
2833
+ costUsdNoCache: 0,
2693
2834
  sawCost: false,
2694
2835
  firstTsMs: undefined,
2695
2836
  lastTsMs: undefined,
@@ -2886,21 +3027,34 @@ export function applyClaudeLine(state, parsed) {
2886
3027
  }
2887
3028
  if (typeof usageObj?.output_tokens === 'number')
2888
3029
  state.outputTokens += usageObj.output_tokens;
3030
+ // Burn split (RUSH-2287): accumulate the raw directional counts so `agents
3031
+ // output` can report uncached-input / cache-read / cache-write separately.
3032
+ if (usageObj && typeof usageObj === 'object') {
3033
+ if (typeof usageObj.input_tokens === 'number')
3034
+ state.inputTokens += usageObj.input_tokens;
3035
+ if (typeof usageObj.cache_read_input_tokens === 'number')
3036
+ state.cacheReadTokens += usageObj.cache_read_input_tokens;
3037
+ if (typeof usageObj.cache_creation_input_tokens === 'number')
3038
+ state.cacheWriteTokens += usageObj.cache_creation_input_tokens;
3039
+ }
2889
3040
  // Per-assistant-message cost: each event carries its own model, so we
2890
3041
  // multiply that event's raw token directions by that model's price.
2891
3042
  const model = parsed.message?.model;
2892
3043
  if (typeof model === 'string' && model)
2893
3044
  state.model = model;
2894
3045
  if (model && usageObj && typeof usageObj === 'object') {
2895
- const eventCost = costOfUsage({
3046
+ const usageForCost = {
2896
3047
  model,
2897
3048
  inputTokens: usageObj.input_tokens,
2898
3049
  outputTokens: usageObj.output_tokens,
2899
3050
  cacheReadTokens: usageObj.cache_read_input_tokens,
2900
3051
  cacheCreationTokens: usageObj.cache_creation_input_tokens,
2901
- });
3052
+ };
3053
+ const eventCost = costOfUsage(usageForCost);
2902
3054
  if (eventCost > 0) {
2903
3055
  state.costUsd += eventCost;
3056
+ // No-cache scenario: reprice this same event's cache tokens at the input rate.
3057
+ state.costUsdNoCache += costOfUsageNoCache(usageForCost);
2904
3058
  state.sawCost = true;
2905
3059
  }
2906
3060
  }
@@ -2930,7 +3084,11 @@ export function finalizeClaudeScan(state) {
2930
3084
  toolCallCount: state.toolCallCount,
2931
3085
  tokenCount: state.sawTokenCount ? state.tokenCount : undefined,
2932
3086
  outputTokens: state.sawTokenCount ? state.outputTokens : undefined,
3087
+ inputTokens: state.sawTokenCount ? state.inputTokens : undefined,
3088
+ cacheReadTokens: state.sawTokenCount ? state.cacheReadTokens : undefined,
3089
+ cacheWriteTokens: state.sawTokenCount ? state.cacheWriteTokens : undefined,
2933
3090
  costUsd: state.sawCost ? state.costUsd : undefined,
3091
+ costUsdNoCache: state.sawCost ? state.costUsdNoCache : undefined,
2934
3092
  durationMs,
2935
3093
  lastActivity: state.lastTsMs !== undefined ? new Date(state.lastTsMs).toISOString() : undefined,
2936
3094
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
@@ -2986,7 +3144,7 @@ export function serializeClaudeParserState(state, offset, jsonlDroppingOversized
2986
3144
  : allIds;
2987
3145
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
2988
3146
  return {
2989
- v: 2,
3147
+ v: 3,
2990
3148
  offset,
2991
3149
  jsonlDroppingOversizedLine: jsonlDroppingOversizedLine || undefined,
2992
3150
  timestamp: state.timestamp,
@@ -3005,9 +3163,13 @@ export function serializeClaudeParserState(state, offset, jsonlDroppingOversized
3005
3163
  toolCallCount: state.toolCallCount,
3006
3164
  tokenCount: state.tokenCount,
3007
3165
  outputTokens: state.outputTokens,
3166
+ inputTokens: state.inputTokens,
3167
+ cacheReadTokens: state.cacheReadTokens,
3168
+ cacheWriteTokens: state.cacheWriteTokens,
3008
3169
  sawTokenCount: state.sawTokenCount,
3009
3170
  sawCost: state.sawCost,
3010
3171
  costUsd: state.costUsd,
3172
+ costUsdNoCache: state.costUsdNoCache,
3011
3173
  seenIdsSize: state.seenAssistantIds.size,
3012
3174
  seenIdsRecent,
3013
3175
  sawPrCreate: state.sawPrCreate,
@@ -3067,8 +3229,12 @@ export function hydrateClaudeParseState(prior) {
3067
3229
  toolCallCount: prior.toolCallCount ?? 0,
3068
3230
  tokenCount: prior.tokenCount,
3069
3231
  outputTokens: prior.outputTokens,
3232
+ inputTokens: prior.inputTokens ?? 0,
3233
+ cacheReadTokens: prior.cacheReadTokens ?? 0,
3234
+ cacheWriteTokens: prior.cacheWriteTokens ?? 0,
3070
3235
  sawTokenCount: prior.sawTokenCount,
3071
3236
  costUsd: prior.costUsd,
3237
+ costUsdNoCache: prior.costUsdNoCache ?? 0,
3072
3238
  sawCost: prior.sawCost,
3073
3239
  firstTsMs: prior.firstTsMs,
3074
3240
  lastTsMs: prior.lastTsMs,
@@ -3217,7 +3383,7 @@ function parsePriorClaudeState(row) {
3217
3383
  return null;
3218
3384
  try {
3219
3385
  const parsed = JSON.parse(row.parserState);
3220
- if (parsed?.v !== 2 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
3386
+ if (parsed?.v !== 3 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
3221
3387
  return null;
3222
3388
  return parsed;
3223
3389
  }
@@ -3373,17 +3539,34 @@ export function applyCodexLine(state, parsed) {
3373
3539
  * exact return-building {@link scanCodexSession} used to run inline.
3374
3540
  */
3375
3541
  export function finalizeCodexScan(state) {
3376
- // Price the final cumulative token snapshot once, against the session model.
3542
+ // Codex reports one cumulative snapshot: uncached input, cached (cache-read)
3543
+ // input, and output+reasoning. It has no cache-write bucket. Derive the burn
3544
+ // split and both costs (actual + no-cache) from that final snapshot (RUSH-2287).
3545
+ const snap = state.lastTotalTokenUsage;
3546
+ const outputTokens = snap
3547
+ ? (snap.output_tokens ?? 0) + (snap.reasoning_output_tokens ?? 0)
3548
+ : undefined;
3377
3549
  let costUsd;
3378
- if (state.model && state.lastTotalTokenUsage) {
3379
- const c = costOfUsage({
3550
+ let costUsdNoCache;
3551
+ let inputTokens;
3552
+ let cacheReadTokens;
3553
+ if (snap) {
3554
+ inputTokens = typeof snap.input_tokens === 'number' ? snap.input_tokens : undefined;
3555
+ cacheReadTokens = typeof snap.cached_input_tokens === 'number' ? snap.cached_input_tokens : undefined;
3556
+ }
3557
+ // Price the final cumulative token snapshot once, against the session model.
3558
+ if (state.model && snap) {
3559
+ const usage = {
3380
3560
  model: state.model,
3381
- inputTokens: state.lastTotalTokenUsage.input_tokens,
3382
- outputTokens: (state.lastTotalTokenUsage.output_tokens ?? 0) + (state.lastTotalTokenUsage.reasoning_output_tokens ?? 0),
3383
- cacheReadTokens: state.lastTotalTokenUsage.cached_input_tokens,
3384
- });
3385
- if (c > 0)
3561
+ inputTokens: snap.input_tokens,
3562
+ outputTokens,
3563
+ cacheReadTokens: snap.cached_input_tokens,
3564
+ };
3565
+ const c = costOfUsage(usage);
3566
+ if (c > 0) {
3386
3567
  costUsd = c;
3568
+ costUsdNoCache = costOfUsageNoCache(usage);
3569
+ }
3387
3570
  }
3388
3571
  const durationMs = state.firstTsMs !== undefined && state.lastTsMs !== undefined && state.lastTsMs > state.firstTsMs
3389
3572
  ? state.lastTsMs - state.firstTsMs
@@ -3400,10 +3583,12 @@ export function finalizeCodexScan(state) {
3400
3583
  topic: state.topic,
3401
3584
  messageCount: state.messageCount,
3402
3585
  tokenCount: state.tokenCount,
3403
- outputTokens: state.lastTotalTokenUsage
3404
- ? (state.lastTotalTokenUsage.output_tokens ?? 0) + (state.lastTotalTokenUsage.reasoning_output_tokens ?? 0)
3405
- : undefined,
3586
+ outputTokens,
3587
+ inputTokens,
3588
+ cacheReadTokens,
3589
+ cacheWriteTokens: undefined,
3406
3590
  costUsd,
3591
+ costUsdNoCache,
3407
3592
  durationMs,
3408
3593
  lastActivity: state.lastTsMs !== undefined ? new Date(state.lastTsMs).toISOString() : undefined,
3409
3594
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
@@ -3902,7 +4087,7 @@ async function scanCursorIncremental(onProgress) {
3902
4087
  const result = readCursorMeta(filePath, currentVersion);
3903
4088
  if (result && !seen.has(result.meta.id)) {
3904
4089
  seen.add(result.meta.id);
3905
- entries.push({ meta: result.meta, content: result.content, scan });
4090
+ entries.push({ meta: result.meta, content: result.content, scan, events: result.events });
3906
4091
  }
3907
4092
  else {
3908
4093
  touched.push({ filePath, scan });
@@ -3998,7 +4183,7 @@ export function readCursorMeta(filePath, currentVersion) {
3998
4183
  messageCount: events.filter((event) => event.type === 'message').length,
3999
4184
  todos: extractTodoProgressFromEvents(events),
4000
4185
  };
4001
- return { meta, content: userTexts.join('\n') };
4186
+ return { meta, content: userTexts.join('\n'), events };
4002
4187
  }
4003
4188
  // ---------------------------------------------------------------------------
4004
4189
  // Kimi
@@ -4361,10 +4546,13 @@ export function readGrokMeta(filePath, currentVersion) {
4361
4546
  ? summary.num_messages
4362
4547
  : undefined;
4363
4548
  // Grok records its managed home in summary.grok_home
4364
- // (…/versions/grok/<version>/home/.grok) — recover the version from it.
4549
+ // (…/versions/grok/<version>/home/.grok) — recover the version from it. The
4550
+ // value is written by the Grok CLI in the writing host's native separators, so
4551
+ // a Windows-authored summary is backslash-separated; normalize to `/` before
4552
+ // matching or the version never resolves on Windows (RUSH-2286).
4365
4553
  let embeddedVersion;
4366
4554
  if (typeof summary?.grok_home === 'string') {
4367
- embeddedVersion = summary.grok_home.match(/versions\/grok\/([^/]+)\//)?.[1];
4555
+ embeddedVersion = summary.grok_home.replace(/\\/g, '/').match(/versions\/grok\/([^/]+)\//)?.[1];
4368
4556
  }
4369
4557
  const meta = {
4370
4558
  id: sessionId,