@phnx-labs/agents-cli 1.20.74 → 1.20.77

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 (231) hide show
  1. package/CHANGELOG.md +1055 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
@@ -19,12 +19,13 @@
19
19
  import * as fs from 'fs';
20
20
  import * as path from 'path';
21
21
  import * as os from 'os';
22
- import { execFile } from 'child_process';
22
+ import { execFile, execFileSync } from 'child_process';
23
23
  import { promisify } from 'util';
24
24
  import { listActiveTasks } from '../cloud/store.js';
25
25
  import { AgentManager } from '../teams/agents.js';
26
26
  import { getTerminalsDir } from '../state.js';
27
- import { readPidSessionEntry, prunePidSessionRegistry } from './pid-registry.js';
27
+ import { readPidSessionEntry, listPidSessionEntries, prunePidSessionRegistry } from './pid-registry.js';
28
+ import { loadHookSessionIndex, resolveHookSessionRecord } from './hook-sessions.js';
28
29
  import { buildClaudeLabelMap } from './discover.js';
29
30
  import { buildRunNameMap } from './run-names.js';
30
31
  import { latestSessionFileForCwd } from './db.js';
@@ -33,6 +34,8 @@ import { readSessionTailWithRaw } from './tail.js';
33
34
  import { computeTokPerSec } from './throughput.js';
34
35
  import { inferSessionState } from './state.js';
35
36
  import { detectProvenance } from './provenance.js';
37
+ import { loadDevices } from '../devices/registry.js';
38
+ import { presenceFromStore } from './detached.js';
36
39
  import { mapBounded } from '../concurrency.js';
37
40
  const execFileAsync = promisify(execFile);
38
41
  /**
@@ -43,6 +46,15 @@ const execFileAsync = promisify(execFile);
43
46
  */
44
47
  export const LSOF_CONCURRENCY = 4;
45
48
  const LSOF_STAGGER_MS = 10;
