@phnx-labs/agents-cli 1.22.47 → 1.22.49

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 (249) hide show
  1. package/CHANGELOG.md +401 -17
  2. package/README.md +66 -32
  3. package/dist/bootstrap.js +4 -2
  4. package/dist/cli/command-registry.d.ts +1 -0
  5. package/dist/cli/command-registry.js +2 -0
  6. package/dist/commands/accounts.d.ts +58 -0
  7. package/dist/commands/accounts.js +206 -32
  8. package/dist/commands/artifacts.js +7 -6
  9. package/dist/commands/attach.js +1 -1
  10. package/dist/commands/browser.js +501 -103
  11. package/dist/commands/config.d.ts +0 -1
  12. package/dist/commands/config.js +0 -20
  13. package/dist/commands/daemon.d.ts +21 -0
  14. package/dist/commands/daemon.js +147 -12
  15. package/dist/commands/exec.js +52 -15
  16. package/dist/commands/focus.d.ts +14 -0
  17. package/dist/commands/focus.js +48 -5
  18. package/dist/commands/go.d.ts +3 -0
  19. package/dist/commands/go.js +16 -3
  20. package/dist/commands/inspect.js +2 -2
  21. package/dist/commands/lease.d.ts +1 -1
  22. package/dist/commands/lease.js +4 -3
  23. package/dist/commands/mailboxes.js +4 -3
  24. package/dist/commands/menubar.js +11 -4
  25. package/dist/commands/projects.d.ts +16 -1
  26. package/dist/commands/projects.js +58 -64
  27. package/dist/commands/resume.js +1 -1
  28. package/dist/commands/route.js +4 -4
  29. package/dist/commands/routines.js +3 -2
  30. package/dist/commands/run-broadcast.d.ts +1 -1
  31. package/dist/commands/run-broadcast.js +1 -1
  32. package/dist/commands/sessions-picker.d.ts +22 -0
  33. package/dist/commands/sessions-picker.js +48 -9
  34. package/dist/commands/sessions-trace.js +1 -1
  35. package/dist/commands/sessions.js +1 -0
  36. package/dist/commands/setup-secrets.js +23 -0
  37. package/dist/commands/share.d.ts +20 -5
  38. package/dist/commands/share.js +152 -52
  39. package/dist/commands/ssh.js +54 -7
  40. package/dist/commands/traces.d.ts +2 -0
  41. package/dist/commands/traces.js +184 -0
  42. package/dist/commands/view.d.ts +1 -1
  43. package/dist/commands/view.js +17 -34
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.js +5 -0
  46. package/dist/lib/accounting/usage.d.ts +14 -3
  47. package/dist/lib/accounting/usage.js +64 -18
  48. package/dist/lib/agent-spec/agents.js +11 -9
  49. package/dist/lib/browser/drivers/ssh.d.ts +16 -2
  50. package/dist/lib/browser/drivers/ssh.js +36 -9
  51. package/dist/lib/browser/ipc.js +8 -3
  52. package/dist/lib/browser/profiles.d.ts +35 -101
  53. package/dist/lib/browser/profiles.js +130 -301
  54. package/dist/lib/browser/registry.d.ts +39 -0
  55. package/dist/lib/browser/registry.js +149 -0
  56. package/dist/lib/browser/remote-control.d.ts +8 -6
  57. package/dist/lib/browser/remote-control.js +8 -6
  58. package/dist/lib/browser/resolve-target.d.ts +68 -0
  59. package/dist/lib/browser/resolve-target.js +250 -0
  60. package/dist/lib/browser/runtime-state.d.ts +40 -16
  61. package/dist/lib/browser/runtime-state.js +92 -25
  62. package/dist/lib/browser/service.d.ts +28 -9
  63. package/dist/lib/browser/service.js +139 -62
  64. package/dist/lib/browser/task-index.d.ts +70 -0
  65. package/dist/lib/browser/task-index.js +180 -0
  66. package/dist/lib/byok-usage.js +2 -2
  67. package/dist/lib/claude-statusline.d.ts +29 -0
  68. package/dist/lib/claude-statusline.js +139 -0
  69. package/dist/lib/cloud/host.d.ts +1 -1
  70. package/dist/lib/cloud/host.js +1 -1
  71. package/dist/lib/cloud/rush.js +1 -1
  72. package/dist/lib/computer/computer-rpc.js +2 -2
  73. package/dist/lib/computer/download.d.ts +15 -15
  74. package/dist/lib/computer/download.js +35 -98
  75. package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
  76. package/dist/lib/computer/ssh-tunnel.js +12 -22
  77. package/dist/lib/config-keys.d.ts +2 -8
  78. package/dist/lib/config-keys.js +2 -13
  79. package/dist/lib/crabbox/cli.d.ts +1 -1
  80. package/dist/lib/crabbox/cli.js +2 -2
  81. package/dist/lib/crabbox/lease.d.ts +9 -0
  82. package/dist/lib/crabbox/lease.js +70 -15
  83. package/dist/lib/crabbox/runtimes.js +1 -1
  84. package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
  85. package/dist/lib/daemon/account-state-daemon-service.js +26 -0
  86. package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
  87. package/dist/lib/daemon/browser-ipc-service.js +46 -0
  88. package/dist/lib/daemon/daemon.d.ts +3 -0
  89. package/dist/lib/daemon/daemon.js +212 -444
  90. package/dist/lib/daemon/device-probe-service.d.ts +26 -0
  91. package/dist/lib/daemon/device-probe-service.js +47 -0
  92. package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
  93. package/dist/lib/daemon/keychain-reap-service.js +32 -0
  94. package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
  95. package/dist/lib/daemon/monitor-engine-service.js +29 -0
  96. package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
  97. package/dist/lib/daemon/secrets-broker-service.js +66 -0
  98. package/dist/lib/daemon/self-heal-service.d.ts +27 -0
  99. package/dist/lib/daemon/self-heal-service.js +48 -0
  100. package/dist/lib/daemon/service.d.ts +102 -0
  101. package/dist/lib/daemon/service.js +98 -0
  102. package/dist/lib/daemon/session-index-service.d.ts +22 -0
  103. package/dist/lib/daemon/session-index-service.js +40 -0
  104. package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
  105. package/dist/lib/daemon/state-dir-check-service.js +63 -0
  106. package/dist/lib/daemon/supervisor.d.ts +87 -0
  107. package/dist/lib/daemon/supervisor.js +298 -0
  108. package/dist/lib/daemon/watchdog-service.d.ts +19 -0
  109. package/dist/lib/daemon/watchdog-service.js +40 -0
  110. package/dist/lib/daemon-health.d.ts +15 -0
  111. package/dist/lib/daemon-health.js +29 -5
  112. package/dist/lib/daemon-services.d.ts +8 -1
  113. package/dist/lib/daemon-services.js +55 -0
  114. package/dist/lib/daemon-ticks.d.ts +13 -2
  115. package/dist/lib/daemon-ticks.js +20 -18
  116. package/dist/lib/device-config.d.ts +0 -2
  117. package/dist/lib/device-config.js +1 -23
  118. package/dist/lib/devices/registry.d.ts +1 -1
  119. package/dist/lib/devices/registry.js +1 -1
  120. package/dist/lib/devices/stats-cache.d.ts +20 -0
  121. package/dist/lib/devices/stats-cache.js +49 -5
  122. package/dist/lib/devices/worker-pick.d.ts +48 -0
  123. package/dist/lib/devices/worker-pick.js +113 -0
  124. package/dist/lib/exec.d.ts +67 -13
  125. package/dist/lib/exec.js +85 -29
  126. package/dist/lib/harness/adapters/cursor.js +8 -11
  127. package/dist/lib/helper-download.d.ts +108 -0
  128. package/dist/lib/helper-download.js +194 -0
  129. package/dist/lib/helper-versions.d.ts +52 -0
  130. package/dist/lib/helper-versions.js +58 -0
  131. package/dist/lib/hooks/cache.js +11 -0
  132. package/dist/lib/hooks/install.js +2 -2
  133. package/dist/lib/hosts/credentials.d.ts +1 -1
  134. package/dist/lib/hosts/credentials.js +1 -1
  135. package/dist/lib/hosts/dispatch.d.ts +7 -1
  136. package/dist/lib/hosts/dispatch.js +24 -5
  137. package/dist/lib/hosts/passthrough.d.ts +8 -15
  138. package/dist/lib/hosts/passthrough.js +19 -16
  139. package/dist/lib/hosts/providers/devices.d.ts +1 -1
  140. package/dist/lib/hosts/providers/devices.js +1 -1
  141. package/dist/lib/hosts/reconnect.d.ts +179 -21
  142. package/dist/lib/hosts/reconnect.js +266 -45
  143. package/dist/lib/installations/migrate.d.ts +4 -5
  144. package/dist/lib/installations/migrate.js +6 -7
  145. package/dist/lib/installations/shims.d.ts +1 -1
  146. package/dist/lib/installations/shims.js +23 -13
  147. package/dist/lib/installations/versions.js +25 -2
  148. package/dist/lib/linear-autoclose.d.ts +1 -1
  149. package/dist/lib/linear-autoclose.js +1 -1
  150. package/dist/lib/loop.d.ts +2 -2
  151. package/dist/lib/loop.js +1 -1
  152. package/dist/lib/menubar/download-menubar.d.ts +47 -0
  153. package/dist/lib/menubar/download-menubar.js +60 -0
  154. package/dist/lib/menubar/install-menubar.d.ts +31 -7
  155. package/dist/lib/menubar/install-menubar.js +79 -21
  156. package/dist/lib/menubar/notify-desktop.d.ts +3 -3
  157. package/dist/lib/menubar/notify-desktop.js +6 -6
  158. package/dist/lib/model-tiers.d.ts +1 -1
  159. package/dist/lib/project-probe.d.ts +1 -1
  160. package/dist/lib/project-probe.js +1 -1
  161. package/dist/lib/project-pull.d.ts +1 -1
  162. package/dist/lib/project-pull.js +2 -2
  163. package/dist/lib/projects.d.ts +1 -1
  164. package/dist/lib/projects.js +1 -1
  165. package/dist/lib/sandbox.js +6 -7
  166. package/dist/lib/secrets/download-keychain.d.ts +47 -0
  167. package/dist/lib/secrets/download-keychain.js +70 -0
  168. package/dist/lib/secrets/install-helper.d.ts +20 -1
  169. package/dist/lib/secrets/install-helper.js +44 -3
  170. package/dist/lib/session/active.js +17 -1
  171. package/dist/lib/session/db.d.ts +11 -1
  172. package/dist/lib/session/db.js +138 -6
  173. package/dist/lib/session/discover.d.ts +21 -1
  174. package/dist/lib/session/discover.js +33 -5
  175. package/dist/lib/session/highlights.d.ts +49 -1
  176. package/dist/lib/session/highlights.js +95 -0
  177. package/dist/lib/session/host-link.d.ts +16 -2
  178. package/dist/lib/session/host-link.js +7 -1
  179. package/dist/lib/session/parse.js +33 -1
  180. package/dist/lib/session/remote/remote-list.d.ts +9 -0
  181. package/dist/lib/session/remote/remote-list.js +19 -2
  182. package/dist/lib/session/remote/watch.js +11 -2
  183. package/dist/lib/session/session-cache.d.ts +53 -0
  184. package/dist/lib/session/session-cache.js +88 -0
  185. package/dist/lib/session/state.js +1 -1
  186. package/dist/lib/session/tool-calls.d.ts +2 -0
  187. package/dist/lib/session/tool-calls.js +1 -1
  188. package/dist/lib/session/trajectory-html.d.ts +5 -1
  189. package/dist/lib/session/trajectory-html.js +42 -22
  190. package/dist/lib/session/trajectory-text.js +9 -2
  191. package/dist/lib/session/trajectory.js +53 -4
  192. package/dist/lib/session/types.d.ts +15 -0
  193. package/dist/lib/sha256-asset.d.ts +26 -0
  194. package/dist/lib/sha256-asset.js +41 -0
  195. package/dist/lib/share/backend.d.ts +95 -0
  196. package/dist/lib/share/backend.js +139 -0
  197. package/dist/lib/share/delete.d.ts +6 -2
  198. package/dist/lib/share/delete.js +8 -9
  199. package/dist/lib/share/html.d.ts +22 -0
  200. package/dist/lib/share/html.js +88 -0
  201. package/dist/lib/share/provision.d.ts +18 -4
  202. package/dist/lib/share/provision.js +38 -4
  203. package/dist/lib/share/publish.d.ts +39 -15
  204. package/dist/lib/share/publish.js +55 -26
  205. package/dist/lib/share/worker-template.js +171 -11
  206. package/dist/lib/sqlite.js +16 -3
  207. package/dist/lib/ssh-exec.d.ts +65 -0
  208. package/dist/lib/ssh-exec.js +120 -4
  209. package/dist/lib/startup/command-registry.js +1 -1
  210. package/dist/lib/startup/dev-build.js +3 -3
  211. package/dist/lib/state.d.ts +1 -1
  212. package/dist/lib/state.js +7 -3
  213. package/dist/lib/subagents-registry.js +1 -1
  214. package/dist/lib/terminal/index.d.ts +1 -1
  215. package/dist/lib/terminal/types.d.ts +1 -1
  216. package/dist/lib/terminal/types.js +1 -1
  217. package/dist/lib/tmux/orphan-reap.js +1 -1
  218. package/dist/lib/tmux/session.js +31 -0
  219. package/dist/lib/traces/backend.d.ts +29 -0
  220. package/dist/lib/traces/backend.js +46 -0
  221. package/dist/lib/traces/classify.d.ts +52 -0
  222. package/dist/lib/traces/classify.js +72 -0
  223. package/dist/lib/traces/config.d.ts +10 -0
  224. package/dist/lib/traces/config.js +2 -0
  225. package/dist/lib/traces/provision.d.ts +19 -0
  226. package/dist/lib/traces/provision.js +30 -0
  227. package/dist/lib/traces/sync.d.ts +150 -0
  228. package/dist/lib/traces/sync.js +447 -0
  229. package/dist/lib/traces/worker-template.d.ts +4 -0
  230. package/dist/lib/traces/worker-template.js +159 -0
  231. package/dist/lib/types.d.ts +18 -19
  232. package/dist/lib/types.js +14 -0
  233. package/dist/lib/usage-refresh.d.ts +1 -1
  234. package/dist/lib/usage-refresh.js +11 -8
  235. package/dist/lib/workflows.d.ts +1 -1
  236. package/package.json +4 -6
  237. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  238. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
  239. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  240. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  241. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
  242. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  243. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -26
  244. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  245. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  246. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -135
  247. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  248. package/dist/lib/usage-fleet.d.ts +0 -32
  249. package/dist/lib/usage-fleet.js +0 -125
