@phnx-labs/agents-cli 1.22.117 → 1.22.119

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 (271) hide show
  1. package/CHANGELOG.md +301 -0
  2. package/dist/bootstrap.js +4 -11
  3. package/dist/commands/accounts.js +7 -4
  4. package/dist/commands/browser.d.ts +52 -70
  5. package/dist/commands/browser.js +192 -3562
  6. package/dist/commands/daemon.d.ts +39 -2
  7. package/dist/commands/daemon.js +72 -73
  8. package/dist/commands/events.js +1 -1
  9. package/dist/commands/exec.js +53 -1
  10. package/dist/commands/projects.js +92 -7
  11. package/dist/commands/repo.js +13 -49
  12. package/dist/commands/route.js +5 -5
  13. package/dist/commands/routines.js +39 -12
  14. package/dist/commands/sessions-render.d.ts +1 -1
  15. package/dist/commands/sessions-render.js +0 -1
  16. package/dist/commands/sessions.test-fixture.d.ts +0 -1
  17. package/dist/commands/sessions.test-fixture.js +0 -37
  18. package/dist/commands/setup-browser.d.ts +17 -12
  19. package/dist/commands/setup-browser.js +52 -87
  20. package/dist/commands/setup-preferences.d.ts +0 -17
  21. package/dist/commands/setup-preferences.js +18 -49
  22. package/dist/commands/setup.js +9 -9
  23. package/dist/commands/ssh.js +10 -1
  24. package/dist/commands/sync.js +6 -44
  25. package/dist/commands/teams.js +0 -1
  26. package/dist/commands/update.d.ts +19 -0
  27. package/dist/commands/update.js +34 -6
  28. package/dist/commands/view.d.ts +12 -1
  29. package/dist/commands/view.js +47 -2
  30. package/dist/index.js +107 -26
  31. package/dist/lib/account-capabilities.js +0 -2
  32. package/dist/lib/account-catalog.d.ts +38 -0
  33. package/dist/lib/account-catalog.js +110 -26
  34. package/dist/lib/account-provider-registry.js +1 -1
  35. package/dist/lib/account-registry.d.ts +1 -1
  36. package/dist/lib/account-registry.js +36 -2
  37. package/dist/lib/accounting/rotate.js +22 -8
  38. package/dist/lib/accounting/usage-ingest.d.ts +6 -1
  39. package/dist/lib/accounting/usage-ingest.js +115 -45
  40. package/dist/lib/accounting/usage-sync.d.ts +134 -31
  41. package/dist/lib/accounting/usage-sync.js +227 -66
  42. package/dist/lib/accounting/usage.d.ts +16 -3
  43. package/dist/lib/accounting/usage.js +26 -8
  44. package/dist/lib/accounts/add.d.ts +3 -1
  45. package/dist/lib/accounts/add.js +24 -13
  46. package/dist/lib/accounts/slots.d.ts +8 -0
  47. package/dist/lib/accounts/slots.js +17 -0
  48. package/dist/lib/add-dir.js +0 -1
  49. package/dist/lib/agent-cli-commands.js +0 -1
  50. package/dist/lib/agent-spec/agents.d.ts +6 -7
  51. package/dist/lib/agent-spec/agents.js +7 -60
  52. package/dist/lib/auth-health.d.ts +69 -1
  53. package/dist/lib/auth-health.js +133 -4
  54. package/dist/lib/browser/context.d.ts +82 -0
  55. package/dist/lib/browser/context.js +88 -0
  56. package/dist/lib/browser/paths.d.ts +25 -0
  57. package/dist/lib/browser/paths.js +51 -0
  58. package/dist/lib/browser/record.d.ts +38 -0
  59. package/dist/lib/browser/record.js +57 -0
  60. package/dist/lib/browser/sessions-list.js +1 -2
  61. package/dist/lib/browser-client.d.ts +158 -0
  62. package/dist/lib/browser-client.js +234 -0
  63. package/dist/lib/channels/owner-sink.d.ts +1 -18
  64. package/dist/lib/channels/owner-sink.js +29 -82
  65. package/dist/lib/channels/providers/rush.d.ts +7 -7
  66. package/dist/lib/channels/providers/rush.js +127 -57
  67. package/dist/lib/claude-account-token.d.ts +7 -1
  68. package/dist/lib/claude-account-token.js +26 -4
  69. package/dist/lib/claude-statusline.d.ts +1 -1
  70. package/dist/lib/claude-statusline.js +15 -3
  71. package/dist/lib/commands.js +1 -1
  72. package/dist/lib/crabbox/lease.js +1 -1
  73. package/dist/lib/crabbox/runtimes.js +1 -3
  74. package/dist/lib/daemon/account-state-daemon-service.js +1 -0
  75. package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
  76. package/dist/lib/daemon/auth-sync-service.js +37 -43
  77. package/dist/lib/daemon/daemon.d.ts +25 -14
  78. package/dist/lib/daemon/daemon.js +92 -154
  79. package/dist/lib/daemon/runner.d.ts +2 -2
  80. package/dist/lib/daemon/runner.js +32 -9
  81. package/dist/lib/daemon/self-update-service.d.ts +35 -34
  82. package/dist/lib/daemon/self-update-service.js +40 -36
  83. package/dist/lib/daemon/service.d.ts +12 -8
  84. package/dist/lib/daemon/supervisor.d.ts +65 -54
  85. package/dist/lib/daemon/supervisor.js +136 -171
  86. package/dist/lib/daemon/testdata/process-view-start.js +5 -2
  87. package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
  88. package/dist/lib/daemon/usage-sync-service.js +46 -78
  89. package/dist/lib/daemon-health.d.ts +22 -5
  90. package/dist/lib/daemon-health.js +47 -3
  91. package/dist/lib/daemon-services.d.ts +1 -1
  92. package/dist/lib/daemon-services.js +0 -10
  93. package/dist/lib/daemon-ticks.js +4 -2
  94. package/dist/lib/devices/doctor-findings.js +8 -11
  95. package/dist/lib/devices/harness-inventory.js +14 -5
  96. package/dist/lib/exec-account-home.js +5 -0
  97. package/dist/lib/exec.js +10 -13
  98. package/dist/lib/feed/events.js +0 -1
  99. package/dist/lib/feed/tool-activity.js +5 -2
  100. package/dist/lib/fleet-shared-state.d.ts +45 -0
  101. package/dist/lib/fleet-shared-state.js +78 -9
  102. package/dist/lib/fs-atomic.js +25 -0
  103. package/dist/lib/github/pr-mergeable.d.ts +4 -2
  104. package/dist/lib/github/pr-mergeable.js +5 -3
  105. package/dist/lib/github/project-prs.d.ts +58 -3
  106. package/dist/lib/github/project-prs.js +205 -7
  107. package/dist/lib/harness/adapters/cursor.js +14 -4
  108. package/dist/lib/harness-auth-capabilities.d.ts +4 -3
  109. package/dist/lib/harness-auth-capabilities.js +1 -2
  110. package/dist/lib/helper-versions.js +1 -1
  111. package/dist/lib/hooks/cache.d.ts +7 -0
  112. package/dist/lib/hooks/cache.js +27 -2
  113. package/dist/lib/hooks/install.js +7 -1
  114. package/dist/lib/hosts/ready.d.ts +38 -8
  115. package/dist/lib/hosts/ready.js +62 -14
  116. package/dist/lib/installations/active-check.d.ts +27 -0
  117. package/dist/lib/installations/active-check.js +61 -0
  118. package/dist/lib/installations/migrate.js +6 -3
  119. package/dist/lib/installations/shims.d.ts +1 -1
  120. package/dist/lib/installations/shims.js +32 -12
  121. package/dist/lib/installations/store.d.ts +9 -0
  122. package/dist/lib/installations/store.js +24 -0
  123. package/dist/lib/installations/update.js +21 -15
  124. package/dist/lib/installations/versions.js +15 -3
  125. package/dist/lib/manifest.js +1 -1
  126. package/dist/lib/models.js +0 -2
  127. package/dist/lib/open-url.js +28 -54
  128. package/dist/lib/profiles.js +0 -2
  129. package/dist/lib/projects.d.ts +16 -0
  130. package/dist/lib/projects.js +31 -0
  131. package/dist/lib/refresh.js +1 -1
  132. package/dist/lib/routine-readiness.d.ts +35 -0
  133. package/dist/lib/routine-readiness.js +74 -16
  134. package/dist/lib/rules/compile.js +6 -0
  135. package/dist/lib/sandbox.js +0 -2
  136. package/dist/lib/secrets-policy.d.ts +89 -11
  137. package/dist/lib/secrets-policy.js +175 -28
  138. package/dist/lib/session/active.d.ts +17 -6
  139. package/dist/lib/session/active.js +105 -49
  140. package/dist/lib/session/db.d.ts +1 -1
  141. package/dist/lib/session/db.js +65 -18
  142. package/dist/lib/session/discover.js +1 -283
  143. package/dist/lib/session/mirror.d.ts +4 -3
  144. package/dist/lib/session/mirror.js +4 -3
  145. package/dist/lib/session/parse.d.ts +0 -2
  146. package/dist/lib/session/parse.js +1 -138
  147. package/dist/lib/session/pid-registry.d.ts +6 -3
  148. package/dist/lib/session/pid-registry.js +18 -8
  149. package/dist/lib/session/remote/watch.d.ts +1 -1
  150. package/dist/lib/session/remote/watch.js +7 -3
  151. package/dist/lib/session/throughput.d.ts +1 -3
  152. package/dist/lib/session/throughput.js +0 -20
  153. package/dist/lib/session/types.d.ts +2 -2
  154. package/dist/lib/session/types.js +2 -2
  155. package/dist/lib/sessions-client.d.ts +60 -1
  156. package/dist/lib/sessions-client.js +227 -4
  157. package/dist/lib/setup-tool-status.js +5 -10
  158. package/dist/lib/signin-badge.d.ts +3 -2
  159. package/dist/lib/signin-badge.js +6 -3
  160. package/dist/lib/smart-launch.js +4 -1
  161. package/dist/lib/staleness/detectors/commands.d.ts +1 -1
  162. package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
  163. package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
  164. package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
  165. package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
  166. package/dist/lib/staleness/detectors/rules.d.ts +1 -1
  167. package/dist/lib/staleness/detectors/skills.d.ts +1 -1
  168. package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
  169. package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
  170. package/dist/lib/staleness/writers/commands.d.ts +1 -1
  171. package/dist/lib/staleness/writers/hooks.d.ts +1 -1
  172. package/dist/lib/staleness/writers/mcp.d.ts +1 -1
  173. package/dist/lib/staleness/writers/permissions.d.ts +1 -1
  174. package/dist/lib/staleness/writers/plugins.d.ts +1 -1
  175. package/dist/lib/staleness/writers/rules.d.ts +1 -1
  176. package/dist/lib/staleness/writers/skills.d.ts +1 -1
  177. package/dist/lib/staleness/writers/sources.js +5 -0
  178. package/dist/lib/staleness/writers/subagents.d.ts +1 -1
  179. package/dist/lib/staleness/writers/workflows.d.ts +1 -1
  180. package/dist/lib/state.d.ts +6 -4
  181. package/dist/lib/state.js +7 -17
  182. package/dist/lib/teams/parsers.d.ts +1 -1
  183. package/dist/lib/teams/parsers.js +1 -136
  184. package/dist/lib/teams/placement-probe.js +50 -15
  185. package/dist/lib/teams/scheduler.d.ts +7 -0
  186. package/dist/lib/types.d.ts +16 -1
  187. package/dist/lib/types.js +1 -1
  188. package/dist/lib/view-types.d.ts +32 -0
  189. package/dist/lib/watchdog/read.js +0 -1
  190. package/dist/lib/watchdog/rotate.js +1 -2
  191. package/dist/lib/watchdog/runner.js +2 -2
  192. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  193. package/dist/lib/watchdog/watchdogTail.js +0 -31
  194. package/package.json +1 -1
  195. package/scripts/postinstall.js +8 -8
  196. package/dist/browser.d.ts +0 -2
  197. package/dist/browser.js +0 -17
  198. package/dist/commands/browser-picker.d.ts +0 -18
  199. package/dist/commands/browser-picker.js +0 -97
  200. package/dist/lib/browser/arc-discovery.d.ts +0 -52
  201. package/dist/lib/browser/arc-discovery.js +0 -197
  202. package/dist/lib/browser/arc-dom.d.ts +0 -14
  203. package/dist/lib/browser/arc-dom.js +0 -121
  204. package/dist/lib/browser/caller-identity.d.ts +0 -40
  205. package/dist/lib/browser/caller-identity.js +0 -175
  206. package/dist/lib/browser/cdp.d.ts +0 -51
  207. package/dist/lib/browser/cdp.js +0 -254
  208. package/dist/lib/browser/chrome.d.ts +0 -141
  209. package/dist/lib/browser/chrome.js +0 -704
  210. package/dist/lib/browser/chromium-discovery.d.ts +0 -28
  211. package/dist/lib/browser/chromium-discovery.js +0 -96
  212. package/dist/lib/browser/devices.d.ts +0 -34
  213. package/dist/lib/browser/devices.js +0 -61
  214. package/dist/lib/browser/domain-skills.d.ts +0 -71
  215. package/dist/lib/browser/domain-skills.js +0 -195
  216. package/dist/lib/browser/drivers/arc.d.ts +0 -40
  217. package/dist/lib/browser/drivers/arc.js +0 -276
  218. package/dist/lib/browser/drivers/firefox.d.ts +0 -99
  219. package/dist/lib/browser/drivers/firefox.js +0 -377
  220. package/dist/lib/browser/drivers/local.d.ts +0 -55
  221. package/dist/lib/browser/drivers/local.js +0 -266
  222. package/dist/lib/browser/drivers/ssh.d.ts +0 -120
  223. package/dist/lib/browser/drivers/ssh.js +0 -467
  224. package/dist/lib/browser/editor.d.ts +0 -3
  225. package/dist/lib/browser/editor.js +0 -50
  226. package/dist/lib/browser/ffmpeg.d.ts +0 -12
  227. package/dist/lib/browser/ffmpeg.js +0 -184
  228. package/dist/lib/browser/firefox-discovery.d.ts +0 -69
  229. package/dist/lib/browser/firefox-discovery.js +0 -162
  230. package/dist/lib/browser/har.d.ts +0 -85
  231. package/dist/lib/browser/har.js +0 -77
  232. package/dist/lib/browser/hygiene.d.ts +0 -97
  233. package/dist/lib/browser/hygiene.js +0 -153
  234. package/dist/lib/browser/index.d.ts +0 -5
  235. package/dist/lib/browser/index.js +0 -5
  236. package/dist/lib/browser/input.d.ts +0 -7
  237. package/dist/lib/browser/input.js +0 -92
  238. package/dist/lib/browser/ipc.d.ts +0 -204
  239. package/dist/lib/browser/ipc.js +0 -1453
  240. package/dist/lib/browser/login-detection.d.ts +0 -87
  241. package/dist/lib/browser/login-detection.js +0 -274
  242. package/dist/lib/browser/profiles.d.ts +0 -422
  243. package/dist/lib/browser/profiles.js +0 -1157
  244. package/dist/lib/browser/refs.d.ts +0 -89
  245. package/dist/lib/browser/refs.js +0 -191
  246. package/dist/lib/browser/registry.d.ts +0 -70
  247. package/dist/lib/browser/registry.js +0 -252
  248. package/dist/lib/browser/remote-control.d.ts +0 -59
  249. package/dist/lib/browser/remote-control.js +0 -85
  250. package/dist/lib/browser/resolve-target.d.ts +0 -66
  251. package/dist/lib/browser/resolve-target.js +0 -264
  252. package/dist/lib/browser/runtime-state.d.ts +0 -291
  253. package/dist/lib/browser/runtime-state.js +0 -584
  254. package/dist/lib/browser/secret-ref.d.ts +0 -10
  255. package/dist/lib/browser/secret-ref.js +0 -14
  256. package/dist/lib/browser/service.d.ts +0 -812
  257. package/dist/lib/browser/service.js +0 -4693
  258. package/dist/lib/browser/stream.d.ts +0 -17
  259. package/dist/lib/browser/stream.js +0 -72
  260. package/dist/lib/browser/task-index.d.ts +0 -78
  261. package/dist/lib/browser/task-index.js +0 -199
  262. package/dist/lib/browser/types.d.ts +0 -647
  263. package/dist/lib/browser/types.js +0 -61
  264. package/dist/lib/browser/upload.d.ts +0 -29
  265. package/dist/lib/browser/upload.js +0 -298
  266. package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
  267. package/dist/lib/daemon/browser-ipc-service.js +0 -46
  268. package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
  269. package/dist/lib/daemon/browser-task-reap-service.js +0 -26
  270. package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
  271. package/dist/lib/fleet-shared-repo-sync.js +0 -490