49
+ /**
50
+ * Hard ceilings on the two syscalls the status path shells out to. Without them
51
+ * a single hung probe (a wedged NFS `lsof`, an EDR that stalls the `ps` snapshot)
52
+ * pins a bounded worker slot forever and silently drops live sessions to a
53
+ * fallback status. On timeout the call rejects, is caught, and the row degrades
54
+ * honestly (unknown / empty table) instead of the sweep hanging.
55
+ */
56
+ const LSOF_TIMEOUT_MS = 5_000;
57
+ const PS_SNAPSHOT_TIMEOUT_MS = 10_000;
46
58
  export function activeStatusFromCloudStatus(status) {
47
59
  switch (status) {
48
60
  case 'running':
@@ -102,16 +114,72 @@ export function agentKindFromComm(commRaw) {
102
114
  const key = stripped === base ? base : stripped.toLowerCase();
103
115
  return AGENT_CLI_NAMES[key];
104
116
  }
105
- function isPidAlive(pid) {
117
+ /**
118
+ * A process that began more than this long AFTER a session's recorded
119
+ * `startedAtMs` cannot be that session's process — the OS handed its pid to
120
+ * something newer. The window absorbs clock granularity (`ps -o lstart=` reports
121
+ * whole seconds) and the gap between a process spawning and the SessionStart
122
+ * hook recording `startedAtMs`; it is far below the minutes-to-hours it takes the
123
+ * pid space to wrap and actually recycle a pid, so it never false-kills a live
124
+ * session.
125
+ */
126
+ const PID_REUSE_TOLERANCE_MS = 60_000;
127
+ /**
128
+ * Epoch-ms start time of the process at `pid`, or null if unknowable.
129
+ *
130
+ * Distinct from teams/agents.ts's `captureProcessStartTime`, which returns an
131
+ * opaque token only meaningful for equality against a prior capture of the SAME
132
+ * pid. Here we need a value comparable to a session's `startedAtMs`, so we read
133
+ * `ps -o lstart=` — a ctime string on both macOS and Linux — and parse it to
134
+ * epoch ms. Windows and any exec/parse failure return null, so the caller falls
135
+ * back to a bare existence check (never worse than before).
136
+ */
137
+ function processStartMs(pid) {
138
+ if (process.platform === 'win32')
139
+ return null;
140
+ try {
141
+ const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
142
+ encoding: 'utf-8',
143
+ stdio: ['ignore', 'pipe', 'ignore'],
144
+ }).trim();
145
+ if (!out)
146
+ return null;
147
+ const ms = Date.parse(out);
148
+ return Number.isFinite(ms) ? ms : null;
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
154
+ /**
155
+ * True when `pid` names a live process AND — when a session's recorded
156
+ * `startedAtMs` is supplied — that process is plausibly the SAME one, not a later
157
+ * process that recycled the pid. The OS reuses pids, so a bare
158
+ * `process.kill(pid, 0)` existence check reports a dead session as alive (a
159
+ * "zombie") once its pid is handed to an unrelated process. A genuine session
160
+ * process starts at or before its own recorded start, so a process that began
161
+ * meaningfully AFTER `startedAtMs` is a reused pid and the session is dead. When
162
+ * the start time can't be read, we keep the existence answer.
163
+ */
164
+ export function isPidAlive(pid, startedAtMs) {
106
165
  if (!pid || pid < 1)
107
166
  return false;
108
167
  try {
109
168
  process.kill(pid, 0);
110
- return true;
111
169
  }
112
170
  catch (err) {
113
- return err?.code === 'EPERM';
171
+ // EPERM means the pid exists but is owned by another user — still "alive",
172
+ // fall through to the identity check. Any other error means no such process.
173
+ if (err?.code !== 'EPERM')
174
+ return false;
114
175
  }
176
+ if (startedAtMs && startedAtMs > 0) {
177
+ const procStartMs = processStartMs(pid);
178
+ if (procStartMs !== null && procStartMs > startedAtMs + PID_REUSE_TOLERANCE_MS) {
179
+ return false; // pid recycled by a newer process — this session is gone
180
+ }
181
+ }
182
+ return true;
115
183
  }
116
184
  /** Read the live-terminals registry, dedupe by sessionId, keep only pid-alive entries. */
117
185
  function readLiveTerminals() {
@@ -134,7 +202,7 @@ function readLiveTerminals() {
134
202
  const merged = new Map();
135
203
  for (const [windowId, slice] of Object.entries(parsed)) {
136
204
  for (const e of (slice?.entries ?? [])) {
137
- if (!e?.sessionId || !isPidAlive(e.pid))
205
+ if (!e?.sessionId || !isPidAlive(e.pid, e.startedAtMs))
138
206
  continue;
139
207
  merged.set(e.sessionId, { ...e, windowId });
140
208
  }
@@ -189,19 +257,49 @@ export function pickSessionFile(projectDir, sessionId) {
189
257
  }
190
258
  return best?.path;
191
259
  }
192
- function classifyActivity(sessionFile) {
193
- // No resolvable transcript is NOT evidence of activity default to idle. (Before
194
- // the no-borrow fix in pickSessionFile this rarely fired because every terminal
195
- // borrowed the newest file; now a session with an unresolved id lands here, and
196
- // "running" would wrongly light it up.)
260
+ /**
261
+ * One `stat` the transcript's creation (≈ session start) and last-write (≈ last
262
+ * activity) epochs. Both `undefined` when the file can't be stat'd (vanished /
263
+ * unresolved). `birthtimeMs` can be 0 on filesystems without creation time coerce
264
+ * that to `undefined` so callers fall through to a real signal instead of epoch 0.
265
+ */
266
+ export function sessionFileTimes(sessionFile) {
197
267
  if (!sessionFile)
198
- return 'idle';
268
+ return {};
269
+ try {
270
+ const st = fs.statSync(sessionFile);
271
+ return { birthtimeMs: st.birthtimeMs || undefined, mtimeMs: st.mtimeMs || undefined };
272
+ }
273
+ catch {
274
+ return {};
275
+ }
276
+ }
277
+ /**
278
+ * The ONE place a fallback status is decided when no rich transcript state is
279
+ * available — a non-Claude/Codex kind we cannot parse, or a Claude/Codex tail
280
+ * that was empty or unreadable. Honest by construction: it never asserts a status
281
+ * it cannot justify from a measured signal.
282
+ *
283
+ * - Resolvable transcript, readable mtime → the MEASURED freshness signal:
284
+ * written within ACTIVE_MTIME_WINDOW_MS ⇒ `running`, else `idle`.
285
+ * - Resolvable transcript whose `stat` throws (file vanished / permission) → we
286
+ * genuinely cannot tell ⇒ `unknown`. (This branch previously returned
287
+ * `running`, which contradicted the `idle` default one branch up.)
288
+ * - No resolvable transcript but the process is alive → alive-but-opaque ⇒
289
+ * `unknown`. This is the truthful answer for a live gemini / droid / cursor /
290
+ * opencode whose format we don't parse — NOT a fabricated `idle` (which the
291
+ * UI reads as "done and waiting"), and it never lies as `running` either.
292
+ * - No transcript and the process is not known alive → nothing to report ⇒ `idle`.
293
+ */
294
+ export function resolveFallbackStatus(sessionFile, pidAlive) {
295
+ if (!sessionFile)
296
+ return pidAlive ? 'unknown' : 'idle';
199
297
  try {
200
298
  const mtimeMs = fs.statSync(sessionFile).mtimeMs;
201
299
  return Date.now() - mtimeMs < ACTIVE_MTIME_WINDOW_MS ? 'running' : 'idle';
202
300
  }
203
301
  catch {
204
- return 'running';
302
+ return 'unknown';
205
303
  }
206
304
  }
207
305
  /**
@@ -255,11 +353,13 @@ function statusFromActivity(activity) {
255
353
  /**
256
354
  * Fold a computed SessionState onto an active-session row: rich status +
257
355
  * preview + PR/worktree/ticket badges. With no state (unreadable/non-Claude/
258
- * Codex file) it degrades to the mtime-only classification.
356
+ * Codex file) it degrades to {@link resolveFallbackStatus}, which needs
357
+ * `pidAlive` to tell an alive-but-opaque process (`unknown`) from a dead one
358
+ * (`idle`).
259
359
  */
260
- function applyState(base, state, fallbackFile) {
360
+ function applyState(base, state, fallbackFile, pidAlive) {
261
361
  if (!state)
262
- return { ...base, status: classifyActivity(fallbackFile) };
362
+ return { ...base, status: resolveFallbackStatus(fallbackFile, pidAlive) };
263
363
  return {
264
364
  ...base,
265
365
  status: statusFromActivity(state.activity),
@@ -361,7 +461,8 @@ export async function listTeamsActive() {
361
461
  const sessionId = a.parentSessionId ?? a.remoteSessionId ?? undefined;
362
462
  const sessionFile = findSessionFileForKind(a.agentType, a.cwd ?? undefined, sessionId ?? undefined);
363
463
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
364
- const { state, tokPerSec } = computeLiveSignals(a.agentType, sessionFile, a.cwd ?? undefined, a.pid ? isPidAlive(a.pid) : true);
464
+ const pidAlive = a.pid ? isPidAlive(a.pid) : true;
465
+ const { state, tokPerSec } = computeLiveSignals(a.agentType, sessionFile, a.cwd ?? undefined, pidAlive);
365
466
  return applyState({
366
467
  context: 'teams',
367
468
  kind: a.agentType,
@@ -373,9 +474,10 @@ export async function listTeamsActive() {
373
474
  tokPerSec,
374
475
  sessionFile,
375
476
  startedAtMs: a.startedAt.getTime(),
477
+ lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
376
478
  teamName: a.taskName,
377
479
  agentId: a.agentId,
378
- }, state, sessionFile);
480
+ }, state, sessionFile, pidAlive);
379
481
  });
380
482
  }
381
483
  /** Live editor-terminal agents across every IDE window. */
@@ -411,7 +513,8 @@ export async function listTerminalsActive() {
411
513
  const name = resolvedId ? runNameMap.get(resolvedId) ?? undefined : undefined;
412
514
  // Extract topic from session file (first meaningful user message)
413
515
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
414
- const { state, tokPerSec } = computeLiveSignals(t.kind, sessionFile, t.cwd ?? undefined, isPidAlive(t.pid));
516
+ const pidAlive = isPidAlive(t.pid, t.startedAtMs);
517
+ const { state, tokPerSec } = computeLiveSignals(t.kind, sessionFile, t.cwd ?? undefined, pidAlive);
415
518
  return applyState({
416
519
  context: 'terminal',
417
520
  kind: t.kind,
@@ -426,8 +529,9 @@ export async function listTerminalsActive() {
426
529
  tokPerSec,
427
530
  sessionFile,
428
531
  startedAtMs: t.startedAtMs,
532
+ lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
429
533
  windowId: t.windowId,
430
- }, state, sessionFile);
534
+ }, state, sessionFile, pidAlive);
431
535
  });
432
536
  }
433
537
  /** Cloud tasks still in a non-terminal state. `tasks.db` may not exist; that's fine. */
@@ -485,7 +589,7 @@ async function readProcessTable() {
485
589
  return readProcessTableWin32();
486
590
  let out;
487
591
  try {
488
- ({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }));
592
+ ({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: PS_SNAPSHOT_TIMEOUT_MS }));
489
593
  }
490
594
  catch {
491
595
  return [];
@@ -519,7 +623,7 @@ async function readProcessTableWin32() {
519
623
  ({ stdout: out } = await execFileAsync('powershell.exe', [
520
624
  '-NoProfile', '-NonInteractive', '-Command',
521
625
  'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Csv -NoTypeInformation',
522
- ], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true }));
626
+ ], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true, timeout: PS_SNAPSHOT_TIMEOUT_MS }));
523
627
  }
