@phnx-labs/agents-cli 1.20.77 → 1.20.82

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 (212) hide show
  1. package/CHANGELOG.md +589 -0
  2. package/README.md +27 -25
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +9 -0
  5. package/dist/commands/activity.js +153 -24
  6. package/dist/commands/apply.js +11 -4
  7. package/dist/commands/cli.js +1 -1
  8. package/dist/commands/commands.d.ts +1 -1
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/defaults.js +3 -11
  11. package/dist/commands/doctor.d.ts +74 -0
  12. package/dist/commands/doctor.js +718 -40
  13. package/dist/commands/events.js +1 -1
  14. package/dist/commands/exec.js +53 -8
  15. package/dist/commands/feed.d.ts +4 -0
  16. package/dist/commands/feed.js +87 -11
  17. package/dist/commands/harness.js +2 -2
  18. package/dist/commands/hooks.js +1 -1
  19. package/dist/commands/import.js +8 -4
  20. package/dist/commands/logs.js +6 -0
  21. package/dist/commands/models.js +1 -1
  22. package/dist/commands/packages.js +6 -6
  23. package/dist/commands/permissions.js +1 -1
  24. package/dist/commands/profiles.js +1 -1
  25. package/dist/commands/pull.d.ts +1 -1
  26. package/dist/commands/pull.js +4 -4
  27. package/dist/commands/repo.d.ts +78 -0
  28. package/dist/commands/repo.js +195 -11
  29. package/dist/commands/routines.js +16 -4
  30. package/dist/commands/rules.js +13 -12
  31. package/dist/commands/secrets.d.ts +3 -1
  32. package/dist/commands/secrets.js +108 -34
  33. package/dist/commands/sessions-browser.d.ts +45 -3
  34. package/dist/commands/sessions-browser.js +118 -12
  35. package/dist/commands/sessions-export.d.ts +3 -0
  36. package/dist/commands/sessions-export.js +13 -10
  37. package/dist/commands/sessions-inject.d.ts +7 -4
  38. package/dist/commands/sessions-inject.js +18 -18
  39. package/dist/commands/sessions-picker.d.ts +15 -1
  40. package/dist/commands/sessions-picker.js +90 -7
  41. package/dist/commands/sessions-resume.d.ts +16 -0
  42. package/dist/commands/sessions-resume.js +14 -13
  43. package/dist/commands/sessions.d.ts +126 -1
  44. package/dist/commands/sessions.js +365 -42
  45. package/dist/commands/set.d.ts +15 -0
  46. package/dist/commands/set.js +79 -0
  47. package/dist/commands/setup-fleet.d.ts +21 -0
  48. package/dist/commands/setup-fleet.js +201 -0
  49. package/dist/commands/setup-secrets.d.ts +20 -0
  50. package/dist/commands/setup-secrets.js +223 -0
  51. package/dist/commands/setup.js +17 -3
  52. package/dist/commands/skills.js +1 -1
  53. package/dist/commands/ssh.js +9 -1
  54. package/dist/commands/sync.js +7 -2
  55. package/dist/commands/teams-picker.js +0 -1
  56. package/dist/commands/teams.d.ts +33 -1
  57. package/dist/commands/teams.js +208 -44
  58. package/dist/commands/versions.js +10 -5
  59. package/dist/commands/view.d.ts +1 -0
  60. package/dist/commands/view.js +11 -0
  61. package/dist/index.js +8 -13
  62. package/dist/lib/acp/harnesses.js +0 -7
  63. package/dist/lib/activity.d.ts +156 -0
  64. package/dist/lib/activity.js +282 -0
  65. package/dist/lib/agents.d.ts +9 -23
  66. package/dist/lib/agents.js +72 -100
  67. package/dist/lib/auto-pull.d.ts +16 -8
  68. package/dist/lib/auto-pull.js +23 -28
  69. package/dist/lib/browser/service.js +3 -0
  70. package/dist/lib/browser/types.d.ts +7 -0
  71. package/dist/lib/capabilities.js +6 -2
  72. package/dist/lib/crabbox/lease.js +2 -2
  73. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  74. package/dist/lib/crabbox/setup-copy.js +4 -4
  75. package/dist/lib/daemon.d.ts +26 -40
  76. package/dist/lib/daemon.js +63 -123
  77. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  78. package/dist/lib/devices/fleet-divergence.js +188 -0
  79. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  80. package/dist/lib/devices/fleet-inventory.js +57 -0
  81. package/dist/lib/devices/fleet.d.ts +1 -1
  82. package/dist/lib/devices/fleet.js +1 -1
  83. package/dist/lib/devices/health-report.d.ts +10 -2
  84. package/dist/lib/devices/health-report.js +32 -1
  85. package/dist/lib/doctor-diff.d.ts +20 -0
  86. package/dist/lib/doctor-diff.js +6 -1
  87. package/dist/lib/drift.d.ts +31 -11
  88. package/dist/lib/drift.js +58 -7
  89. package/dist/lib/events.d.ts +8 -1
  90. package/dist/lib/events.js +10 -1
  91. package/dist/lib/exec.js +35 -8
  92. package/dist/lib/feed-post.d.ts +28 -1
  93. package/dist/lib/feed-post.js +110 -2
  94. package/dist/lib/fleet/apply.js +8 -1
  95. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  96. package/dist/lib/fleet/auth-sync.js +25 -11
  97. package/dist/lib/fleet/remote-login.js +5 -0
  98. package/dist/lib/git.d.ts +27 -14
  99. package/dist/lib/git.js +138 -43
  100. package/dist/lib/hooks.d.ts +42 -0
  101. package/dist/lib/hooks.js +137 -93
  102. package/dist/lib/hosts/dispatch.d.ts +28 -1
  103. package/dist/lib/hosts/dispatch.js +33 -5
  104. package/dist/lib/hosts/option.js +2 -2
  105. package/dist/lib/hosts/passthrough.d.ts +22 -1
  106. package/dist/lib/hosts/passthrough.js +238 -13
  107. package/dist/lib/hosts/remote-cmd.js +2 -0
  108. package/dist/lib/hosts/run-target.d.ts +2 -0
  109. package/dist/lib/hosts/run-target.js +1 -0
  110. package/dist/lib/hq/floor.d.ts +1 -1
  111. package/dist/lib/hq/floor.js +6 -1
  112. package/dist/lib/mcp.js +3 -71
  113. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  116. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  117. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  118. package/dist/lib/menubar/notify-desktop.js +41 -5
  119. package/dist/lib/merged-resources.d.ts +11 -0
  120. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  121. package/dist/lib/permissions.d.ts +0 -32
  122. package/dist/lib/permissions.js +4 -157
  123. package/dist/lib/picker.js +1 -1
  124. package/dist/lib/platform/process.d.ts +16 -5
  125. package/dist/lib/platform/process.js +54 -0
  126. package/dist/lib/plugins.d.ts +0 -8
  127. package/dist/lib/plugins.js +4 -110
  128. package/dist/lib/project-resources.js +4 -1
  129. package/dist/lib/refresh.d.ts +5 -3
  130. package/dist/lib/refresh.js +7 -5
  131. package/dist/lib/resources/commands.js +4 -1
  132. package/dist/lib/resources/mcp.js +0 -4
  133. package/dist/lib/resources/permissions.d.ts +1 -1
  134. package/dist/lib/resources/permissions.js +1 -5
  135. package/dist/lib/resources/rules.js +4 -1
  136. package/dist/lib/resources/skills.js +4 -1
  137. package/dist/lib/resources/subagents.js +4 -0
  138. package/dist/lib/resources/types.d.ts +1 -1
  139. package/dist/lib/routines.d.ts +20 -0
  140. package/dist/lib/routines.js +6 -0
  141. package/dist/lib/run-defaults.d.ts +1 -0
  142. package/dist/lib/run-defaults.js +9 -0
  143. package/dist/lib/runner.d.ts +0 -12
  144. package/dist/lib/runner.js +71 -29
  145. package/dist/lib/sandbox.js +0 -16
  146. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  147. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  148. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  149. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  150. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  151. package/dist/lib/secrets/agent.d.ts +18 -0
  152. package/dist/lib/secrets/agent.js +131 -17
  153. package/dist/lib/secrets/audit.d.ts +46 -0
  154. package/dist/lib/secrets/audit.js +56 -0
  155. package/dist/lib/secrets/bundles.d.ts +12 -9
  156. package/dist/lib/secrets/bundles.js +47 -31
  157. package/dist/lib/secrets/index.d.ts +2 -1
  158. package/dist/lib/secrets/index.js +8 -2
  159. package/dist/lib/secrets/remote.d.ts +75 -2
  160. package/dist/lib/secrets/remote.js +139 -5
  161. package/dist/lib/secrets/scope.d.ts +26 -0
  162. package/dist/lib/secrets/scope.js +29 -0
  163. package/dist/lib/secrets/session-store.d.ts +10 -0
  164. package/dist/lib/secrets/session-store.js +64 -11
  165. package/dist/lib/secrets/sync.js +3 -3
  166. package/dist/lib/session/active.d.ts +117 -26
  167. package/dist/lib/session/active.js +199 -53
  168. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  169. package/dist/lib/session/actor-sidecar.js +101 -0
  170. package/dist/lib/session/db.d.ts +41 -1
  171. package/dist/lib/session/db.js +173 -8
  172. package/dist/lib/session/digest.js +126 -21
  173. package/dist/lib/session/discover.d.ts +19 -2
  174. package/dist/lib/session/discover.js +32 -11
  175. package/dist/lib/session/hook-sessions.d.ts +9 -0
  176. package/dist/lib/session/hook-sessions.js +57 -8
  177. package/dist/lib/session/origin-machine.d.ts +18 -0
  178. package/dist/lib/session/origin-machine.js +34 -0
  179. package/dist/lib/session/pid-registry.d.ts +13 -0
  180. package/dist/lib/session/render.d.ts +2 -0
  181. package/dist/lib/session/render.js +1 -1
  182. package/dist/lib/session/state.d.ts +1 -0
  183. package/dist/lib/session/state.js +1 -1
  184. package/dist/lib/session/sync/config.js +2 -2
  185. package/dist/lib/session/types.d.ts +16 -0
  186. package/dist/lib/smart-launch.d.ts +86 -0
  187. package/dist/lib/smart-launch.js +172 -0
  188. package/dist/lib/staleness/detectors/commands.js +2 -2
  189. package/dist/lib/staleness/detectors/permissions.js +1 -45
  190. package/dist/lib/staleness/writers/commands.js +4 -5
  191. package/dist/lib/staleness/writers/hooks.js +1 -1
  192. package/dist/lib/startup/command-registry.d.ts +1 -2
  193. package/dist/lib/startup/command-registry.js +2 -4
  194. package/dist/lib/subagents-registry.js +5 -12
  195. package/dist/lib/subagents.d.ts +0 -10
  196. package/dist/lib/subagents.js +0 -12
  197. package/dist/lib/teams/agents.d.ts +0 -2
  198. package/dist/lib/teams/agents.js +10 -27
  199. package/dist/lib/teams/index.d.ts +1 -1
  200. package/dist/lib/teams/index.js +1 -1
  201. package/dist/lib/types.d.ts +17 -9
  202. package/dist/lib/usage.d.ts +28 -5
  203. package/dist/lib/usage.js +271 -8
  204. package/dist/lib/versions.js +7 -1
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +25 -13
  207. package/package.json +1 -2
  208. package/dist/commands/check.d.ts +0 -15
  209. package/dist/commands/check.js +0 -180
  210. package/dist/commands/resources.d.ts +0 -5
  211. package/dist/lib/secrets/account-token.d.ts +0 -20
  212. package/dist/lib/secrets/account-token.js +0 -64