@@ -0,0 +1,70 @@
1
+ /**
2
+ * On-demand download + verification of the macOS keychain broker helper
3
+ * ("Agents CLI.app").
4
+ *
5
+ * Mirrors the ComputerHelper / MenubarHelper download model
6
+ * (`../helper-download.ts`): the helper ships as a signed + notarized `.app`
7
+ * zipped as a GitHub release asset on the helper's own `keychain/v<x.y.z>` tag,
8
+ * NOT the CLI's tag (see `helper-versions.ts`). A fresh `npm i -g`
9
+ * machine whose tarball lacks a bundled copy fetches the asset for the running
10
+ * CLI version, verifies its sha256 + code signature before install.
11
+ *
12
+ * Unlike the menu-bar helper, the keychain helper pins NO designated
13
+ * requirement (no `expectedBundleId`). The menu-bar `expectedBundleId` exists
14
+ * because macOS keys the Accessibility (TCC) grant to the bundle's designated
15
+ * requirement, so a substituted bundle whose DR does not pin (id, team) would
16
+ * silently revoke that upgrade-stable grant. The keychain helper has no such
17
+ * DR-keyed grant to protect: its items are gated by the access-group
18
+ * entitlement (`keychain-helper.swift` → `kSecAttrAccessGroup`, the
19
+ * `keychain-access-groups` entitlement) plus a biometry `SecAccessControl`
20
+ * (user presence) — NOT a `SecTrustedApplication` ACL keyed to a bundle-id/DR.
21
+ * The release-time gate `scripts/verify-keychain-helper.sh` correspondingly
22
+ * pins the executable's sha256, not a designated requirement. So Team +
23
+ * notarization is the verification boundary here, exactly like ComputerHelper.
24
+ */
25
+ import { EXPECTED_TEAM_ID, downloadHelperApp, helperAssetUrls, helperCacheDir, verifyHelperApp, } from '../helper-download.js';
26
+ /** The zipped `.app` release asset name (the bundle name has a space). */
27
+ // Underscore, and NOT a space. GitHub rewrites a space in a release-asset name
28
+ // to a dot on upload, so an asset staged as `Agents CLI.app.zip` is SERVED as
29
+ // `Agents.CLI.app.zip` and a client asking for the spaced name 404s forever --
30
+ // which is exactly what shipped. The fix is to CHOOSE a name GitHub leaves
31
+ // alone rather than to mirror its rewrite: `Agents.CLI.app.zip` would work by
32
+ // matching an artifact of that behavior, and would silently break if the
33
+ // normalization rule ever changed. Underscores are preserved verbatim (verified
34
+ // against a real upload).
35
+ //
36
+ // The bundle DIRECTORY inside the zip keeps its space (`appName` below) -- that
37
+ // is the on-disk bundle name macOS and the TCC grant key on, and it is unchanged.
38
+ export const KEYCHAIN_HELPER_ASSET = 'Agents_CLI.app.zip';
39
+ /** The bundle directory name once extracted. */
40
+ export const KEYCHAIN_HELPER_APP_NAME = 'Agents CLI.app';
41
+ /** Keychain-helper identity + verification policy — Team + notarization only,
42
+ * no DR pin (see docblock). */
43
+ export const KEYCHAIN_HELPER_SPEC = {
44
+ helper: 'keychain',
45
+ assetName: KEYCHAIN_HELPER_ASSET,
46
+ appName: KEYCHAIN_HELPER_APP_NAME,
47
+ cacheSubdir: ['secrets', 'mac-helper'],
48
+ expectedTeamId: EXPECTED_TEAM_ID,
49
+ localBuildHint: 'cli/scripts/build-keychain-helper.sh',
50
+ };
51
+ /** Release-asset URLs for the keychain helper zip + its checksum at `v<version>`. */
52
+ export function keychainHelperAssetUrls(version) {
53
+ return helperAssetUrls(KEYCHAIN_HELPER_SPEC, version);
54
+ }
55
+ /** Cache dir for the downloaded keychain helper, one subdir per release tag. */
56
+ export function keychainHelperCacheDir(version) {
57
+ return helperCacheDir(KEYCHAIN_HELPER_SPEC, version);
58
+ }
59
+ /** Verify a keychain helper `.app`: codesign + Team + notarization (no DR pin). */
60
+ export function verifyKeychainHelper(appPath) {
61
+ verifyHelperApp(appPath, KEYCHAIN_HELPER_SPEC);
62
+ }
63
+ /**
64
+ * Download the keychain helper release asset for `version`, verify sha256 +
65
+ * signature, and return the path to the extracted `Agents CLI.app` in the
66
+ * cache. A missing asset is a hard error naming the exact tag.
67
+ */
68
+ export function downloadKeychainHelperApp(version) {
69
+ return downloadHelperApp(KEYCHAIN_HELPER_SPEC, version);
70
+ }
@@ -29,6 +29,20 @@ export declare function setInstallRootForTest(dir: string | null): string | null
29
29
  export declare function ensureKeychainHelperInstalled(opts?: {
30
30
  forceReinstall?: boolean;
31
31
  }): void;