524
628
  catch {
525
629
  return [];
@@ -581,6 +685,7 @@ async function getCwdForPid(pid) {
581
685
  try {
582
686
  const res = await execFileAsync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
583
687
  encoding: 'utf8',
688
+ timeout: LSOF_TIMEOUT_MS,
584
689
  });
585
690
  out = res.stdout;
586
691
  }
@@ -763,6 +868,33 @@ export async function listUnattributedActive(attributed) {
763
868
  // Bounded + staggered lsof probes: same cwds, but a trickle of spawns instead
764
869
  // of one simultaneous system-wide burst that behavioral EDR flags as recon.
765
870
  const cwds = await resolveCwds(kept.map(c => c.pid));
871
+ // The hook state dir is scanned at most ONCE per active-scan, and the ppid map
872
+ // is inverted at most once — both built lazily on the first candidate that
873
+ // lacks an exact launch-time id, so an all-Claude set does neither. The ~3s
874
+ // poll must not re-read the dir (or re-invert the map) per candidate.
875
+ let hookIndex;
876
+ let childrenByParent;
877
+ // Durable `agents run --name` handles keyed by session id — the same source the
878
+ // terminal path uses to name a row. Headless agents have no live-terminals
879
+ // label and no /rename, so without this a `--name`d headless run would surface
880
+ // with only a topic and no tab title. Built once per scan.
881
+ const runNameMap = buildRunNameMap();
882
+ const ensureChildren = () => {
883
+ if (childrenByParent)
884
+ return childrenByParent;
885
+ const m = new Map();
886
+ // The hook records under the agent pid; a wrapper/shell pid we recorded has
887
+ // the agent as a child, so we resolve via a recorded pid's immediate children.
888
+ for (const [childPid, parentPid] of ppidMap) {
889
+ const arr = m.get(parentPid);
890
+ if (arr)
891
+ arr.push(childPid);
892
+ else
893
+ m.set(parentPid, [childPid]);
894
+ }
895
+ childrenByParent = m;
896
+ return m;
897
+ };
766
898
  const out = [];
767
899
  for (let i = 0; i < kept.length; i++) {
768
900
  const { pid, kind } = kept[i];
@@ -772,12 +904,45 @@ export async function listUnattributedActive(attributed) {
772
904
  // .jsonl. The shim's entry may sit on a wrapper ancestor (Windows .cmd
773
905
  // path). Absent entirely (direct launch outside agents-cli) → heuristic.
774
906
  const entry = readPidSessionEntry(pid) ?? readAncestorSessionEntry(pid, ppidMap, kind);
907
+ // Exact session id, in priority: (1) the id we recorded at launch (Claude,
908
+ // known up front via --session-id); (2) the agent's OWN SessionStart hook,
909
+ // authoritative for non-Claude and for agents we didn't launch, joined by
910
+ // launchId/terminalId/pid and kind-guarded against a stale reused-pid file;
911
+ // (3) the newest-jsonl heuristic (sessionIdFromFile, below).
912
+ let exactId = entry?.sessionId;
913
+ // The hook record (when we fall to it) also carries the SessionStart `ts` — the
914
+ // real session-start epoch. Capture it so terminal/headless rows get a
915
+ // `startedAtMs` instead of rendering "0s ago" (they set none before this).
916
+ let hookRec;
917
+ if (!exactId) {
918
+ hookIndex ??= loadHookSessionIndex();
919
+ hookRec = resolveHookSessionRecord(hookIndex, {
920
+ pid,
921
+ kind,
922
+ launchId: entry?.launchId,
923
+ terminalId: entry?.terminalId,
924
+ childPids: ensureChildren().get(pid),
925
+ });
926
+ exactId = hookRec?.session_id;
927
+ }
775
928
  const cwd = cwds[i] ?? entry?.cwd ?? undefined;
776
- const sessionFile = findSessionFileForKind(kind, cwd, entry?.sessionId);
929
+ const sessionFile = findSessionFileForKind(kind, cwd, exactId);
777
930
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
778
931
  const host = detectHost(pid, procByPid);
779
932
  const context = host && UI_HOSTS.has(host) ? 'terminal' : 'headless';
933
+ // pidAlive is true by construction: this pid was just enumerated from the
934
+ // live process table, so an opaque (non-parseable) kind resolves to
935
+ // `unknown`, not a fake `idle`.
780
936
  const { state, tokPerSec } = computeLiveSignals(kind, sessionFile, cwd, true);
937
+ const { birthtimeMs, mtimeMs } = sessionFileTimes(sessionFile);
938
+ // Durable run name from `agents run --name`, resolved by the run's session id
939
+ // — the tab-title handle for a run we launched by name. A headless row carries
940
+ // no /rename label and no live-terminals label, so this handle IS its label
941
+ // (mirrors listTeamsActive `label: a.name`). Absent a `--name`, label stays
942
+ // undefined and the display falls back to the topic on its own — no band-aid.
943
+ const resolvedId = exactId ?? sessionIdFromFile(sessionFile);
944
+ const name = resolvedId ? runNameMap.get(resolvedId) ?? undefined : undefined;
945
+ const label = name;
781
946
  out.push(applyState({
782
947
  context,
783
948
  kind,
@@ -785,25 +950,73 @@ export async function listUnattributedActive(attributed) {
785
950
  tty: procByPid.get(pid)?.tty,
786
951
  pid,
787
952
  cwd,
788
- sessionId: entry?.sessionId ?? sessionIdFromFile(sessionFile),
953
+ sessionId: resolvedId,
954
+ label,
955
+ name,
789
956
  topic,
790
957
  tokPerSec,
791
958
  sessionFile,
959
+ // Session start: the hook's authoritative SessionStart `ts`, else the
960
+ // transcript's creation time. Last activity: the transcript's last write.
961
+ startedAtMs: hookRec?.ts ?? birthtimeMs,
962
+ lastActivityMs: mtimeMs,
792
963
  pidCount: 1 + (foldedByRoot.get(pid) ?? 0),
793
- }, state, sessionFile));
964
+ }, state, sessionFile, true));
794
965
  }