@@ -25,19 +25,33 @@ import { listActiveTasks } from '../cloud/store.js';
25
25
  import { AgentManager } from '../teams/agents.js';
26
26
  import { getTerminalsDir } from '../state.js';
27
27
  import { readPidSessionEntry, listPidSessionEntries, prunePidSessionRegistry } from './pid-registry.js';
28
- import { loadHookSessionIndex, resolveHookSessionRecord } from './hook-sessions.js';
29
- import { buildClaudeLabelMap } from './discover.js';
28
+ import { readSessionActorRecord } from './actor-sidecar.js';
29
+ import { loadHookSessionIndex, resolveHookSessionRecord, readStateSessionRecord } from './hook-sessions.js';
30
+ import { buildClaudeLabelMap, getAgentSessionDirs } from './discover.js';
30
31
  import { buildRunNameMap } from './run-names.js';
31
32
  import { latestSessionFileForCwd } from './db.js';
32
33
  import { extractSessionTopic } from './prompt.js';
33
34
  import { readSessionTailWithRaw } from './tail.js';
35
+ import { parseSession } from './parse.js';
34
36
  import { computeTokPerSec } from './throughput.js';
35
37
  import { inferSessionState } from './state.js';
38
+ import { isSessionTrackedAgent } from './types.js';
36
39
  import { detectProvenance } from './provenance.js';