@@ -18,7 +18,7 @@ import { isHostPinned, isDevicePinned, managedKnownHostsPath } from './devices/k
18
18
  import { machineId, normalizeHost } from './session/sync/config.js';
19
19
  import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from './fleet-shared-state.js';
20
20
  import { getCacheDir, getUserAgentsDir, readMeta } from './state.js';
21
- import { listNativeAccounts, readSlots } from './account-registry.js';
21
+ import { dropSlots, listNativeAccounts, readSlots } from './account-registry.js';
22
22
  import { claudeAccountTokenKey, isClaudeWorkerHomeSeeded, provisionWorkerSlot, readReservedCredential } from './claude-account-token.js';
23
23
  import { configuredDeviceRole, isHeadedDeviceRole, selfConfiguredDeviceRole } from './device-config.js';
24
24
  import { AUTH_STORE_ALIAS, AUTH_BUNDLE_BACKEND, RESERVED_BUNDLE_NAMES, isReservedBundleName, ReservedBundleWrongBackendError, assertReservedAuthBackend, isReservedBundleBackendError, RESERVED_STORES, reservedStoreName, isReservedStoreName, assertStorableCredentialKind, inspectReservedAuthBundle, } from './reserved-stores.js';
@@ -245,6 +245,26 @@ export async function syncReservedAuthBundle(deps = {}) {
245
245
  // The functions below fix both. The plan is per ACCOUNT and per KEY
246
246
  // (`<ENV>_<accountId>`), not per bundle, and targets `role=worker` peers only.
247
247
  //
248
+ // PRESENCE IS FIRST-HAND + ROTATION-AWARE (PHNX-4116). Each device's account-
249
+ // state daemon publishes a per-account verdict row into `accounts.rows`
250
+ // (`account-state-daemon-service.ts`), and the usage-sync SSH exchange stores
251
+ // each peer's reply at `devices/<peer>/daemon-state.json`. A key counts as
252
+ // present on a peer only when BOTH hold:
253
+ // 1. FIRST-HAND — the peer's own reply reports a NON-`missing` verdict for the
254
+ // account (its slot materialized). This is what a publisher-side memo alone
255
+ // could never see: a key removed on the worker out of band flips the next
256
+ // verdict to `missing`, so the push resumes within a tick.
257
+ // 2. ROTATION — the fingerprint (`workerCredential.mintedAt`) we last delivered
258
+ // to that peer matches the account's CURRENT fingerprint. A re-mint
259
+ // (`accounts login`) rotates the reserved key and bumps `mintedAt` while the
260
+ // peer's OLD token keeps authenticating, so its verdict stays non-`missing`;
261
+ // verdict alone would read `present` forever and the rotated key would never
262
+ // reach the worker. The delivery memo is a LOCAL rotation cursor keyed per
263
+ // (peer, bundle, key) → fingerprint, never synced, never the primary signal.
264
+ // A peer reply with no `accounts.rows` field at all (an older CLI) is not proof
265
+ // of an empty inventory, so the plan FAILS CLOSED and skips it (INFO), rather
266
+ // than treating "no rows" as "holds nothing" and pushing every key blindly.
267
+ //
248
268
  // INVARIANT 1 (transport, retain nothing): materializing a worker slot happens
249
269
  // on the box where the key LANDED (`reconcileLocalWorkerSlots` ->
250
270
  // `provisionWorkerSlot`); the credential itself only ever moves over the
@@ -254,10 +274,34 @@ export async function syncReservedAuthBundle(deps = {}) {
254
274
  // `isCredentialSafeToPropagate` stays false).
255
275
  // ---------------------------------------------------------------------------
256
276
  const EMPTY_KEY_SET = new Set();
277
+ /**
278
+ * Skip reason for a peer that has never sent a daemon-state reply (no
279
+ * `devices/<peer>/daemon-state.json`). auth-sync logs these at INFO — a
280
+ * brand-new or never-dialed worker legitimately has no reply on an early tick,
281
+ * so it is informational, not a warning. Stable so the service classifies the
282
+ * skip without matching a substring.
283
+ */
284
+ export const SKIP_REASON_NO_PEER_REPLY = 'no daemon-state reply from this peer yet';
285
+ /**
286
+ * Skip reason for a peer whose reply exists but carries no `accounts.rows` field
287
+ * at all (an older CLI that does not publish per-account verdicts, or a partial
288
+ * state written before the account-state daemon ran). Without those rows there is
289
+ * no first-hand knowledge of what the peer holds, so the plan FAILS CLOSED and
290
+ * skips rather than pushing every key blindly. auth-sync logs it at INFO — it is
291
+ * transient during a rolling upgrade. An EMPTY `accounts.rows` array (a peer with
292
+ * no registered account) is NOT this case: it is a legitimate "holds nothing",
293
+ * and its keys are (correctly) pushed to provision it.
294
+ */
295
+ export const SKIP_REASON_NO_ACCOUNT_ROWS = 'daemon-state reply carries no account rows (fail closed)';
257
296
  /**
258
297
  * Pure plan: for each worker peer, push each bundle it is missing at least one
259
298
  * key of. Deterministic (peers and bundles sorted) so it pins exactly in tests.
260
- * A headed peer is skipped BEFORE any key comparison -- it never receives a key.
299
+ * A headed peer is skipped BEFORE any key comparison -- it never receives a key;
300
+ * a peer with no reply yet (`hasReply: false`) is skipped next -- there is no
301
+ * first-hand knowledge of what it holds, so nothing is pushed to it this tick;
302
+ * a peer whose reply carries no `accounts.rows` field (`hasAccountRows: false`)
303
+ * is skipped FAIL-CLOSED for the same reason (an older CLI's reply is not proof
304
+ * of an empty inventory), rather than pushing every key blindly.
261
305
  */
262
306
  export function planReservedStoreSync(accounts, peers) {
263
307
  const keysByBundle = new Map();
@@ -274,6 +318,14 @@ export function planReservedStoreSync(accounts, peers) {
274
318
  items.push({ action: 'skip', device: peer.name, reason: 'headed device receives the account row, never a durable key' });
275
319
  continue;
276
320
  }
321
+ if (!peer.hasReply) {
322
+ items.push({ action: 'skip', device: peer.name, reason: SKIP_REASON_NO_PEER_REPLY });
323
+ continue;
324
+ }
325
+ if (!peer.hasAccountRows) {
326
+ items.push({ action: 'skip', device: peer.name, reason: SKIP_REASON_NO_ACCOUNT_ROWS });
327
+ continue;
328
+ }
277
329
  if (!peer.reachable) {
278
330
  items.push({ action: 'skip', device: peer.name, reason: 'unreachable' });
279
331
  continue;
@@ -318,14 +370,44 @@ export function reservedSyncTargets(meta) {
318
370
  }
319
371
  return out;
320
372
  }
321
- // --- publisher-side delivery memo -----------------------------------------
322
- // A push sends a WHOLE bundle; the receiver reports no per-key inventory today
323
- // (that is T3's per-account verdict seam). So the publisher records what it has
324
- // delivered to each peer, keyed by the credential's fingerprint, to answer "does
325
- // the peer already have this key" without re-pushing an unchanged bundle every
326
- // tick. A new account (no memo entry) or a re-mint (fingerprint change) is not a
327
- // hit, so it propagates within one tick. The memo is LOCAL publisher bookkeeping,
328
- // never synced.
373
+ /** Narrow a peer's opaque `accounts.rows` to the verdict fields the planner reads. */
374
+ export function readPeerAccountVerdicts(state) {
375
+ const rows = state?.accounts?.rows;
376
+ if (!Array.isArray(rows))
377
+ return [];
378
+ const out = [];
379
+ for (const row of rows) {
380
+ if (!row || typeof row !== 'object' || Array.isArray(row))
381
+ continue;
382
+ const r = row;
383
+ if (typeof r.accountId === 'string' && typeof r.harness === 'string' && typeof r.verdict === 'string') {
384
+ out.push({ accountId: r.accountId, harness: r.harness, verdict: r.verdict });
385
+ }
386
+ }
387
+ return out;
388
+ }
389
+ /**
390
+ * True when the peer's reply carried an `accounts.rows` array at all (present,
391
+ * even if empty). An older CLI, or a partial state written before the account-
392
+ * state daemon ran, has no such field — and its absence must NOT read as "the
393
+ * peer holds nothing", or the plan would push every key blindly. An EMPTY array
394
+ * (a peer with no registered account) IS a valid first-hand "holds nothing".
395
+ */
396
+ export function peerHasAccountRows(state) {
397
+ return Array.isArray(state?.accounts?.rows);
398
+ }
399
+ // --- publisher-side delivery memo (rotation) -------------------------------
400
+ // A peer's verdict says WHETHER it holds a working credential for an account, but
401
+ // not WHICH one: a re-mint (`accounts login`) rotates the reserved key and bumps
402
+ // `workerCredential.mintedAt`, yet the peer's OLD token keeps authenticating, so
403
+ // its verdict stays `live`/`unverified` and never flips to `missing`. Verdict
404
+ // alone would therefore read `present` forever and the new key would never reach
405
+ // the worker. The publisher records the fingerprint it last delivered per
406
+ // (peer, bundle, key); presence requires BOTH the peer's own verdict AND a
407
+ // delivered-fingerprint match, so a re-mint (fingerprint change) re-pushes within
408
+ // a tick while a key removed on the worker (verdict `missing`) also re-pushes.
409
+ // LOCAL publisher bookkeeping, never synced — it is a rotation cursor, not a
410
+ // substitute for the first-hand verdict.
329
411
  function deliveryMemoPath(root = getCacheDir()) {
330
412
  return path.join(root, 'reserved-sync-delivered.json');
331
413
  }
@@ -346,18 +428,29 @@ function writeDeliveryMemo(memo, root = getCacheDir()) {
346
428
  fs.writeFileSync(deliveryMemoPath(root), `${JSON.stringify(memo, null, 2)}\n`, 'utf-8');
347
429
  }
348
430
  /**
349
- * The keys a peer is KNOWN to hold, per bundle, from what we have delivered to it
350
- * (memo, matched by fingerprint) plus the coarse legacy-`auth` verdict: a peer
351
- * reporting `auth.status === 'ready'` is known to hold every legacy `auth` key.
352
- * Reserved `__<harness>__` bundles have no coarse verdict, so their presence comes
353
- * from the memo alone -- which is what makes a newly-added reserved account push.
431
+ * The reserved keys a peer is KNOWN to hold, per bundle. Two conditions must BOTH
432
+ * hold (PHNX-4116):
433
+ *
434
+ * 1. FIRST-HAND — the peer's own daemon-state reply reports a NON-`missing`
435
+ * verdict for the account, so it has a working credential for it (its slot
436
+ * materialized). A `missing` verdict, or no row for the account at all, means
437
+ * it does not — self-correcting: a key removed on the worker flips its next
438
+ * verdict to `missing` and the push resumes, which a publisher-side memo alone
439
+ * could never see.
440
+ * 2. ROTATION — the fingerprint we last delivered to this peer (`delivered`)
441
+ * matches the account's CURRENT fingerprint. A re-mint bumps
442
+ * `workerCredential.mintedAt` while the old token still authenticates, so the
443
+ * verdict stays non-`missing`; without this the rotated key would never
444
+ * propagate. A never-delivered key (no memo entry ⇒ `undefined`) never
445
+ * matches, so a newly-added account still pushes.
354
446
  */
355
- export function peerPresentKeys(peer, accounts, memo, legacyAuthReady) {
447
+ export function peerPresentKeys(accounts, verdicts, delivered) {
448
+ const held = new Set(verdicts.filter((v) => v.verdict !== 'missing').map((v) => `${v.harness}:${v.accountId}`));
356
449
  const present = {};
357
450
  for (const account of accounts) {
358
- const known = memo[memoKey(peer, account.bundle, account.key)] === account.fingerprint
359
- || (legacyAuthReady && account.bundle === AUTH_STORE_ALIAS);
360
- if (!known)
451
+ if (!held.has(`${account.harness}:${account.accountId}`))
452
+ continue;
453
+ if (delivered(account.bundle, account.key) !== account.fingerprint)
361
454
  continue;
362
455
  (present[account.bundle] ??= new Set()).add(account.key);
363
456
  }
@@ -425,13 +518,18 @@ export async function syncReservedStores(deps = {}) {
425
518
  }
426
519
  const memo = readDeliveryMemo(deps.cacheDir);
427
520
  const pinned = deps.isPinned ?? ((name) => isHostPinned(name, managedKnownHostsPath()));
428
- const peers = devices.map((d) => ({
429
- name: d.name,
430
- headed: isHeadedDeviceRole(peerRole(d.name)),
431
- reachable: isDialableDevice(d),
432
- pinned: isDevicePinned(d, pinned),
433
- presentKeys: peerPresentKeys(d.name, targets, memo, stateByDevice.get(normalizeHost(d.name))?.auth?.status === 'ready'),
434
- }));
521
+ const peers = devices.map((d) => {
522
+ const state = stateByDevice.get(normalizeHost(d.name));
523
+ return {
524
+ name: d.name,
525
+ headed: isHeadedDeviceRole(peerRole(d.name)),
526
+ reachable: isDialableDevice(d),
527
+ pinned: isDevicePinned(d, pinned),
528
+ hasReply: state !== undefined,
529
+ hasAccountRows: peerHasAccountRows(state),
530
+ presentKeys: peerPresentKeys(targets, readPeerAccountVerdicts(state), (bundle, key) => memo[memoKey(d.name, bundle, key)]),
531
+ };
532
+ });
435
533
  const plan = planReservedStoreSync(targets, peers);
436
534
  const byName = new Map(devices.map((d) => [d.name, d]));
437
535
  const push = deps.push ?? ((bundle, host) => pushBundleToHostAsync(bundle, host, {
@@ -452,6 +550,9 @@ export async function syncReservedStores(deps = {}) {
452
550
  const out = await push(item.bundle, sshTarget(profile));
453
551
  if (out.ok) {
454
552
  result.pushed.push({ device: item.device, bundle: item.bundle, keys: item.keys });
553
+ // Record the fingerprint delivered so an UNCHANGED key is not re-pushed
554
+ // next tick; a re-mint (new fingerprint) or a removed key (verdict flips
555
+ // `missing`) still re-pushes.
455
556
  for (const key of item.keys) {
456
557
  const fp = targets.find((t) => t.bundle === item.bundle && t.key === key)?.fingerprint;
457
558
  if (fp)
@@ -469,6 +570,15 @@ export async function syncReservedStores(deps = {}) {
469
570
  writeDeliveryMemo(memo, deps.cacheDir ?? getCacheDir());
470
571
  return result;
471
572
  }
573
+ /**
574
+ * Fire-and-forget the daemon log without pulling its module graph into every
575
+ * consumer of this file: `reconcileLocalWorkerSlots` is synchronous and only
576
+ * runs inside the daemon (where daemon.js is already loaded), so the dynamic
577
+ * import is paid only on the rare tick that actually drops a slot.
578
+ */
579
+ function defaultWorkerSlotLog(level, message) {
580
+ void import('./daemon/daemon.js').then((m) => m.log(level, message)).catch(() => { });
581
+ }
472
582
  function defaultSlotSeeded(harness, slotDir) {
473
583
  return harness === 'claude' ? isClaudeWorkerHomeSeeded(slotDir) : true;
474
584
  }
@@ -482,8 +592,10 @@ function defaultSlotSeeded(harness, slotDir) {
482
592
  * seeded (no `hasCompletedOnboarding`) is provisioned again so it converges.
483
593
  */
484
594
  export function reconcileLocalWorkerSlots(deps = {}) {
485
- const result = { provisioned: [], skipped: [], errors: [] };
486
- const role = deps.selfRole ?? selfConfiguredDeviceRole();
595
+ const result = { provisioned: [], dropped: [], skipped: [], errors: [] };
596
+ // `'selfRole' in deps` (not `??`) so a caller can inject an explicit `undefined`
597
+ // to mean "unmarked device"; a caller that omits it reads the real machine role.
598
+ const role = 'selfRole' in deps ? deps.selfRole : selfConfiguredDeviceRole();
487
599
  if (isHeadedDeviceRole(role))
488
600
  return result; // headed boxes provision via native login
489
601
  const meta = (deps.readMetaFn ?? readMeta)();
@@ -492,6 +604,41 @@ export function reconcileLocalWorkerSlots(deps = {}) {
492
604
  const provision = deps.provision ?? provisionWorkerSlot;
493
605
  const slotSeeded = deps.slotSeeded ?? defaultSlotSeeded;
494
606
  const byId = new Map(listNativeAccounts(meta).map((account) => [account.id, account]));
607
+ // Drop stale worker slots (PHNX-4116). A `deviceAccounts.slots` record whose
608
+ // accountId is no longer a registered native account is a leftover from an
609
+ // older account-id generation — every worker held 8 such duplicates, each a
610
+ // full HOME carrying a live setup-token in `.claude/.oauth_token`. Delete that
611
+ // credential (a 0600 file) and drop the record, but KEEP the slot directory:
612
+ // `.claude/projects` holds session transcripts. Fail closed — an empty
613
+ // registry is a transient read, and stripping every slot on it would wipe a
614
+ // healthy box's whole slot set, so drop nothing until the registry answers.
615
+ // This deletion runs ONLY on a device explicitly marked `worker`: provisioning
616
+ // above is safe on an unmarked box, but destroying a credential is not — an
617
+ // unmarked device is not headed (`isHeadedDeviceRole(undefined)` is false) yet
618
+ // must never run a credential-deleting branch.
619
+ if (role === 'worker' && byId.size > 0) {
620
+ const stale = Object.values(slots).filter((slot) => !byId.has(slot.accountId));
621
+ const droppable = [];
622
+ for (const slot of stale) {
623
+ try {
624
+ // force ignores an already-absent token; a real failure (EACCES) keeps
625
+ // the slot so the record is never dropped while its credential lingers.
626
+ fs.rmSync(path.join(slot.slotDir, '.claude', '.oauth_token'), { force: true });
627
+ droppable.push(slot);
628
+ }
629
+ catch (err) {
630
+ result.errors.push({ accountId: slot.accountId, message: `stale slot credential not removed, slot kept: ${err.message}` });
631
+ }
632
+ }
633
+ if (droppable.length > 0) {
634
+ (deps.dropSlots ?? dropSlots)(droppable.map((slot) => slot.accountId));
635
+ const log = deps.log ?? defaultWorkerSlotLog;
636
+ for (const slot of droppable) {
637
+ result.dropped.push(slot.accountId);
638
+ log('INFO', `worker-slot: dropped stale slot ${slot.accountId} — no longer a registered account; removed its credential, kept ${slot.slotDir} for transcripts`);
639
+ }
640
+ }
641
+ }
495
642
  // Resolve each account to the one (bundle, key) the push plan uses: a T1 row's
496
643
  // reserved `__<harness>__` key, or the legacy `auth` key by email for a claude
497
644
  // row predating T1. Both are worker credentials this box may already hold, so
@@ -52,10 +52,10 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
52
52
  * process table. `alive` receives the row's `startedAtMs` so callers can apply
53
53
  * the same pid-reuse guard {@link isPidAlive} uses elsewhere.
54
54
  */
55
- export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
55
+ export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean, ppidMap?: Map<number, number>): ActiveSession[];
56
56
  type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
57
57
  /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
58
- export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'generatedTitle' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount' | 'lastActivity'>;
58
+ export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'accountId' | 'accountKey' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'generatedTitle' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount' | 'lastActivity'>;
59
59
  /**
60
60
  * Fold index-only enrichment onto live rows, then RE-DERIVE the recap for every
61
61
  * row it touched. The re-derive is load-bearing: `foldRecap` runs inside
@@ -65,6 +65,8 @@ export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp'
65
65
  * backfill call sites is what keeps them from drifting apart.
66
66
  */
67
67
  export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
68
+ /** Resolve a display name only when the indexed identity names one registered slot. */
69
+ export declare function sessionAccountLabel(kind: string, session: Pick<SessionMeta, 'accountId' | 'accountKey' | 'account'>, accounts?: import("../account-registry.js").NativeAccount[]): string | undefined;
68
70
  export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
69
71
  export declare function isRunningLiveSession(s: ActiveSession): boolean;
70
72
  export declare function activeSessionProjectKey(s: Pick<ActiveSession, 'cwd' | 'context'>): string;
@@ -319,6 +321,13 @@ export interface ActiveSession {
319
321
  * `accountKey`.
320
322
  */
321
323
  account?: string;
324
+ /**
325
+ * Human account-slot name (e.g. `gmail` in `claude#gmail`), display-only.
326
+ * Backfilled alongside {@link account} from indexed accountId/accountKey/email
327
+ * and the account registry. Unknown or ambiguous slots stay undefined.
328
+ * Never group on this label; use the index's `accountKey`.
329
+ */
330
+ accountLabel?: string;
322
331
  /**
323
332
  * Last-activity epoch — the transcript's last write (mtime). Distinct from
324
333
  * {@link startedAtMs} (session START): a session begun 3h ago but last touched
@@ -486,7 +495,7 @@ export interface ActiveSession {
486
495
  };
487
496
  /**
488
497
  * The editor tab that launched this agent (`AGENT_TERMINAL_ID`), from the pid
489
- * registry. This is the one identifier that survives an SSH hop AND a session
498
+ * registry or the published terminal entry. This is the one identifier that survives an SSH hop AND a session
490
499
  * rotation: a Factory tab offloaded to a device has no local process to inspect,
491
500
  * and its spawn-time session id goes stale the moment the agent moves to another
492
501
  * session (`/clear`, exit-and-rerun), so `--active --device <device>` joined on
@@ -494,6 +503,8 @@ export interface ActiveSession {
494
503
  * did not inherit a terminal id.
495
504
  */
496
505
  terminalId?: string;
506
+ /** Published editor tab index, retained across agent restarts. */
507
+ tabIndex?: number;
497
508
  /**
498
509
  * The launch id (`AGENT_LAUNCH_ID`) the CLI stamps on every agent at spawn — a
499
510
  * stable UUID that is identical locally and across an SSH hop and survives a
@@ -501,9 +512,8 @@ export interface ActiveSession {
501
512
  * non-Claude harnesses only mint after boot) it exists from the first tick, so
502
513
  * it is the join key a client uses to re-identify a session on the watch stream.
503
514
  * Populated wherever the by-pid launch registry resolves — reliably for
504
- * `agents run`-launched processes; still absent for editor-launched terminals
505
- * whose shell pid does not line up with the agent-pid registry today (the same
506
- * limitation the `readPidSessionEntry` call in `listTerminalsActive` documents).
515
+ * `agents run`-launched processes and editor terminals with a recorded live
516
+ * agent descendant.
507
517
  */
508
518
  launchId?: string;
509
519
  /**
@@ -750,6 +760,7 @@ interface ProcRow {
750
760
  tty?: string;
751
761
  comm: string;
752
762
  kind?: string;
763
+ startTime?: string;
753
764
  }
754
765
  /** Parse `ConvertTo-Csv` output of Win32_Process rows. Exported for tests. */
755
766
  export declare function parseWin32ProcessCsv(out: string): ProcRow[];
@@ -24,7 +24,9 @@ import { execFile, execFileSync } from 'child_process';
24
24
  import { promisify } from 'util';
25
25
  import { listActiveTasks } from '../cloud/store.js';
26
26
  import { AgentManager } from '../teams/agents.js';
27
- import { getTerminalsDir } from '../state.js';
27
+ import { getTerminalsDir, readMeta } from '../state.js';
28
+ import { listNativeAccounts } from '../account-registry.js';
29
+ import { parseNativeIdentityKey } from '../native-accounts.js';
28
30
  import { readLivePidSessionEntry as readPidSessionEntry, listPidSessionEntries, prunePidSessionRegistry, sessionIdFromLivePid, isSessionIdShape, } from './pid-registry.js';
29
31
  import { readSessionActorRecord, writeSessionAliasRecord } from './actor-sidecar.js';
30
32
  import { loadHookSessionIndex, resolveHookSessionRecord, readStateSessionRecord } from './hook-sessions.js';
@@ -129,11 +131,11 @@ export function attributedSetLostPids(prev, next) {
129
131
  * process table. `alive` receives the row's `startedAtMs` so callers can apply
130
132
  * the same pid-reuse guard {@link isPidAlive} uses elsewhere.
131
133
  */
132
- export function filterCachedUnattributed(sessions, attributed, alive) {
134
+ export function filterCachedUnattributed(sessions, attributed, alive, ppidMap) {
133
135
  return sessions.filter((s) => {
134
136
  if (s.pid == null)
135
137
  return false;
136
- if (attributed.has(s.pid))
138
+ if (attributed.has(s.pid) || (ppidMap && hasAttributedAncestor(s.pid, ppidMap, attributed)))
137
139
  return false;
138
140
  return alive(s.pid, s.startedAtMs);
139
141
  });
@@ -147,6 +149,7 @@ export function filterCachedUnattributed(sessions, attributed, alive) {
147
149
  * backfill call sites is what keeps them from drifting apart.
148
150
  */
149
151
  export function backfillActiveRowsFromMeta(sessions, metaById) {
152
+ const accounts = listNativeAccounts(readMeta());
150
153
  for (const s of sessions) {
151
154
  if (!s.sessionId)
152
155
  continue;
@@ -157,6 +160,7 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
157
160
  s.version = m.version;
158
161
  if (!s.account && m.account)
159
162
  s.account = m.account;
163
+ s.accountLabel ??= sessionAccountLabel(s.kind, m, accounts);
160
164
  if (!s.label && m.label)
161
165
  s.label = m.label;
162
166
  if (!s.firstUserMessage && m.firstUserMessage)
@@ -196,6 +200,30 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
196
200
  applyRecap(s);
197
201
  }
198
202
  }
203
+ /** Resolve a display name only when the indexed identity names one registered slot. */
204
+ export function sessionAccountLabel(kind, session, accounts = listNativeAccounts(readMeta())) {
205
+ const matches = accounts.filter(account => {
206
+ if (account.agent !== kind)
207
+ return false;
208
+ if (session.accountId)
209
+ return account.id === session.accountId;
210
+ if (session.accountKey && !session.accountKey.startsWith('unattributed:')) {
211
+ // Claude's index groups quota by org; the registry names a login within it.
212
+ const indexed = parseNativeIdentityKey(account.agent, session.accountKey);
213
+ const emailMatches = !!session.account && account.identityLabel?.toLowerCase() === session.account.toLowerCase();
214
+ if (!indexed || (!indexed.account && !emailMatches))
215
+ return false;
216
+ if (account.identityKey === session.accountKey)
217
+ return true;
218
+ const registered = parseNativeIdentityKey(account.agent, account.identityKey);
219
+ return !!registered
220
+ && Object.entries(indexed).every(([key, value]) => registered[key] === value)
221
+ && (!session.account || emailMatches);
222
+ }
223
+ return !!session.account && account.identityLabel?.toLowerCase() === session.account.toLowerCase();
224
+ });
225
+ return matches.length === 1 ? matches[0].name : undefined;
226
+ }
199
227
  function loadBackfillMetaFor(sessions) {
200
228
  const byId = new Map();
201
229
  try {
@@ -1062,49 +1090,48 @@ export async function listTerminalsActive() {
1062
1090
  const procByPid = new Map();
1063
1091
  for (const r of await readProcessTable())
1064
1092
  procByPid.set(r.pid, r);
1093
+ const children = childrenByParent(new Map([...procByPid.values()].map(r => [r.pid, r.ppid])));
1065
1094
  // Build label map from Claude's sessions/*.json for /rename support
1066
1095
  const labelMap = buildClaudeLabelMap();
1067
1096
  // Run-name handles (`agents run --name`) keyed by session id, for the same
1068
1097
  // sessionId → handle resolution as labels.
1069
1098
  const runNameMap = buildRunNameMap();
1070
1099
  return entries.map((t) => {
1071
- // The id cached in live-terminals.json goes stale when Claude rotates its
1072
- // transcript uuid on resume/compact, so it often no longer matches any
1073
- // <id>.jsonl. When the pid registry knows this pid's current id, prefer it —
1074
- // the same source the headless path uses. NOTE: live-terminals.json stores the
1075
- // SHELL pid, while the by-pid registry is keyed by the AGENT pid, so for
1076
- // editor-launched terminals this lookup returns undefined today and we fall
1077
- // back to the stale cached id — the duplicate-card fix comes from
1078
- // pickSessionFile no longer borrowing a sibling, not from this lookup. Kept as
1079
- // a forward-looking hook for the cases where the pid does line up.
1080
- const pidEntry = readPidSessionEntry(t.pid);
1100
+ // Keep the shell pid for tab ownership and headless suppression; the live
1101
+ // agent below it owns the session after an exit-and-rerun in the same tab.
1102
+ const directEntry = readPidSessionEntry(t.pid, procByPid.get(t.pid)?.startTime);
1103
+ const candidate = directEntry?.sessionId ? directEntry
1104
+ : (!t.pidDead ? terminalDescendantEntry(t.pid, procByPid, children) : undefined) ?? directEntry;
1105
+ const pidEntry = candidate && (!isSessionTrackedAgent(t.kind) || candidate.agent === t.kind) ? candidate : undefined;
1081
1106
  const resolvedId = pidEntry?.sessionId ?? t.sessionId;
1082
- const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, resolvedId);
1083
- // Prefer label from live terminal, fall back to Claude's session label
1084
- const label = t.label ?? (t.sessionId ? labelMap.get(t.sessionId) : undefined) ?? undefined;
1107
+ const cwd = pidEntry?.cwd ?? t.cwd ?? undefined;
1108
+ const sessionKind = pidEntry?.agent ?? t.kind;
1109
+ const sessionFile = findSessionFileForKind(sessionKind, cwd, resolvedId);
1110
+ const label = t.label ?? (resolvedId ? labelMap.get(resolvedId) : undefined) ?? undefined;
1085
1111
  // Durable run name from `agents run --name`, resolved by the run's session id.
1086
1112
  const name = resolvedId ? runNameMap.get(resolvedId) ?? undefined : undefined;
1087
1113
  // Extract topic from session file (first meaningful user message)
1088
1114
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
1089
1115
  const pidAlive = isPidAlive(t.pid, t.startedAtMs);
1090
- const { state, tokPerSec } = computeLiveSignals(t.kind, sessionFile, t.cwd ?? undefined, pidAlive);
1116
+ const { state, tokPerSec } = computeLiveSignals(sessionKind, sessionFile, cwd, pidAlive);
1091
1117
  return applyState({
1092
1118
  context: 'terminal',
1093
- kind: t.kind,
1119
+ kind: sessionKind,
1094
1120
  harness: pidEntry?.harness,
1095
1121
  host: detectHost(t.pid, procByPid),
1096
1122
  tty: procByPid.get(t.pid)?.tty,
1097
1123
  pid: t.pid,
1098
- sessionId: t.sessionId ?? sessionIdFromFile(sessionFile),
1124
+ sessionId: resolvedId ?? sessionIdFromFile(sessionFile),
1099
1125
  launchId: pidEntry?.launchId,
1100
- terminalId: pidEntry?.terminalId,
1101
- cwd: t.cwd ?? undefined,
1126
+ terminalId: pidEntry?.terminalId ?? t.terminalId,
1127
+ tabIndex: t.tabIndex,
1128
+ cwd,
1102
1129
  label,
1103
1130
  name,
1104
1131
  topic,
1105
1132
  tokPerSec,
1106
1133
  sessionFile,
1107
- startedAtMs: t.startedAtMs,
1134
+ startedAtMs: pidEntry?.startedAtMs ?? t.startedAtMs,
1108
1135
  lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
1109
1136
  windowId: t.windowId,
1110
1137
  windowHeartbeatMs: t.windowHeartbeatMs,
@@ -1183,16 +1210,16 @@ async function readProcessTableLive() {
1183
1210
  return readProcessTableWin32();
1184
1211
  let out;
1185
1212
  try {
1186
- ({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: PS_SNAPSHOT_TIMEOUT_MS }));
1213
+ ({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,lstart=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: PS_SNAPSHOT_TIMEOUT_MS }));
1187
1214
  }
1188
1215
  catch {
1189
1216
  return [];
1190
1217
  }
1191
1218
  const rows = [];
1192
1219
  for (const line of out.split('\n')) {
1193
- // pid ppid tty comm — tty is a single token ('ttys003', 's003', or '??'/'?'
1220
+ // pid ppid tty lstart comm — tty is a single token ('ttys003', 's003', or '??'/'?'
1194
1221
  // for none); comm stays last so it may contain spaces.
1195
- const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
1222
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(\S+\s+\S+\s+\d+\s+\S+\s+\d+)\s+(.+)$/);
1196
1223
  if (!m)
1197
1224
  continue;
1198
1225
  const pid = parseInt(m[1], 10);
@@ -1201,8 +1228,8 @@ async function readProcessTableLive() {
1201
1228
  continue;
1202
1229
  const ttyRaw = m[3];
1203
1230
  const tty = ttyRaw === '??' || ttyRaw === '?' || ttyRaw === '-' ? undefined : ttyRaw;
1204
- const commRaw = m[4].trim();
1205
- rows.push({ pid, ppid, tty, comm: commRaw, kind: agentKindFromComm(commRaw) });
1231
+ const commRaw = m[5].trim();
1232
+ rows.push({ pid, ppid, tty, startTime: m[4], comm: commRaw, kind: agentKindFromComm(commRaw) });
1206
1233
  }
1207
1234
  return rows;
1208
1235
  }
@@ -1240,6 +1267,50 @@ export function parseWin32ProcessCsv(out) {
1240
1267
  }
1241
1268
  return rows;
1242
1269
  }
1270
+ function childrenByParent(ppidMap) {
1271
+ const children = new Map();
1272
+ for (const [pid, ppid] of ppidMap) {
1273
+ const siblings = children.get(ppid);
1274
+ if (siblings)
1275
+ siblings.push(pid);
1276
+ else
1277
+ children.set(ppid, [pid]);
1278
+ }
1279
+ return children;
1280
+ }
1281
+ function terminalDescendantEntry(pid, processes, children) {
1282
+ // Windows only proves pidExists: a recycled PID can be a live shell descendant.
1283
+ // Retain the published identity until that platform can prove process starts.
1284
+ if (process.platform === 'win32')
1285
+ return undefined;
1286
+ let pending = children.get(pid) ?? [];
1287
+ const seen = new Set([pid]);
1288
+ while (pending.length) {
1289
+ const next = [];
1290
+ let recordedAgent = false;
1291
+ let newest;
1292
+ for (const child of pending) {
1293
+ if (seen.has(child))
1294
+ continue;
1295
+ seen.add(child);
1296
+ const entry = readPidSessionEntry(child, processes.get(child)?.startTime);
1297
+ if (!entry || !isSessionTrackedAgent(entry.agent)) {
1298
+ next.push(...(children.get(child) ?? []));
1299
+ continue;
1300
+ }
1301
+ // A recorded agent is a boundary even before its session ID is known:
1302
+ // its tools may launch agents of their own, which never own this tab.
1303
+ recordedAgent = true;
1304
+ if (entry.sessionId && (!newest || entry.startedAtMs > newest.startedAtMs
1305
+ || (entry.startedAtMs === newest.startedAtMs && entry.pid > newest.pid)))
1306
+ newest = entry;
1307
+ }
1308
+ if (recordedAgent)
1309
+ return newest;
1310
+ pending = next;
1311
+ }
1312
+ return undefined;
1313
+ }
1243
1314
  /**
1244
1315
  * True when any ancestor in pid's parent chain is a known attributed PID.
1245
1316
  * VS Code / Cursor terminals store the *shell* PID in live-terminals.json,
@@ -1456,11 +1527,11 @@ export async function listUnattributedActive(attributed) {
1456
1527
  if (unattributedCache &&
1457
1528
  now - unattributedCache.at < UNATTRIBUTED_RESCAN_MS &&
1458
1529
  !attributedSetLostPids(unattributedCache.attributed, attributed)) {
1459
- return filterCachedUnattributed(unattributedCache.sessions, attributed, isPidAlive);
1530
+ return filterCachedUnattributed(unattributedCache.sessions, attributed, isPidAlive, unattributedCache.ppidMap);
1460
1531
  }
1461
- const out = await listUnattributedActiveLive(attributed);
1462
- unattributedCache = { at: now, attributed: new Set(attributed), sessions: out };
1463
- return out;
1532
+ const result = await listUnattributedActiveLive(attributed);
1533
+ unattributedCache = { at: now, attributed: new Set(attributed), ...result };
1534
+ return result.sessions;
1464
1535
  }
1465
1536
  /** Unthrottled headless scan (live process table + cwd probes). */
1466
1537
  async function listUnattributedActiveLive(attributed) {
@@ -1495,28 +1566,13 @@ async function listUnattributedActiveLive(attributed) {
1495
1566
  // lacks an exact launch-time id, so an all-Claude set does neither. The ~3s
1496
1567
  // poll must not re-read the dir (or re-invert the map) per candidate.
1497
1568
  let hookIndex;
1498
- let childrenByParent;
1569
+ let children;
1499
1570
  // Durable `agents run --name` handles keyed by session id — the same source the
1500
1571
  // terminal path uses to name a row. Headless agents have no live-terminals
1501
1572
  // label and no /rename, so without this a `--name`d headless run would surface
1502
1573
  // with only a topic and no tab title. Built once per scan.
1503
1574
  const runNameMap = buildRunNameMap();
1504
- const ensureChildren = () => {
1505
- if (childrenByParent)
1506
- return childrenByParent;
1507
- const m = new Map();
1508
- // The hook records under the agent pid; a wrapper/shell pid we recorded has
1509
- // the agent as a child, so we resolve via a recorded pid's immediate children.
1510
- for (const [childPid, parentPid] of ppidMap) {
1511
- const arr = m.get(parentPid);
1512
- if (arr)
1513
- arr.push(childPid);
1514
- else
1515
- m.set(parentPid, [childPid]);
1516
- }
1517
- childrenByParent = m;
1518
- return m;
1519
- };
1575
+ const ensureChildren = () => children ??= childrenByParent(ppidMap);
1520
1576
  const out = [];
1521
1577
  for (let i = 0; i < kept.length; i++) {
1522
1578
  const { pid, kind } = kept[i];
@@ -1603,7 +1659,7 @@ async function listUnattributedActiveLive(attributed) {
1603
1659
  }
1604
1660
  // Housekeeping: drop registry files for pids that have since died.
1605
1661
  prunePidSessionRegistry(isPidAlive);
1606
- return out;
1662
+ return { sessions: out, ppidMap };
1607
1663
  }
1608
1664
  /**
1609
1665
  * Attribute a single tmux pane to the agent actually running in it.
@@ -14,7 +14,7 @@ import { type TimelineState } from './timeline.js';
14
14
  /** Current schema version; bumped when migrations are added. Exported so tests
15
15
  * assert against the constant instead of hardcoding a number that every bump
16
16
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
17
- export declare const SCHEMA_VERSION = 50;
17
+ export declare const SCHEMA_VERSION = 51;
18
18
  /**
19
19
  * Bump to force the content extractor (assistant-answer text, alongside the
20
20
  * user-prompt text every harness already accumulates) to re-derive on every