795
966
  // Housekeeping: drop registry files for pids that have since died.
796
967
  prunePidSessionRegistry(isPidAlive);
797
968
  return out;
798
969
  }
970
+ /**
971
+ * Attribute a single tmux pane to the agent actually running in it.
972
+ *
973
+ * The launch registry — written per bare-spawn AND per wrap, each stamped with the
974
+ * `tmuxPane` it targeted (see src/lib/exec.ts) — is the EXACT, per-pane source of
975
+ * truth. So an agent spawned into an EXISTING pane (a split, where `$TMUX` is
976
+ * already set so no new session meta is stamped) is attributed to its OWN launch,
977
+ * not the session's original agent — closing the gap where such an agent was
978
+ * dropped by this source and left to the weaker ps-scan fallback. Session-meta
979
+ * labels remain the fallback for the wrapped origin pane of a session whose
980
+ * registry entry is absent (a failed best-effort write, or a legacy session that
981
+ * predates the registry's `tmuxPane` field) — and ONLY for that origin pane
982
+ * (`meta.pane`), so a split shell pane of a labeled session isn't mis-attributed
983
+ * the wrapped agent. When `meta.pane` is unknown (attach-existing sessions), any
984
+ * labeled pane is accepted and the caller's per-session dedupe keeps one.
985
+ * `source: 'teams'` panes are skipped — teammates are surfaced by listTeamsActive.
986
+ * Pure so it is unit-tested without tmux.
987
+ */
988
+ export function resolvePaneIdentity(pane, meta, liveEntry, getHookIndex) {
989
+ if (meta?.source === 'teams')
990
+ return undefined;
991
+ if (liveEntry) {
992
+ // Exact id: the id recorded at launch (Claude), else the agent's own
993
+ // SessionStart hook joined by launchId/terminalId (non-Claude, or agents we
994
+ // didn't launch) — kind-guarded against a stale reused-pid file.
995
+ const sessionId = liveEntry.sessionId
996
+ ?? resolveHookSessionRecord(getHookIndex(), {
997
+ pid: liveEntry.pid,
998
+ kind: liveEntry.agent,
999
+ launchId: liveEntry.launchId,
1000
+ terminalId: liveEntry.terminalId,
1001
+ })?.session_id;
1002
+ return { agent: liveEntry.agent, sessionId, pid: liveEntry.pid };
1003
+ }
1004
+ const agent = meta?.labels?.agent;
1005
+ const sessionId = meta?.labels?.sessionId;
1006
+ if (agent && sessionId && (meta?.pane == null || meta.pane === pane))
1007
+ return { agent, sessionId };
1008
+ return undefined;
1009
+ }
799
1010
  /**
800
1011
  * Agents hosted in the shared-socket tmux server — the authoritative source for
801
- * tmux-wrapped interactive spawns (see src/lib/exec.ts `runInTmux`). Enumerates
802
- * every pane on the shared socket and keeps those whose session meta was stamped
803
- * with `labels.agent` + `labels.sessionId` by the spawn-wrap. Because tmux (not a
804
- * per-window `live-terminals.json`) is the source of truth, a tmux-hosted agent is
805
- * ALWAYS captured with its exact `%pane` even when the extension registry is stale
806
- * or absent. `source: 'teams'` is skipped teammates are surfaced by listTeamsActive.
1012
+ * tmux-hosted interactive spawns (see src/lib/exec.ts `runInTmux`). Enumerates
1013
+ * every pane on the shared socket and attributes each to the agent running in it
1014
+ * via {@link resolvePaneIdentity}: the per-pane launch registry (exact) first, the
1015
+ * session-meta labels as fallback. Because tmux (not a per-window
1016
+ * `live-terminals.json`) is the source of truth, a tmux-hosted agent is captured
1017
+ * with its exact `%pane` even when the extension registry is stale — INCLUDING an
1018
+ * agent bare-spawned into a split of an existing session, which older logic dropped
1019
+ * (it kept only the first pane per session meta). `source: 'teams'` is skipped.
807
1020
  */