37
40
  import { loadDevices } from '../devices/registry.js';
38
41
  import { presenceFromStore } from './detached.js';
39
42
  import { mapBounded } from '../concurrency.js';
40
43
  const execFileAsync = promisify(execFile);
44
+ /**
45
+ * The owner (actor id) to show for a session in `--active`. Prefers the actor
46
+ * recorded on the live-attribution source (the pid registry / teammate record),
47
+ * but falls back to the durable per-session actor sidecar — written at spawn and,
48
+ * unlike the pid entry, NOT overwritten by the SessionStart hook's own by-pid
49
+ * write. Without this fallback a real `agents run` shows no owner whenever the
50
+ * hook's actor-less entry wins the by-pid file (RUSH-2018 fix).
51
+ */
52
+ export function resolveOwner(pidActor, sessionId) {
53
+ return pidActor ?? (sessionId ? readSessionActorRecord(sessionId)?.actor : undefined) ?? undefined;
54
+ }
41
55
  /**
42
56
  * Per-PID `lsof` probes run bounded and staggered rather than as one parallel
43
57
  * fan-out: a simultaneous system-wide `lsof` burst reads to behavioral EDR
@@ -67,7 +81,6 @@ export function activeStatusFromCloudStatus(status) {
67
81
  return 'queued';
68
82
  }
69
83
  }
70
- const HOME = os.homedir();
71
84
  const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
72
85
  /**
73
86
  * A process is classified `running` if its session file was touched in the
@@ -83,6 +96,16 @@ const ACTIVE_MTIME_WINDOW_MS = 2 * 60_000;
83
96
  * transcript just because a GUI app service with the same basename is alive.
84
97
  */
85
98
  export const ACTIVE_SESSION_STALE_MS = 24 * 60 * 60_000;
99
+ /**
100
+ * A session whose transcript hasn't been written in this long is ABANDONED /
101
+ * dangling — the framework can no longer treat it as live work, whether its PID
102
+ * is dead (long gone) or still alive but making no progress (a hung agent).
103
+ * Two days: past a normal work gap, well short of a session legitimately kept
104
+ * open across a long weekend. Deliberately far wider than
105
+ * {@link ACTIVE_SESSION_STALE_MS} (which bounds transcript-borrowing, a different
106
+ * concern) — this is the lifecycle threshold, not the freshness window.
107
+ */
108
+ export const ABANDONED_STALE_MS = 2 * 24 * 60 * 60_000;
86
109
  /** Executables we recognize as agent CLIs when scanning the process table. */