32
+ /**
33
+ * ASYNC, download-capable install — the EXPLICIT onboarding/upgrade path
34
+ * (`agents setup secrets`, the bootstrap upgrade self-heal). When a bundled or
35
+ * locally-built `.app` ships (the common case, and always true this release
36
+ * stage), it installs that copy with NO network. When the tarball lacks the
37
+ * bundle (a fresh `npm i -g` whose tarball dropped it), it fetches the signed +
38
+ * notarized release asset for the running CLI version — sha256 + codesign +
39
+ * Team + notarization verified before install — and installs from the cached
40
+ * copy. The sync `getKeychainHelperPath()` never routes here: it stays sync +
41
+ * network-free and fails loud toward this command when no source exists.
42
+ */
43
+ export declare function ensureKeychainHelperInstalledAsync(opts?: {
44
+ forceReinstall?: boolean;
45
+ }): Promise<void>;
32
46
  /**
33
47
  * Return the absolute path to the helper executable. If the installed bundle
34
48
  * is missing, or is stale relative to the bundled source helper, performs a
@@ -37,7 +51,12 @@ export declare function ensureKeychainHelperInstalled(opts?: {
37
51
  * `npm i -g`, so this call site is the only one every machine is guaranteed to
38
52
  * pass through.
39
53
  *
40
- * Throws on non-darwin.
54
+ * SYNC and network-free by design: this is the secrets hot path, called by
55
+ * every keychain op. It never downloads. When no bundled/local source exists
56
+ * (a tarball that dropped the bundle), the reinstall's `sourceAppPath()` throws
57
+ * loud, directing the user to `agents setup secrets` — the explicit, async,
58
+ * download-capable path (`ensureKeychainHelperInstalledAsync`). Throws on
59
+ * non-darwin.
41
60
  */