808
1021
  export async function listTmuxAgentSessions() {
809
1022
  const { getDefaultSocketPath } = await import('../tmux/paths.js');
@@ -825,6 +1038,21 @@ export async function listTmuxAgentSessions() {
825
1038
  }
826
1039
  if (res.code !== 0)
827
1040
  return [];
1041
+ // Index live launches by the pane they target, so a pane we did NOT wrap (a
1042
+ // split) resolves to its own agent. Newest launch wins a pane (pid reuse), and
1043
+ // only live pids count — a dead agent's stale entry can't light up its old pane.
1044
+ const liveByPane = new Map();
1045
+ for (const e of listPidSessionEntries()) {
1046
+ if (!e.tmuxPane || !isPidAlive(e.pid, e.startedAtMs))
1047
+ continue;
1048
+ const prev = liveByPane.get(e.tmuxPane);
1049
+ if (!prev || e.startedAtMs > prev.startedAtMs)
1050
+ liveByPane.set(e.tmuxPane, e);
1051
+ }
1052
+ // Hook index is scanned lazily — only when a live launch lacks a recorded id
1053
+ // (a non-Claude split) and needs the SessionStart-hook join.
1054
+ let hookIndex;
1055
+ const getHookIndex = () => (hookIndex ??= loadHookSessionIndex());
828
1056
  const out = [];
829
1057
  const seen = new Set();
830
1058
  for (const line of res.stdout.split('\n')) {
@@ -834,22 +1062,31 @@ export async function listTmuxAgentSessions() {
834
1062
  if (!pane || !sessName)
835
1063
  continue;
836
1064
  const meta = readSessionMeta(sessName);
837
- const agent = meta?.labels?.agent;
838
- const sessionId = meta?.labels?.sessionId;
839
- if (!agent || !sessionId)
840
- continue; // only our stamped agent sessions
841
- if (meta?.source === 'teams')
842
- continue; // teammates come from listTeamsActive
843
- if (seen.has(sessionId))
844
- continue; // first pane per session wins
845
- seen.add(sessionId);
846
- const pid = parseInt(pidRaw, 10) || undefined;
847
- const cwd = meta?.cwd ?? (curPath || undefined);
848
- const sessionFile = findSessionFileForKind(agent, cwd, sessionId);
1065
+ const liveEntry = liveByPane.get(pane);
1066
+ const id = resolvePaneIdentity(pane, meta, liveEntry, getHookIndex);
1067
+ if (!id)
1068
+ continue;
1069
+ // Dedupe by resolved session id; an as-yet-unresolved id (a hookless/lagging
1070
+ // split) keys on the unique pane so it still surfaces as its own row.
1071
+ const dedupKey = id.sessionId ?? pane;
1072
+ if (seen.has(dedupKey))
1073
+ continue;
1074
+ seen.add(dedupKey);
1075
+ // Prefer the registry pid (the agent), falling back to the pane leaf pid. A
1076
+ // pid we emit here goes into getActiveSessions' attributed set, so the ps-scan
1077
+ // does NOT also surface this agent as a duplicate headless row.
1078
+ const pid = id.pid ?? (parseInt(pidRaw, 10) || undefined);
1079
+ const cwd = liveEntry?.cwd ?? meta?.cwd ?? (curPath || undefined);
1080
+ const sessionFile = findSessionFileForKind(id.agent, cwd, id.sessionId);
849
1081
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
850
- const { state, tokPerSec } = computeLiveSignals(agent, sessionFile, cwd, pid ? isPidAlive(pid) : true);
851
- // Provenance is known exactly here (the pane IS a tmux pane) — set it so
852
- // enrichProvenance skips it and the locator/reply rails resolve off the pane.
1082
+ const pidAlive = pid ? isPidAlive(pid, liveEntry?.startedAtMs) : true;
1083
+ const { state, tokPerSec } = computeLiveSignals(id.agent, sessionFile, cwd, pidAlive);
1084
+ const { birthtimeMs, mtimeMs } = sessionFileTimes(sessionFile);
1085
+ // The mux/reply rails are known exactly here (the pane IS a tmux pane), so we
1086
+ // stamp them off the pane. `transport:'local'` is only a placeholder: the pane
1087
+ // can't reveal how the shell above it was reached. enrichProvenance later reads
1088
+ // the pane process's env and upgrades this to 'ssh' (with the real origin) when
1089
+ // SSH_CONNECTION is present, while preserving this mux/reply.
853
1090
  const provenance = {
854
1091
  host: os.hostname(),
855
1092
  transport: 'local',
@@ -858,16 +1095,20 @@ export async function listTmuxAgentSessions() {
858
1095
  };
859
1096
  out.push(applyState({
860
1097
  context: 'terminal',
861
- kind: agent,
1098
+ kind: id.agent,
862
1099
  host: 'tmux',
863
1100
  pid,
864
- sessionId,
1101
+ sessionId: id.sessionId ?? sessionIdFromFile(sessionFile),
865
1102
  cwd,
866
1103
  topic,
867
1104
  tokPerSec,
868
1105
  sessionFile,
1106
+ // tmux panes carry no start timestamp; derive both from the transcript
1107
+ // (creation ≈ start, last write ≈ last activity).
1108
+ startedAtMs: birthtimeMs,
1109
+ lastActivityMs: mtimeMs,
869
1110
  provenance,
870
- }, state, sessionFile));
1111
+ }, state, sessionFile, pidAlive));
871
1112
  }