87
110
  const AGENT_CLI_NAMES = {
88
111
  claude: 'claude',
@@ -217,9 +240,45 @@ function claudeProjectDirName(cwd) {
217
240
  * Locate the active Claude session file for a process. If we know the session
218
241
  * UUID (from terminal env or team parent), prefer the exact match. Otherwise
219
242
  * fall back to the most-recent-mtime .jsonl in the project's folder.
243
+ *
244
+ * Searches EVERY version-home project root, not just the live `~/.claude`
245
+ * symlink. `~/.claude` points at the currently-installed agent version; a
246
+ * session launched under an EARLIER version keeps its transcript under that
247
+ * version's home (`…/.history/versions/claude/<ver>/home/.claude/projects/`).
248
+ * Resolving only `~/.claude/projects` meant that the instant a newer version
249
+ * was installed, every still-running older-version session lost its transcript
250
+ * here — no `sessionFile`, so no start/activity time, so `agents sessions`
251
+ * rendered it `unknown` and the watchdog skipped it as "no activity timestamp".
252
+ * `getAgentSessionDirs('claude','projects')` is the same version-aware enumerator
253
+ * the rest of the CLI uses, so this stays in lockstep with discovery.
220
254
  */
221
255
  function findClaudeSessionFile(cwd, sessionId) {
222
- return pickSessionFile(path.join(HOME, '.claude', 'projects', claudeProjectDirName(cwd)), sessionId);
256
+ return pickClaudeSessionFileAcrossRoots(getAgentSessionDirs('claude', 'projects'), cwd, sessionId);
257
+ }
258
+ /**
259
+ * Resolve a Claude transcript for `cwd` across the given project roots, newest
260
+ * mtime winning when the same id/cwd resolves in more than one version home
261
+ * (the actively-written copy is the newest). Pure over `projectRoots`, so it is
262
+ * testable against temp dirs without touching the real home directory.
263
+ */
264
+ export function pickClaudeSessionFileAcrossRoots(projectRoots, cwd, sessionId) {
265
+ const enc = claudeProjectDirName(cwd);
266
+ let best;
267
+ for (const root of projectRoots) {
268
+ const hit = pickSessionFile(path.join(root, enc), sessionId);
269
+ if (!hit)
270
+ continue;
271
+ let mtime;
272
+ try {
273
+ mtime = fs.statSync(hit).mtimeMs;
274
+ }
275
+ catch {
276
+ continue;
277
+ }
278
+ if (!best || mtime > best.mtime)
279
+ best = { path: hit, mtime };
280
+ }
281
+ return best?.path;
223
282
  }
224
283
  /**
225
284
  * Pick a Claude transcript file within a project dir.
@@ -274,46 +333,68 @@ export function sessionFileTimes(sessionFile) {
274
333
  return {};
275
334
  }
276
335
  }
336
+ /**
337
+ * The lifecycle status computed purely from the two hard signals the framework
338
+ * always has — PID liveness and transcript last-write (mtime) — independent of
339
+ * anything the agent self-reports or what its last transcript turn happened to
340
+ * look like. Returns the definitive lifecycle label when one applies, or
341
+ * `undefined` when the process is live and fresh (so the caller uses the richer
342
+ * activity-derived status instead).
343
+ *
344
+ * - No write in {@link ABANDONED_STALE_MS} ⇒ `abandoned` (dangling): a
345
+ * days-stale session is not live work, whether its PID is dead (long gone)
346
+ * or still alive but stuck. Checked first — it outranks a bare `closed`.
347
+ * - Otherwise, PID dead ⇒ `closed`: the process has exited. This is the fix
348
+ * for the old "dead PID reports idle" lie — `idle` reads as "done, waiting
349
+ * for you", but a dead process is done, period.
350
+ * - Otherwise (alive + fresh) ⇒ `undefined`: defer to the activity engine.
351
+ */
352
+ export function lifecycleStatus(pidAlive, mtimeMs, nowMs = Date.now()) {
353
+ if (mtimeMs !== undefined && nowMs - mtimeMs >= ABANDONED_STALE_MS)
354
+ return 'abandoned';
355
+ if (!pidAlive)
356
+ return 'closed';
357
+ return undefined;
358
+ }
277
359
  /**
278
360
  * 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.
361
+ * available — an opaque kind we cannot parse (cursor, openclaw), or a transcript
362
+ * whose parse/tail was empty or unreadable. Honest by construction: computed from
363
+ * PID + mtime, never a fabricated `idle`.
282
364
  *
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`.
365
+ * - Dead PID, or a days-stale transcript {@link lifecycleStatus} (`closed` /
366
+ * `abandoned`). A dead process is `closed`, not the old fabricated `idle`
367
+ * (which the UI reads as "done, waiting for you"); a dead process whose file
368
+ * also vanished is still `closed` (death is a definitive answer, not `unknown`).
369
+ * - Live + fresh ⇒ `running`. A live process is, at minimum, running: we may
370
+ * not see WHAT an opaque harness (or an empty tail) is doing, but the process
371
+ * being alive is a positive signal never a blank `unknown` (the old blanket
372
+ * bug for every non-claude/codex live agent) nor a downgraded `idle`.
293
373
  */
294
- export function resolveFallbackStatus(sessionFile, pidAlive) {
295
- if (!sessionFile)
296
- return pidAlive ? 'unknown' : 'idle';
297
- try {
298
- const mtimeMs = fs.statSync(sessionFile).mtimeMs;
299
- return Date.now() - mtimeMs < ACTIVE_MTIME_WINDOW_MS ? 'running' : 'idle';
300
- }
301
- catch {
302
- return 'unknown';
303
- }
374
+ export function resolveFallbackStatus(sessionFile, pidAlive, nowMs = Date.now()) {
375
+ const { mtimeMs } = sessionFileTimes(sessionFile);
376
+ return lifecycleStatus(pidAlive, mtimeMs, nowMs) ?? 'running';
304
377
  }
305
378
  /**
306
- * Locate the live transcript for an agent process. Claude files are keyed by
307
- * cwd (+ optional session uuid); Codex files are date-partitioned, so we resolve
308
- * the newest indexed Codex session for the cwd instead.
379
+ * Locate the live transcript for an agent process. Claude files are keyed by cwd
380
+ * (+ optional session uuid), so they resolve straight off disk. Every OTHER
381
+ * tracked harness Codex (date-partitioned), plus grok / droid / rush / gemini /
382
+ * kimi / hermes / opencode / antigravity (per-session dirs, SQLite, single-JSON)
383
+ * — is resolved through the session index by cwd: the newest indexed transcript
384
+ * for that cwd, bounded by ACTIVE_SESSION_STALE_MS so a live pid never borrows a
385
+ * weeks-old transcript. This is what lets a live NON-claude/codex agent get a real
386
+ * status instead of falling through to `unknown` (the file feeds
387
+ * {@link computeLiveSignals}). An opaque kind we don't track (cursor) still yields
388
+ * undefined here and degrades honestly to a live `running`.
309
389
  */
310
390
  export function findSessionFileForKind(kind, cwd, sessionId) {
311
391
  if (!cwd)
312
392
  return undefined;
313
393
  if (kind === 'claude')
314
394
  return findClaudeSessionFile(cwd, sessionId);
315
- if (kind === 'codex')
316
- return latestSessionFileForCwd('codex', cwd, { maxAgeMs: ACTIVE_SESSION_STALE_MS });
395
+ if (isSessionTrackedAgent(kind)) {
396
+ return latestSessionFileForCwd(kind, cwd, { maxAgeMs: ACTIVE_SESSION_STALE_MS });
397
+ }
317
398
  return undefined;
318
399
  }
319
400
  /** Recover the session UUID from a transcript filename (Claude `<uuid>.jsonl`, Codex `rollout-…-<uuid>.jsonl`). */
@@ -323,28 +404,64 @@ function sessionIdFromFile(file) {
323
404
  return undefined;
324
405
  return path.basename(file).match(UUID_RE)?.[0];
325
406
  }
407
+ /** Cap the events fed to state inference for a non-tailable harness — the last
408
+ * turns are all `inferActivity` needs, and a bound keeps a huge transcript cheap. */
409
+ const LIVE_STATE_MAX_EVENTS = 80;
326
410
  /**
327
- * Read a session file's tail ONCE and derive both the inferred state and the
328
- * output-token throughput from it. State needs the normalized event model;
329
- * throughput needs the raw lines the event model drops (Codex `token_count`), so
330
- * both come off the same {@link readSessionTailWithRaw} read. Only Claude/Codex
331
- * carry live state; other kinds yield an empty signal set.
411
+ * Parse a NON-claude/codex transcript with that harness's own parser and return
412
+ * its last events for state inference. These formats vary too much for the
413
+ * single-file byte-tail fast path (per-session dirs for grok/kimi, SQLite for
414
+ * opencode/antigravity, single-JSON for gemini/hermes), so parse the whole
415
+ * (size-guarded, via safeReadSessionFile) transcript and keep the tail. A parse
416
+ * failure yields no events, degrading honestly to the live fallback.
332
417
  */
333
- function computeLiveSignals(kind, sessionFile, cwd, pidAlive) {
418
+ function parseTailEventsForKind(agent, sessionFile) {
419
+ let events;
420
+ try {
421
+ events = parseSession(sessionFile, agent);
422
+ }
423
+ catch {
424
+ return [];
425
+ }
426
+ return events.length > LIVE_STATE_MAX_EVENTS ? events.slice(-LIVE_STATE_MAX_EVENTS) : events;
427
+ }
428
+ /**
429
+ * Derive the inferred state (working / waiting / idle + preview/badges) and, for
430
+ * the two harnesses whose raw lines carry it, the output-token throughput.
431
+ *
432
+ * Claude/Codex take the fast bounded byte-tail ({@link readSessionTailWithRaw}) —
433
+ * the hot path, and the only two that also yield throughput (their raw lines
434
+ * carry usage the event model drops). EVERY OTHER tracked harness (grok, droid,
435
+ * rush, gemini, kimi, hermes, opencode, antigravity) is parsed with its own
436
+ * parser and run through the SAME {@link inferSessionState}, so a live
437
+ * non-claude/codex agent gets a real working/waiting/idle instead of the blanket
438
+ * `unknown` it used to fall through to. An opaque/untracked kind (cursor) or an
439
+ * unreadable/empty transcript yields an empty signal set, and the caller's
440
+ * {@link resolveFallbackStatus} reports the honest live floor (`running`).
441
+ */
442
+ export function computeLiveSignals(kind, sessionFile, cwd, pidAlive) {
334
443
  if (!sessionFile)
335
444
  return {};
336
- const agent = kind === 'codex' ? 'codex' : 'claude';
337
- const { events, content } = readSessionTailWithRaw(sessionFile, agent);
338
- if (events.length === 0)
339
- return {};
340
445
  let mtimeMs;
341
446
  try {
342
447
  mtimeMs = fs.statSync(sessionFile).mtimeMs;
343
448
  }
344
449
  catch { /* vanished between calls */ }
345
- const state = inferSessionState(events, { cwd, pidAlive, mtimeMs, activeWindowMs: ACTIVE_MTIME_WINDOW_MS });
346
- const tokPerSec = computeTokPerSec(content, agent);
347
- return { state, tokPerSec: tokPerSec > 0 ? tokPerSec : undefined };
450
+ const ctx = { cwd, pidAlive, mtimeMs, activeWindowMs: ACTIVE_MTIME_WINDOW_MS };
451
+ if (kind === 'claude' || kind === 'codex') {
452
+ const { events, content } = readSessionTailWithRaw(sessionFile, kind);
453
+ if (events.length === 0)
454
+ return {};
455
+ const state = inferSessionState(events, ctx);
456
+ const tokPerSec = computeTokPerSec(content, kind);
457
+ return { state, tokPerSec: tokPerSec > 0 ? tokPerSec : undefined };
458
+ }
459
+ if (!isSessionTrackedAgent(kind))
460
+ return {};
461
+ const events = parseTailEventsForKind(kind, sessionFile);
462
+ if (events.length === 0)
463
+ return {};
464
+ return { state: inferSessionState(events, ctx) };
348
465
  }
349
466
  /** Map inferred activity onto the coarse ActiveStatus used by the renderer and counts. */
350
467
  function statusFromActivity(activity) {
@@ -352,17 +469,23 @@ function statusFromActivity(activity) {
352
469
  }
353
470
  /**
354
471
  * Fold a computed SessionState onto an active-session row: rich status +
355
- * preview + PR/worktree/ticket badges. With no state (unreadable/non-Claude/
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`).
472
+ * preview + PR/worktree/ticket badges. With no state (an opaque/untracked kind,
473
+ * or an unreadable/empty transcript) it degrades to
474
+ * {@link resolveFallbackStatus}, which reports the honest live floor (`running`
475
+ * for an alive process) rather than the old blanket `unknown`.
359
476
  */
360
477
  function applyState(base, state, fallbackFile, pidAlive) {
361
478
  if (!state)
362
479
  return { ...base, status: resolveFallbackStatus(fallbackFile, pidAlive) };
480
+ // Lifecycle (closed/abandoned) is computed from PID + mtime and OVERRIDES the
481
+ // activity-derived status: a dead or days-stale process is closed/abandoned no
482
+ // matter what its last parsed transcript turn looked like (a dead session whose
483
+ // tail ended mid-tool-call must not read as `running`). `base.lastActivityMs` is
484
+ // the transcript mtime the row already resolved — reuse it, no extra stat.
485
+ const life = lifecycleStatus(pidAlive, base.lastActivityMs ?? sessionFileTimes(fallbackFile).mtimeMs);
363
486
  return {
364
487
  ...base,
365
- status: statusFromActivity(state.activity),
488
+ status: life ?? statusFromActivity(state.activity),
366
489
  activity: state.activity,
367
490
  awaitingReason: state.awaitingReason,
368
491
  question: state.question,
@@ -477,6 +600,10 @@ export async function listTeamsActive() {
477
600
  lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
478
601
  teamName: a.taskName,
479
602
  agentId: a.agentId,
603
+ // The frozen actor stamped on the teammate record (RUSH-2028) — who ran
604
+ // this teammate, surfaced as the owner in --active (RUSH-2018); sidecar
605
+ // fallback for a teammate record predating the actor field.
606
+ owner: resolveOwner(a.actor, sessionId ?? sessionIdFromFile(sessionFile)),
480
607
  }, state, sessionFile, pidAlive);
481
608
  });
482
609
  }
@@ -505,7 +632,8 @@ export async function listTerminalsActive() {
505
632
  // back to the stale cached id — the duplicate-card fix comes from
506
633
  // pickSessionFile no longer borrowing a sibling, not from this lookup. Kept as
507
634
  // a forward-looking hook for the cases where the pid does line up.
508
- const resolvedId = readPidSessionEntry(t.pid)?.sessionId ?? t.sessionId;
635
+ const pidEntry = readPidSessionEntry(t.pid);
636
+ const resolvedId = pidEntry?.sessionId ?? t.sessionId;
509
637
  const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, resolvedId);
510
638
  // Prefer label from live terminal, fall back to Claude's session label
511
639
  const label = t.label ?? (t.sessionId ? labelMap.get(t.sessionId) : undefined) ?? undefined;
@@ -531,6 +659,7 @@ export async function listTerminalsActive() {
531
659
  startedAtMs: t.startedAtMs,
532
660
  lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
533
661
  windowId: t.windowId,
662
+ owner: resolveOwner(pidEntry?.actor, resolvedId),
534
663
  }, state, sessionFile, pidAlive);
535
664
  });