42
61
  export declare function getKeychainHelperPath(): string;
43
62
  /** Diagnostic snapshot of the installed Keychain helper. */
@@ -19,6 +19,8 @@ import * as fs from 'fs';
19
19
  import * as os from 'os';
20
20
  import * as path from 'path';
21
21
  import { copyAppBundle, withInstallLock } from '../app-bundle-install.js';
22
+ import { downloadKeychainHelperApp } from './download-keychain.js';
23
+ import { helperFloor } from '../helper-versions.js';
22
24
  const APP_BUNDLE_NAME = 'Agents CLI.app';
23
25
  const INSTALL_DIR_NAME = 'agents-cli';
24
26
  let installRootOverride = null;
@@ -60,7 +62,18 @@ function sourceAppPath() {
60
62
  return c;
61
63
  }
62
64
  throw new Error(`Source ${APP_BUNDLE_NAME} not found. Looked in:\n ${candidates.join('\n ')}\n` +
63
- 'The npm package may have been built without the signed helper bundle. Reinstall agents-cli.');
65
+ 'The npm package shipped without the bundled keychain helper. Run `agents setup secrets` to ' +
66
+ 'download the signed helper for this version, or reinstall agents-cli.');
67
+ }
68
+ /** The bundled/local source `.app`, or null when absent (no throw) — the async
69
+ * download path resolves the missing bundle instead of failing. */
70
+ function bundledSourceAppPathOrNull() {
71
+ try {
72
+ return sourceAppPath();
73
+ }
74
+ catch {
75
+ return null;
76
+ }
64
77
  }