872
1113
  return out;
873
1114
  }
@@ -898,34 +1139,127 @@ export async function getActiveSessions(opts = {}) {
898
1139
  const unattributed = opts.skipHeadless ? [] : await listUnattributedActive(knownPids);
899
1140
  const merged = dedupeBySession([...tmuxAgents, ...teams, ...terminals, ...cloud, ...unattributed]);
900
1141
  await enrichProvenance(merged);
1142
+ await resolveOrigins(merged);
1143
+ foldPresence(merged);
901
1144
  return merged;
902
1145
  }
1146
+ /**
1147
+ * Fold detach/attach presence onto each row from the detach store. A stored
1148
+ * record wins (`background`/`parked`); otherwise a live terminal session is
1149
+ * `attached`. Ad-hoc headless runs and cloud/team rows stay unmarked — they are
1150
+ * not on the foreground/background axis.
1151
+ */
1152
+ function foldPresence(rows) {
1153
+ for (const s of rows) {
1154
+ if (!s.sessionId)
1155
+ continue;
1156
+ const stored = presenceFromStore(s.sessionId);
1157
+ if (stored)
1158
+ s.presence = stored;
1159
+ else if (s.context === 'terminal')
1160
+ s.presence = 'attached';
1161
+ }
1162
+ }
903
1163
  /**
904
1164
  * Attach provenance (host / local-vs-SSH / tmux pane / reply rail) to every
905
1165
  * session that has a live pid. Mutates in place. Runs after dedupe so we probe
906
1166
  * each session once, not once per fork pid. Probes run in parallel — each is a
907
1167
  * single /proc read (Linux) or `ps` call (macOS); failures leave `provenance`
908
1168
  * undefined rather than blocking the listing.
1169
+ *
1170
+ * A row that already carries provenance (the tmux path, which knows its exact
1171
+ * mux/reply from the pane) is not skipped — it is probe-and-MERGED. The tmux
1172
+ * path can only stamp a `transport:'local'` placeholder because the pane alone
1173
+ * doesn't reveal how the shell above it was reached; the process env does. So we
1174
+ * still read the env and fill in the real SSH origin/term, while preserving the
1175
+ * authoritative mux/reply the pane already gave us. Skipping this (the old
1176
+ * behavior) is exactly why ssh-launched tmux sessions rendered as local.
909
1177
  */