536
665
  }
@@ -932,7 +1061,7 @@ export async function listUnattributedActive(attributed) {
932
1061
  const context = host && UI_HOSTS.has(host) ? 'terminal' : 'headless';
933
1062
  // pidAlive is true by construction: this pid was just enumerated from the
934
1063
  // live process table, so an opaque (non-parseable) kind resolves to
935
- // `unknown`, not a fake `idle`.
1064
+ // `running`, not a fake `idle` or blanket `unknown`.
936
1065
  const { state, tokPerSec } = computeLiveSignals(kind, sessionFile, cwd, true);
937
1066
  const { birthtimeMs, mtimeMs } = sessionFileTimes(sessionFile);
938
1067
  // Durable run name from `agents run --name`, resolved by the run's session id
@@ -961,6 +1090,7 @@ export async function listUnattributedActive(attributed) {
961
1090
  startedAtMs: hookRec?.ts ?? birthtimeMs,
962
1091
  lastActivityMs: mtimeMs,
963
1092
  pidCount: 1 + (foldedByRoot.get(pid) ?? 0),
1093
+ owner: resolveOwner(entry?.actor, resolvedId),
964
1094
  }, state, sessionFile, true));
965
1095
  }
966
1096
  // Housekeeping: drop registry files for pids that have since died.