65
78
  function assertDarwin() {
66
79
  if (process.platform !== 'darwin') {
@@ -122,6 +135,30 @@ function spctlAssess(appPath) {
122
135
  */
123
136
  export function ensureKeychainHelperInstalled(opts = {}) {
124
137
  assertDarwin();
138
+ installKeychainHelperFromSource(sourceAppPath(), opts);
139
+ }
140
+ /**
141
+ * ASYNC, download-capable install — the EXPLICIT onboarding/upgrade path
142
+ * (`agents setup secrets`, the bootstrap upgrade self-heal). When a bundled or
143
+ * locally-built `.app` ships (the common case, and always true this release
144
+ * stage), it installs that copy with NO network. When the tarball lacks the
145
+ * bundle (a fresh `npm i -g` whose tarball dropped it), it fetches the signed +
146
+ * notarized release asset for the running CLI version — sha256 + codesign +
147
+ * Team + notarization verified before install — and installs from the cached
148
+ * copy. The sync `getKeychainHelperPath()` never routes here: it stays sync +
149
+ * network-free and fails loud toward this command when no source exists.
150
+ */
151
+ export async function ensureKeychainHelperInstalledAsync(opts = {}) {
152
+ assertDarwin();
153
+ const bundled = bundledSourceAppPathOrNull();
154
+ const src = bundled ?? (await downloadKeychainHelperApp(helperFloor('keychain')));
155
+ installKeychainHelperFromSource(src, opts);
156
+ }
157
+ /**
158
+ * Copy `src` (a verified `.app` bundle) to the stable user path, then verify.
159
+ * Shared by the sync (bundled-source) and async (download-capable) installers.
160
+ */
161
+ function installKeychainHelperFromSource(src, opts = {}) {
125
162
  const dest = installedAppPath();
126
163
  const upToDate = () => fs.existsSync(dest) && codesignVerify(dest).ok && !installedHelperIsStale();
127
164
  // Fast path: already installed, valid, and current — no lock, no copy.
@@ -134,7 +171,6 @@ export function ensureKeychainHelperInstalled(opts = {}) {
134
171
  withInstallLock(dest, (heartbeat) => {
135
172
  if (!opts.forceReinstall && upToDate())
136
173
  return;
137
- const src = sourceAppPath();
138
174
  copyAppBundle(src, dest);
139
175
  heartbeat(); // cp -R done; keep the lock fresh across the codesign/spctl spawns
140
176
  const verify = codesignVerify(dest);
@@ -160,7 +196,12 @@ export function ensureKeychainHelperInstalled(opts = {}) {
160
196
  * `npm i -g`, so this call site is the only one every machine is guaranteed to
161
197
  * pass through.
162
198
  *
163
- * Throws on non-darwin.
199
+ * SYNC and network-free by design: this is the secrets hot path, called by
200
+ * every keychain op. It never downloads. When no bundled/local source exists
201
+ * (a tarball that dropped the bundle), the reinstall's `sourceAppPath()` throws
202
+ * loud, directing the user to `agents setup secrets` — the explicit, async,
203
+ * download-capable path (`ensureKeychainHelperInstalledAsync`). Throws on
204
+ * non-darwin.
164
205
  */
165
206
  export function getKeychainHelperPath() {
166
207
  assertDarwin();
@@ -2068,10 +2068,26 @@ export function foldHostLink(rows) {
2068
2068
  // disproves. A stored record never yields `attached` (it is background or
2069
2069
  // parked), so clearing only that value drops the derived lie and leaves a
2070
2070
  // real detach record untouched.
2071
- if (link !== 'connected' && s.presence === 'attached')
2071
+ // Only a POSITIVE loss signal disproves a derived `attached`. `unknown` means
2072
+ // we never looked — a bare terminal with no IDE window and no tmux has
2073
+ // neither input — so it must not clear presence, or every plain-terminal
2074
+ // session the user is sitting in would lose its `attached` marker.
2075
+ if ((link === 'no-client' || link === 'host-gone') && s.presence === 'attached') {
2072
2076
  s.presence = undefined;
2077
+ }
2073
2078
  if (link === 'host-gone' && s.status === 'closed')
2074
2079
  s.status = 'crashed';
2080
+ // Only idle/input_required are promoted — a `running` session keeps its
2081
+ // status (SES-18a). Extending this to a running agent with no client was
2082
+ // tried and reverted: since RUSH-3125 wraps every remote interactive run in
2083
+ // a detached tmux pane, "running with zero attached clients" is the NORMAL
2084
+ // steady state between check-ins, and that path writes no detach record, so
2085
+ // `deliberatelyDetached` is false for it. Promoting it would relabel every
2086
+ // remote agent as orphaned whenever nobody is looking — the over-reporting
2087
+ // this file's header calls worthless. Telling a stranded agent from a
2088
+ // healthy unattended one needs to know a client was EXPECTED and LOST, which
2089
+ // no signal available here carries; that belongs with the peer-side pane
2090
+ // ownership work, not this function.
2075
2091
  else if (link === 'no-client' && (s.status === 'idle' || s.status === 'input_required')) {
2076
2092
  s.status = 'orphaned';
2077
2093
  }
@@ -12,7 +12,7 @@ import { type IndexedToolCall } from './tool-calls.js';
12
12
  /** Current schema version; bumped when migrations are added. Exported so tests
13
13
  * assert against the constant instead of hardcoding a number that every bump
14
14
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
15
- export declare const SCHEMA_VERSION = 39;
15
+ export declare const SCHEMA_VERSION = 40;
16
16
  /**
17
17
  * Bumping this invalidates every cached facet row without touching the schema
18
18
  * version, so a change to the extraction logic (a new metric, a corrected bucket)
@@ -21,6 +21,7 @@ export declare const SCHEMA_VERSION = 39;
21
21
  */
22
22
  /** Bump when facet extraction changes so cached rows recompute (stalls-by-model v6). */
23
23
  export declare const INSIGHTS_EXTRACTOR_VERSION = 6;
24
+ export declare const SESSION_TOPIC_EXTRACTOR_VERSION = 1;
24
25
  /** File stat snapshot used to detect changes between scan runs. */
25
26
  export interface ScanStamp {
26
27
  fileMtimeMs: number;
@@ -338,6 +339,15 @@ export declare function writeSessionInsights<T>(entries: Array<{
338
339
  }>): void;
339
340
  /** Drop every cached facet row. Backs `agents insights --refresh`. */
340
341
  export declare function clearSessionInsights(): void;
342
+ /** Read cached trace topics only when their transcript byte stamps still match. */
343
+ export declare function readSessionTopics<T>(ids: string[]): Map<string, T>;
344
+ /** Persist trace topics against the exact transcript bytes used to classify them. */
345
+ export declare function writeSessionTopics<T>(entries: Array<{
346
+ id: string;
347
+ fileMtimeMs: number | null;
348
+ fileSize: number | null;
349
+ topic: T;
350
+ }>): void;
341
351
  /** Read one derived preview only when it matches the transcript bytes on disk. */
342
352
  export declare function readSessionPreviewCache<T>(id: string, sourceStamp: {
343
353
  fileMtimeMs: number | null;
@@ -18,7 +18,7 @@ import { loadSessionActorIndex, readSessionActorRecord } from './actor-sidecar.j
18
18
  import { toolCallsFromEvents } from './tool-calls.js';
19
19
  import { persistToolCalls, toolEvidenceSourcePath } from './tool-store.js';
20
20
  import { buildClaudeAccountIndex, resolveClaudeAccount } from './claude-accounts.js';
21
- import { extractSkills, extractSlashCommands } from './highlights.js';
21
+ import { extractBackgroundShells, extractSkills, extractSlashCommands, harnessTracksBackgroundShells, isSubAgentTool, } from './highlights.js';
22
22
  import { resolveResource } from '../resources.js';
23
23
  import { discoverPlugins } from '../plugins/plugins.js';
24
24
  import { machineId } from '../machine-id.js';
@@ -27,7 +27,7 @@ const DB_PATH = getSessionsDbPath();
27
27
  /** Current schema version; bumped when migrations are added. Exported so tests
28
28
  * assert against the constant instead of hardcoding a number that every bump
29
29
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
30
- export const SCHEMA_VERSION = 39;
30
+ export const SCHEMA_VERSION = 40;
31
31
  /**
32
32
  * Bump to force `agents sessions backfill resources` to re-derive every
33
33
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -100,6 +100,8 @@ CREATE TABLE IF NOT EXISTS sessions (
100
100
  worktree_slug TEXT,
101
101
  ticket_id TEXT,
102
102
  spawned_team TEXT,
103
+ sub_agent_count INTEGER,
104
+ background_shell_count INTEGER,
103
105
  plan TEXT,
104
106
  machine TEXT,
105
107
  todos TEXT,
@@ -314,6 +316,17 @@ CREATE TABLE IF NOT EXISTS session_insights (
314
316
  facets TEXT NOT NULL
315
317
  );
316
318
 
319
+ -- Derived topic classification for traces sync. Like session_insights, this is
320
+ -- a lazy, stamp-validated cache and is intentionally independent of SCHEMA_VERSION.
321
+ CREATE TABLE IF NOT EXISTS session_topics (
322
+ session_id TEXT PRIMARY KEY,
323
+ file_mtime_ms INTEGER,
324
+ file_size INTEGER,
325
+ extractor_version INTEGER NOT NULL,
326
+ computed_at INTEGER NOT NULL,
327
+ topic_json TEXT NOT NULL
328
+ );
329
+
317
330
  -- Normalized data behind sessions preview. Like session_insights this is a
318
331
  -- lazy, stamp-validated cache: opening one session parses only that transcript,
319
332
  -- while subsequent processes reuse the derived preview until its bytes change.
@@ -397,6 +410,7 @@ CREATE INDEX IF NOT EXISTS idx_computer_sessions_started ON computer_sessions(st
397
410
  */
398
411
  /** Bump when facet extraction changes so cached rows recompute (stalls-by-model v6). */
399
412
  export const INSIGHTS_EXTRACTOR_VERSION = 6;
413
+ export const SESSION_TOPIC_EXTRACTOR_VERSION = 1;
400
414
  const PREVIEW_EXTRACTOR_VERSION = 1;
401
415
  let dbInstance = null;
402
416
  /**
@@ -1037,6 +1051,27 @@ function migrateSchema(db, fromVersion) {
1037
1051
  CREATE INDEX IF NOT EXISTS idx_computer_sessions_started ON computer_sessions(started_at DESC);
1038
1052
  `);
1039
1053
  }
1054
+ if (fromVersion < 40) {
1055
+ // v39 -> v40: persist the fan-out a session left behind — sub-agents spawned
1056
+ // and shells backgrounded (RUSH-3091/3095). Both were recomputed per render
1057
+ // from the transcript, so a REMOTE or unindexed row (rendered from
1058
+ // SessionMeta alone by sessions-picker's formatMetaOnlyBody, which has no
1059
+ // events to derive from) showed neither. Columns are what make them
1060
+ // renderable there.
1061
+ //
1062
+ // New nullable columns, so no ledger flush — the v33->v34 contract that
1063
+ // adding a column keeps warm session ledgers warm. Pre-upgrade rows stay
1064
+ // NULL until re-scanned, and NULL is deliberately distinct from 0: NULL is
1065
+ // "not computed / harness cannot report it", 0 is "scanned, none found".
1066
+ // The renders omit the segment for both, so neither can be misread as a
1067
+ // positive "nothing is running" claim.
1068
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
1069
+ if (!cols.has('sub_agent_count'))
1070
+ db.exec(`ALTER TABLE sessions ADD COLUMN sub_agent_count INTEGER`);
1071
+ if (!cols.has('background_shell_count')) {
1072
+ db.exec(`ALTER TABLE sessions ADD COLUMN background_shell_count INTEGER`);
1073
+ }
1074
+ }
1040
1075
  }
1041
1076
  /**
1042
1077
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its
@@ -1554,7 +1589,8 @@ const upsertSessionStmt = (db) => db.prepare(`
1554
1589
  output_tokens, input_tokens, cache_read_tokens, cache_write_tokens,
1555
1590
  cost_usd, cost_usd_nocache, duration_ms, model, tool_call_count,
1556
1591
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
1557
- pr_url, pr_number, worktree_slug, ticket_id, spawned_team, plan, todos,
1592
+ pr_url, pr_number, worktree_slug, ticket_id, spawned_team,
1593
+ sub_agent_count, background_shell_count, plan, todos,
1558
1594
  recent_directories_touched, linear_project, linear_project_url, machine,
1559
1595
  actor, initiated_by, used_browser, used_computer
1560
1596
  ) VALUES (
@@ -1564,7 +1600,8 @@ const upsertSessionStmt = (db) => db.prepare(`
1564
1600
  @output_tokens, @input_tokens, @cache_read_tokens, @cache_write_tokens,
1565
1601
  @cost_usd, @cost_usd_nocache, @duration_ms, @model, @tool_call_count,
1566
1602
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
1567
- @pr_url, @pr_number, @worktree_slug, @ticket_id, @spawned_team, @plan, @todos,
1603
+ @pr_url, @pr_number, @worktree_slug, @ticket_id, @spawned_team,
1604
+ @sub_agent_count, @background_shell_count, @plan, @todos,
1568
1605
  @recent_directories_touched, @linear_project, @linear_project_url, @machine,
1569
1606
  @actor, @initiated_by, @used_browser, @used_computer
1570
1607
  )
@@ -1615,6 +1652,8 @@ const upsertSessionStmt = (db) => db.prepare(`
1615
1652
  worktree_slug = excluded.worktree_slug,
1616
1653
  ticket_id = excluded.ticket_id,
1617
1654
  spawned_team = excluded.spawned_team,
1655
+ sub_agent_count = COALESCE(excluded.sub_agent_count, sessions.sub_agent_count),
1656
+ background_shell_count = COALESCE(excluded.background_shell_count, sessions.background_shell_count),
1618
1657
  plan = excluded.plan,
1619
1658
  todos = excluded.todos,
1620
1659
  recent_directories_touched = excluded.recent_directories_touched,
@@ -1752,6 +1791,30 @@ function writeResourceUsageFromTallies(sessionId, skills, commands, cwd) {
1752
1791
  function writeResourceUsage(sessionId, events, cwd) {
1753
1792
  writeResourceUsageFromTallies(sessionId, extractSkills(events), extractSlashCommands(events), cwd);
1754
1793
  }
1794
+ /**
1795
+ * Fan-out the session left behind, from an ALREADY-parsed transcript (never a
1796
+ * re-parse — every caller here has `events` in hand).
1797
+ *
1798
+ * `backgroundShellCount` is `undefined`, not 0, for a harness that cannot report
1799
+ * backgrounded shells. That distinction is the whole point: 0 asserts "none were
1800
+ * started", while undefined says "this harness does not record it" — and the
1801
+ * renders omit the segment for both, so neither can read as "nothing is running".
1802
+ */
1803
+ function fanOutCounts(events, agent) {
1804
+ let subAgentCount = 0;
1805
+ for (const e of events) {
1806
+ if (e.type !== 'tool_use' || e._local)
1807
+ continue;
1808
+ if (isSubAgentTool(e.tool || '', e.command || ''))
1809
+ subAgentCount++;
1810
+ }
1811
+ return {
1812
+ subAgentCount,
1813
+ backgroundShellCount: harnessTracksBackgroundShells(agent)
1814
+ ? extractBackgroundShells(events).length
1815
+ : undefined,
1816
+ };
1817
+ }
1755
1818
  function enrichCachedSessionMeta(meta) {
1756
1819
  if (!meta.filePath)
1757
1820
  return meta;
@@ -1762,6 +1825,7 @@ function enrichCachedSessionMeta(meta) {
1762
1825
  ...meta,
1763
1826
  todos: extractTodoProgressFromEvents(events),
1764
1827
  recentDirectoriesTouched: extractRecentDirectoriesTouched(events, meta.cwd),
1828
+ ...fanOutCounts(events, meta.agent),
1765
1829
  };
1766
1830
  }
1767
1831
  catch {
@@ -1858,6 +1922,8 @@ export function upsertSession(meta, content, scan) {
1858
1922
  worktree_slug: meta.worktreeSlug ?? null,
1859
1923
  ticket_id: meta.ticketId ?? null,
1860
1924
  spawned_team: meta.spawnedTeam ?? null,
1925
+ sub_agent_count: meta.subAgentCount ?? null,
1926
+ background_shell_count: meta.backgroundShellCount ?? null,
1861
1927
  plan: meta.plan ?? null,
1862
1928
  todos: meta.todos ? JSON.stringify(meta.todos) : null,
1863
1929
  recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
@@ -1917,8 +1983,16 @@ export function upsertSessionsBatch(entries) {
1917
1983
  .filter(e => e.scan && e.meta.filePath)
1918
1984
  .map(e => [canonicalLedgerKey(e.meta.filePath), e]));
1919
1985
  const enrichedEntries = entries.map(entry => {
1920
- if (entry.meta.agent === 'claude' || entry.meta.agent === 'codex' || !entry.meta.filePath)
1921
- return entry;
1986
+ if (entry.meta.agent === 'claude' || entry.meta.agent === 'codex' || !entry.meta.filePath) {
1987
+ // claude/codex keep their resumable-parse optimization (no transcript read
1988
+ // here). When their scanner already handed us normalized events, the counts
1989
+ // are free — take them. Otherwise leave them undefined, which persists NULL
1990
+ // and means "not computed yet", and the next scan that carries events fills
1991
+ // it. Never collapse that to 0: see fanOutCounts.
1992
+ return entry.events
1993
+ ? { ...entry, meta: { ...entry.meta, ...fanOutCounts(entry.events, entry.meta.agent) } }
1994
+ : entry;
1995
+ }
1922
1996
  try {
1923
1997
  const toolSourcePath = toolEvidenceSourcePath(entry.meta.filePath, entry.meta.agent);
1924
1998
  const toolScan = toolSourcePath === entry.meta.filePath
@@ -1938,6 +2012,7 @@ export function upsertSessionsBatch(entries) {
1938
2012
  ...entry.meta,
1939
2013
  todos: extractTodoProgressFromEvents(events),
1940
2014
  recentDirectoriesTouched: extractRecentDirectoriesTouched(events, entry.meta.cwd),
2015
+ ...fanOutCounts(events, entry.meta.agent),
1941
2016
  },
1942
2017
  toolCalls: toolCallsFromEvents(events),
1943
2018
  toolScan,
@@ -2042,6 +2117,8 @@ export function upsertSessionsBatch(entries) {
2042
2117
  worktree_slug: meta.worktreeSlug ?? null,
2043
2118
  ticket_id: meta.ticketId ?? null,
2044
2119
  spawned_team: meta.spawnedTeam ?? null,
2120
+ sub_agent_count: meta.subAgentCount ?? null,
2121
+ background_shell_count: meta.backgroundShellCount ?? null,
2045
2122
  plan: meta.plan ?? null,
2046
2123
  todos: meta.todos ? JSON.stringify(meta.todos) : null,
2047
2124
  recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
@@ -2265,6 +2342,8 @@ function rowToMeta(row) {
2265
2342
  worktreeSlug: row.worktree_slug ?? undefined,
2266
2343
  ticketId: row.ticket_id ?? undefined,
2267
2344
  spawnedTeam: row.spawned_team ?? undefined,
2345
+ subAgentCount: row.sub_agent_count ?? undefined,
2346
+ backgroundShellCount: row.background_shell_count ?? undefined,
2268
2347
  plan: row.plan ?? undefined,
2269
2348
  todos: parseJsonColumn(row.todos),
2270
2349
  recentDirectoriesTouched: parseJsonColumn(row.recent_directories_touched),
@@ -2690,6 +2769,59 @@ export function writeSessionInsights(entries) {
2690
2769
  export function clearSessionInsights() {
2691
2770
  getDB().exec(`DELETE FROM session_insights`);
2692
2771
  }
2772
+ /** Read cached trace topics only when their transcript byte stamps still match. */
2773
+ export function readSessionTopics(ids) {
2774
+ const db = getDB();
2775
+ const out = new Map();
2776
+ if (ids.length === 0)
2777
+ return out;
2778
+ const CHUNK = 400;
2779
+ for (let i = 0; i < ids.length; i += CHUNK) {
2780
+ const chunk = ids.slice(i, i + CHUNK);
2781
+ const placeholders = chunk.map(() => '?').join(',');
2782
+ const rows = db.prepare(`
2783
+ SELECT st.session_id AS id, st.topic_json AS topicJson
2784
+ FROM session_topics st
2785
+ JOIN sessions s ON s.id = st.session_id
2786
+ WHERE st.session_id IN (${placeholders})
2787
+ AND st.extractor_version = ?
2788
+ AND st.file_mtime_ms IS s.file_mtime_ms
2789
+ AND st.file_size IS s.file_size
2790
+ `).all(...chunk, SESSION_TOPIC_EXTRACTOR_VERSION);
2791
+ for (const row of rows) {
2792
+ try {
2793
+ out.set(row.id, JSON.parse(row.topicJson));
2794
+ }
2795
+ catch {
2796
+ // Invalid derived cache data is a miss and self-heals on the next write.
2797
+ }
2798
+ }
2799
+ }
2800
+ return out;
2801
+ }
2802
+ /** Persist trace topics against the exact transcript bytes used to classify them. */
2803
+ export function writeSessionTopics(entries) {
2804
+ if (entries.length === 0)
2805
+ return;
2806
+ const db = getDB();
2807
+ const stmt = db.prepare(`
2808
+ INSERT INTO session_topics
2809
+ (session_id, file_mtime_ms, file_size, extractor_version, computed_at, topic_json)
2810
+ VALUES (?, ?, ?, ?, ?, ?)
2811
+ ON CONFLICT(session_id) DO UPDATE SET
2812
+ file_mtime_ms = excluded.file_mtime_ms,
2813
+ file_size = excluded.file_size,
2814
+ extractor_version = excluded.extractor_version,
2815
+ computed_at = excluded.computed_at,
2816
+ topic_json = excluded.topic_json
2817
+ `);
2818
+ const now = Date.now();
2819
+ db.transaction(() => {
2820
+ for (const entry of entries) {
2821
+ stmt.run(entry.id, entry.fileMtimeMs, entry.fileSize, SESSION_TOPIC_EXTRACTOR_VERSION, now, JSON.stringify(entry.topic));
2822
+ }
2823
+ })();
2824
+ }
2693
2825
  /** Read one derived preview only when it matches the transcript bytes on disk. */
2694
2826
  export function readSessionPreviewCache(id, sourceStamp) {
2695
2827
  const row = getDB().prepare(`
@@ -122,6 +122,9 @@ interface ClaudeSessionScan {
122
122
  name: string;
123
123
  count: number;
124
124
  }>;
125
+ /** Fan-out left behind (RUSH-3091/3095) — see SessionMeta.subAgentCount. */
126
+ subAgentCount?: number;
127
+ backgroundShellCount?: number;
125
128
  /** Slash commands invoked (#12) — see SessionMeta.slashCommandsUsed. */
126
129
  slashCommandsUsed?: Array<{
127
130
  name: string;
@@ -481,6 +484,14 @@ interface ClaudeParseState {
481
484
  /** Slash-command events (user-typed <command-name> wrapper OR a SlashCommand
482
485
  * tool_use), held for extractSlashCommands() at finalize (#12). */
483
486
  slashCommandEvents: SessionEvent[];
487
+ /**
488
+ * Fan-out tallies (RUSH-3091/3095). Counted as we stream rather than held as
489
+ * events — unlike skills we need only the count, and a busy session can spawn
490
+ * hundreds. `backgroundShells` stays undefined for a harness with no
491
+ * background-shell concept, which must render as absence, never as 0.
492
+ */
493
+ subAgents: number;
494
+ backgroundShells: number | undefined;
484
495
  }
485
496
  /** Zero-value accumulator for a fresh (from-byte-0) Claude parse. */
486
497
  export declare function initClaudeParseState(): ClaudeParseState;
@@ -510,7 +521,16 @@ export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessi
510
521
  * exact even when the recent window is smaller than the true count.
511
522
  */
512
523
  export interface ClaudeParserState {
513
- v: 3;
524
+ v: 4;
525
+ /**
526
+ * Fan-out tallies carried across a RESUMED parse (RUSH-3091/3095). They must
527
+ * live in the durable blob: the resumable parser reads only new bytes, so
528
+ * re-initialising them on hydrate would report a session's tail, not its
529
+ * total. Adding them is why `v` moved 3 -> 4 — an older blob is rejected and
530
+ * the session re-parses from byte 0 with correct totals.
531
+ */
532
+ subAgents: number;
533
+ backgroundShells: number | undefined;
514
534
  offset: number;
515
535
  jsonlDroppingOversizedLine?: boolean;
516
536
  timestamp?: string;
@@ -26,8 +26,8 @@ import { getConfigSymlinkVersion } from '../installations/shims.js';
26
26
  import { SESSION_AGENTS } from './types.js';
27
27
  import { deriveShortId } from './short-id.js';
28
28
  import { buildClaudeAccountIndex, resolveClaudeAccount } from './claude-accounts.js';
29
- import { extractSessionTopic, extractSlashCommandName, extractSlashCommandFromToolInput } from './prompt.js';
30
- import { isSkillInvocation, extractSkills, extractSlashCommands } from './highlights.js';
29
+ import { extractSessionTopic, extractSlashCommandName, extractSlashCommandFromToolInput, classifyUserPrompt } from './prompt.js';
30
+ import { isBackgroundShellStart, isSkillInvocation, extractSkills, extractSlashCommands, isSubAgentTool } from './highlights.js';
31
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';
@@ -1239,6 +1239,8 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, label) {
1239
1239
  todos: scan.todos,
1240
1240
  recentDirectoriesTouched: scan.recentDirectoriesTouched,
1241
1241
  skillsUsed: scan.skillsUsed,
1242
+ subAgentCount: scan.subAgentCount,
1243
+ backgroundShellCount: scan.backgroundShellCount,
1242
1244
  slashCommandsUsed: scan.slashCommandsUsed,
1243
1245
  };
1244
1246
  }
@@ -1280,6 +1282,8 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, label) {
1280
1282
  todos: scan.todos,
1281
1283
  recentDirectoriesTouched: scan.recentDirectoriesTouched,
1282
1284
  skillsUsed: scan.skillsUsed,
1285
+ subAgentCount: scan.subAgentCount,
1286
+ backgroundShellCount: scan.backgroundShellCount,
1283
1287
  slashCommandsUsed: scan.slashCommandsUsed,
1284
1288
  };
1285
1289
  }
@@ -2996,6 +3000,8 @@ function extractDroidMessageText(content) {
2996
3000
  /** Zero-value accumulator for a fresh (from-byte-0) Claude parse. */
2997
3001
  export function initClaudeParseState() {
2998
3002
  return {
3003
+ subAgents: 0,
3004
+ backgroundShells: 0,
2999
3005
  timestamp: undefined,
3000
3006
  cwd: undefined,
3001
3007
  gitBranch: undefined,
@@ -3044,6 +3050,14 @@ function foldDerivedToolState(state, event) {
3044
3050
  // from a full re-parse (see session/db.ts's writeResourceUsage doc comment).
3045
3051
  if (isSkillInvocation(event))
3046
3052
  state.skillEvents.push(event);
3053
+ // Fan-out tallies (RUSH-3091/3095): counted, not held — we need the number,
3054
+ // and a heavy orchestrator session can spawn hundreds of each.
3055
+ if (state.subAgents !== undefined && isSubAgentTool(event.tool ?? '', event.command ?? '')) {
3056
+ state.subAgents += 1;
3057
+ }
3058
+ if (state.backgroundShells !== undefined && isBackgroundShellStart(event)) {
3059
+ state.backgroundShells += 1;
3060
+ }
3047
3061
  if (event.tool === 'SlashCommand') {
3048
3062
  const slashCommand = extractSlashCommandFromToolInput(event.args);
3049
3063
  if (slashCommand)
@@ -3254,7 +3268,15 @@ export function finalizeClaudeScan(state) {
3254
3268
  // A topic is the first meaningful prompt. Harness-owned names travel in the
3255
3269
  // separate label field so consumers can replace an early topic once Claude's
3256
3270
  // generated title (or a later `/rename`) arrives.
3257
- const label = state.customTitle || state.aiTitle;
3271
+ // Claude's generated `ai-title` is derived from the first turn, so a session
3272
+ // opened with a skill gets named after the injected "Base directory for this
3273
+ // skill: …" line instead of its task — and that name then wins on every
3274
+ // surface for the session's whole life. `classifyUserPrompt` reports `skill`
3275
+ // only for that injected line, so collapse it to `/<skill>`. A `/rename`
3276
+ // (`custom-title`) is the user's own words and is never rewritten.
3277
+ const generated = classifyUserPrompt(state.aiTitle ?? '');
3278
+ const label = state.customTitle
3279
+ || (generated.kind === 'skill' ? generated.clean : state.aiTitle);
3258
3280
  const worktree = detectWorktree(state.cwd, state.gitBranch);
3259
3281
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
3260
3282
  return {
@@ -3289,6 +3311,8 @@ export function finalizeClaudeScan(state) {
3289
3311
  recentDirectoriesTouched: state.recentDirectoriesTouched.length ? state.recentDirectoriesTouched : undefined,
3290
3312
  skillsUsed: state.skillEvents.length ? extractSkills(state.skillEvents) : undefined,
3291
3313
  slashCommandsUsed: state.slashCommandEvents.length ? extractSlashCommands(state.slashCommandEvents) : undefined,
3314
+ subAgentCount: state.subAgents,
3315
+ backgroundShellCount: state.backgroundShells,
3292
3316
  };
3293
3317
  }
3294
3318
  /** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
@@ -3330,7 +3354,9 @@ export function serializeClaudeParserState(state, offset, jsonlDroppingOversized
3330
3354
  : allIds;
3331
3355
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
3332
3356
  return {
3333
- v: 3,
3357
+ v: 4,
3358
+ subAgents: state.subAgents,
3359
+ backgroundShells: state.backgroundShells,
3334
3360
  offset,
3335
3361
  jsonlDroppingOversizedLine: jsonlDroppingOversizedLine || undefined,
3336
3362
  timestamp: state.timestamp,
@@ -3402,6 +3428,8 @@ export function hydrateClaudeParseState(prior) {
3402
3428
  seen.add(`pad:${pad++}`);
3403
3429
  }
3404
3430
  return {
3431
+ subAgents: prior.subAgents,
3432
+ backgroundShells: prior.backgroundShells,
3405
3433
  timestamp: prior.timestamp,
3406
3434
  cwd: prior.cwd,
3407
3435
  gitBranch: prior.gitBranch,
@@ -3569,7 +3597,7 @@ function parsePriorClaudeState(row) {
3569
3597
  return null;
3570
3598
  try {
3571
3599
  const parsed = JSON.parse(row.parserState);
3572
- if (parsed?.v !== 3 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
3600
+ if (parsed?.v !== 4 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
3573
3601
  return null;
3574
3602
  return parsed;
3575
3603
  }