910
1178
  async function enrichProvenance(sessions) {
911
1179
  await Promise.all(sessions.map(async (s) => {
912
- if (s.provenance || !s.pid)
1180
+ if (!s.pid)
913
1181
  return;
914
- s.provenance = await detectProvenance(s.pid);
1182
+ const probed = await detectProvenance(s.pid);
1183
+ if (!probed)
1184
+ return;
1185
+ if (!s.provenance) {
1186
+ s.provenance = probed;
1187
+ return;
1188
+ }
1189
+ // Row already carries exact mux/reply (the tmux path). Fill only what a
1190
+ // pre-set provenance can't know from the pane alone: the real launch origin.
1191
+ if (probed.transport === 'ssh' && !s.provenance.ssh) {
1192
+ s.provenance.transport = 'ssh';
1193
+ s.provenance.ssh = probed.ssh;
1194
+ }
1195
+ if (probed.term && !s.provenance.term)
1196
+ s.provenance.term = probed.term;
915
1197
  }));
916
1198
  }
1199
+ /**
1200
+ * Match an SSH client IP to a registered device (pure — testable with a plain
1201
+ * registry object). Returns the device name + ssh login user when the IP is a
1202
+ * known device address.
1203
+ */
1204
+ export function matchOriginDevice(clientIp, reg) {
1205
+ for (const d of Object.values(reg)) {
1206
+ if (d.address?.ip && d.address.ip === clientIp) {
1207
+ return { device: d.name, ...(d.user ? { user: d.user } : {}) };
1208
+ }
1209
+ }
1210
+ return undefined;
1211
+ }
1212
+ /**
1213
+ * Resolve the initiating device for every ssh-transport session by matching its
1214
+ * `ssh.clientIp` against the device registry. Read-only and best-effort: a
1215
+ * registry that can't be loaded, or an IP that matches no device, leaves
1216
+ * `origin` undefined (the raw client IP is still on `ssh`). Mutates in place.
1217
+ */
1218
+ async function resolveOrigins(sessions) {
1219
+ const needing = sessions.filter((s) => s.provenance?.ssh && !s.provenance.origin);
1220
+ if (needing.length === 0)
1221
+ return;
1222
+ let reg;
1223
+ try {
1224
+ reg = await loadDevices();
1225
+ }
1226
+ catch {
1227
+ return;
1228
+ }
1229
+ for (const s of needing) {
1230
+ const match = matchOriginDevice(s.provenance.ssh.clientIp, reg);
1231
+ if (match)
1232
+ s.provenance.origin = match;
1233
+ }
1234
+ }
1235
+ /**
1236
+ * Identity for a row the scan could not tie to a session: a daemon's worker
1237
+ * processes (an OpenClaw gateway spawning `codex`, a supervisor pool) have no
1238
+ * session id, no transcript file, and no cloud/run handle — nothing tells two of
1239
+ * them apart, because nothing distinguishes them. Same binary + same working
1240
+ * directory + same context IS the identity, so N indistinguishable workers
1241
+ * collapse to one row carrying `pidCount: N`.
1242
+ *
1243
+ * Returns undefined with no cwd: without it there is no stable identity, and
1244
+ * keying on kind alone would fold unrelated agents onto one row.
1245
+ */
1246
+ function anonymousWorkerKey(s) {
1247
+ if (!s.cwd)
1248
+ return undefined;
1249
+ return `anon\0${s.kind}\0${s.context}\0${s.cwd}`;
1250
+ }
917
1251
  /**
918
1252
  * Collapse rows that resolve to the *same* session — a session with many
919
1253
  * subagent/fork PIDs (all matched to one transcript file) would otherwise print
920
- * dozens of identical rows. Keyed by session id (falling back to the file), the
921
- * first row wins and carries a `pidCount`. Rows with no session identity (cloud,
922
- * unresolved headless) pass through untouched.
1254
+ * dozens of identical rows. Keyed by session id, falling back to the transcript
1255
+ * file, then the cloud/run handle, then {@link anonymousWorkerKey}. The first row
1256
+ * wins and carries a `pidCount`.
923
1257
  */
924
- function dedupeBySession(sessions) {
1258
+ export function dedupeBySession(sessions) {
925
1259
  const out = [];
926
1260
  const byKey = new Map();
927
1261
  for (const s of sessions) {
928
- const key = s.sessionId || s.sessionFile;
1262
+ const key = s.sessionId || s.sessionFile || s.cloudTaskId || s.agentId || anonymousWorkerKey(s);
929
1263
  if (!key) {
930
1264
  out.push(s);
931
1265
  continue;