@@ -1063,9 +1193,24 @@ export async function listTmuxAgentSessions() {
1063
1193
  continue;
1064
1194
  const meta = readSessionMeta(sessName);
1065
1195
  const liveEntry = liveByPane.get(pane);
1066
- const id = resolvePaneIdentity(pane, meta, liveEntry, getHookIndex);
1196
+ let id = resolvePaneIdentity(pane, meta, liveEntry, getHookIndex);
1067
1197
  if (!id)
1068
1198
  continue;
1199
+ // RUSH-2007 Layer A: a non-Claude tmux session whose id resolved via neither the
1200
+ // launch registry (no id minted at spawn) nor the session-tracker index (not
1201
+ // deployed on the fleet — its dir is empty) — backfill it from the DEPLOYED
1202
+ // hook's own per-pid record at state/sessions/<pid>.json. Targeted single-file
1203
+ // reads on the pane leaf pid, then the registry launch pid; freshness-guarded by
1204
+ // the launch's known start so a reused-pid graveyard file can't cross sessions.
1205
+ // Without this the session surfaces id-less (keyed on the bare pane) and is
1206
+ // invisible to `agents sessions focus` — the remaining RUSH-2007 discovery gap.
1207
+ if (!id.sessionId) {
1208
+ const panePid = parseInt(pidRaw, 10) || undefined;
1209
+ const backfilled = (panePid ? readStateSessionRecord(panePid, liveEntry?.startedAtMs)?.session_id : undefined)
1210
+ ?? (liveEntry ? readStateSessionRecord(liveEntry.pid, liveEntry.startedAtMs)?.session_id : undefined);
1211
+ if (backfilled)
1212
+ id = { ...id, sessionId: backfilled };
1213
+ }
1069
1214
  // Dedupe by resolved session id; an as-yet-unresolved id (a hookless/lagging
1070
1215
  // split) keys on the unique pane so it still surfaces as its own row.
1071
1216
  const dedupKey = id.sessionId ?? pane;
@@ -1108,6 +1253,7 @@ export async function listTmuxAgentSessions() {
1108
1253
  startedAtMs: birthtimeMs,
1109
1254
  lastActivityMs: mtimeMs,
1110
1255
  provenance,
1256
+ owner: resolveOwner(liveEntry?.actor, id.sessionId ?? sessionIdFromFile(sessionFile)),
1111
1257
  }, state, sessionFile, pidAlive));
1112
1258
  }
1113
1259
  return out;
@@ -0,0 +1,23 @@
1
+ export interface SessionActorRecord {
2
+ sessionId: string;
3
+ /** Resolved actor id (`resolveActor().id`) — the responsible human/agent. */
4
+ actor: string;
5
+ /** Actor kind (`resolveActor().kind`). */
6
+ initiatedBy: 'human' | 'agent';
7
+ startedAtMs: number;
8
+ }
9
+ /**
10
+ * Record the actor a session was launched under. Never throws — the sidecar is
11
+ * an attribution optimization; a session with no record simply scans unattributed.
12
+ * No-ops without a concrete session id (nothing to key on).
13
+ */
14
+ export declare function writeSessionActorRecord(record: SessionActorRecord): void;
15
+ /** Read one session's actor record. Returns undefined if absent/corrupt. */
16
+ export declare function readSessionActorRecord(sessionId: string): SessionActorRecord | undefined;
17
+ /**
18
+ * Load every session actor record into a `sessionId -> record` map, for the scan
19
+ * path to join a whole batch of sessions in one directory read instead of a
20
+ * stat-per-row. Best-effort: unreadable/corrupt files are skipped, a missing dir
21
+ * yields an empty map.
22
+ */
23
+ export declare function loadSessionActorIndex(): Map<string, SessionActorRecord>;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Durable sessionId -> actor sidecar (RUSH-2019, P3 lineage).
3
+ *
4
+ * The actor behind a run is resolved at spawn (`resolveActor()`), but the session
5
+ * transcript on disk carries no record of it — so the scanner that indexes those
6
+ * transcripts into `sessions.db` (`discover.ts` -> `upsertSessionsBatch`) has
7
+ * nothing to attribute a session to a person with. The pid-registry answers this
8
+ * for LIVE processes (`--active` owner, RUSH-2018), but it lives under `.cache`
9
+ * and is pruned when the pid dies, so it can't back a DURABLE, historical listing.
10
+ *
11
+ * This module writes one small, durable record per launched session — keyed by
12
+ * session id, under `~/.agents/.history` (never pruned) — that the scanner joins
13
+ * on to fill the write-once `actor` / `initiated_by` columns. Best-effort
14
+ * throughout: a failed write or a corrupt file degrades to an unattributed row,
15
+ * never throws into the launch or the scan path.
16
+ */
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ import { getHistoryDir } from '../state.js';
20
+ function sidecarDir() {
21
+ return path.join(getHistoryDir(), 'by-session');
22
+ }
23
+ /**
24
+ * A session id safe to use as a filename: no path separators or `..`, so a
25
+ * caller-supplied `--session-id` can never escape `by-session/` via
26
+ * `path.join`. Session ids are uuids in practice; anything else is rejected
27
+ * rather than sanitized, so a bad id degrades to no record, never a write
28
+ * outside the directory.
29
+ */
30
+ function isSafeSessionId(sessionId) {
31
+ return sessionId.length > 0 && !/[/\\]/.test(sessionId) && sessionId !== '.' && sessionId !== '..';
32
+ }
33
+ function recordPath(sessionId) {
34
+ return path.join(sidecarDir(), `${sessionId}.json`);
35
+ }
36
+ /**
37
+ * Record the actor a session was launched under. Never throws — the sidecar is
38
+ * an attribution optimization; a session with no record simply scans unattributed.
39
+ * No-ops without a concrete session id (nothing to key on).
40
+ */
41
+ export function writeSessionActorRecord(record) {
42
+ if (!isSafeSessionId(record.sessionId))
43
+ return;
44
+ try {
45
+ fs.mkdirSync(sidecarDir(), { recursive: true });
46
+ fs.writeFileSync(recordPath(record.sessionId), JSON.stringify(record), 'utf8');
47
+ }
48
+ catch {
49
+ /* degrade to an unattributed row */
50
+ }
51
+ }
52
+ /** Read one session's actor record. Returns undefined if absent/corrupt. */
53
+ export function readSessionActorRecord(sessionId) {
54
+ if (!isSafeSessionId(sessionId))
55
+ return undefined;
56
+ let raw;
57
+ try {
58
+ raw = fs.readFileSync(recordPath(sessionId), 'utf8');
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ try {
64
+ const parsed = JSON.parse(raw);
65
+ if (parsed && typeof parsed === 'object' && typeof parsed.sessionId === 'string' && typeof parsed.actor === 'string') {
66
+ return parsed;
67
+ }
68
+ }
69
+ catch {
70
+ /* unparseable */
71
+ }
72
+ return undefined;
73
+ }
74
+ /**
75
+ * Load every session actor record into a `sessionId -> record` map, for the scan
76
+ * path to join a whole batch of sessions in one directory read instead of a
77
+ * stat-per-row. Best-effort: unreadable/corrupt files are skipped, a missing dir
78
+ * yields an empty map.
79
+ */
80
+ export function loadSessionActorIndex() {
81
+ const out = new Map();
82
+ let files;
83
+ try {
84
+ files = fs.readdirSync(sidecarDir()).filter(f => f.endsWith('.json'));
85
+ }
86
+ catch {
87
+ return out;
88
+ }
89
+ for (const f of files) {
90
+ try {
91
+ const parsed = JSON.parse(fs.readFileSync(path.join(sidecarDir(), f), 'utf8'));
92
+ if (parsed && typeof parsed === 'object' && typeof parsed.sessionId === 'string' && typeof parsed.actor === 'string') {
93
+ out.set(parsed.sessionId, parsed);
94
+ }
95
+ }
96
+ catch {
97
+ /* raced with a writer, or corrupt — skip */
98
+ }
99
+ }
100
+ return out;
101
+ }
@@ -2,7 +2,7 @@
2
2
  * SQLite-backed session index and full-text search.
3
3
  *
4
4
  * Stores session metadata and user-prompt text in a WAL-mode SQLite database
5
- * at ~/.agents/sessions/sessions.db. Provides incremental upsert, scan-stamp
5
+ * at ~/.agents/.history/sessions/sessions.db. Provides incremental upsert, scan-stamp
6
6
  * ledger (mtime/size tracking to skip unchanged files), FTS5 search with
7
7
  * BM25 ranking, and label-first search for /rename'd sessions.
8
8
  */
@@ -30,6 +30,7 @@ export interface SessionRow {
30
30
  output_tokens: number | null;
31
31
  cost_usd: number | null;
32
32
  duration_ms: number | null;
33
+ model: string | null;
33
34
  file_path: string;
34
35
  file_mtime_ms: number | null;
35
36
  file_size: number | null;
@@ -40,10 +41,13 @@ export interface SessionRow {
40
41
  worktree_slug: string | null;
41
42
  ticket_id: string | null;
42
43
  plan: string | null;
44
+ machine: string | null;
43
45
  todos: string | null;
44
46
  recent_directories_touched: string | null;
45
47
  linear_project: string | null;
46
48
  linear_project_url: string | null;
49
+ actor: string | null;
50
+ initiated_by: string | null;
47
51
  }
48
52
  /** File stat snapshot used to detect changes between scan runs. */
49
53
  export interface ScanStamp {
@@ -248,6 +252,42 @@ export interface UsageRollupRow {
248
252
  }
249
253
  /** What to group a usage rollup by. */
250
254
  export type UsageRollupGroup = 'agent' | 'project' | 'day';
255
+ /**
256
+ * Smart-launch affinity priors: group sessions by origin machine, harness, or
257
+ * joint (machine + agent). Ordered by launch count desc.
258
+ *
259
+ * SQL shape (device example):
260
+ * SELECT machine, COUNT(*) launches, SUM(duration_ms), SUM(token_count)
261
+ * FROM sessions
262
+ * WHERE timestamp >= ? AND origin = 'cli' AND is_team_origin = 0
263
+ * GROUP BY machine ORDER BY launches DESC
264
+ *
265
+ * Account rotation is NOT done here — that stays on live rate-limit windows
266
+ * via `--strategy balanced` / rotate.ts.
267
+ */
268
+ export type AffinityGroup = 'machine' | 'agent' | 'machine_agent';
269
+ export interface AffinityRow {
270
+ /** Group key: machine name, agent id, or "machine\\tagent". */
271
+ key: string;
272
+ machine?: string;
273
+ agent?: string;
274
+ launches: number;
275
+ durationMs: number;
276
+ tokenCount: number;
277
+ costUsd: number;
278
+ }
279
+ export declare function queryAffinityRollup(options: {
280
+ groupBy: AffinityGroup;
281
+ /** ISO cutoff or ms; defaults to 14 days ago when omitted. */
282
+ sinceMs?: number;
283
+ /** Restrict to these harnesses (e.g. claude/codex/kimi). */
284
+ agents?: SessionAgentId[];
285
+ /** Default true: only origin=cli rows. */
286
+ onlyCli?: boolean;
287
+ /** Default true: drop team-spawned sessions. */
288
+ excludeTeamOrigin?: boolean;
289
+ project?: string;
290
+ }): AffinityRow[];
251
291
  /**
252
292
  * Aggregate cost / duration / tokens across sessions, grouped by agent,
253
293
  * project, or calendar day. Honors the same filter shape as querySessions