@phnx-labs/agents-cli 1.20.78 → 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 (176) hide show
  1. package/CHANGELOG.md +487 -0
  2. package/README.md +25 -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 +65 -0
  12. package/dist/commands/doctor.js +575 -31
  13. package/dist/commands/events.js +1 -1
  14. package/dist/commands/exec.js +24 -5
  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 +187 -11
  29. package/dist/commands/routines.js +12 -2
  30. package/dist/commands/rules.js +13 -12
  31. package/dist/commands/secrets.d.ts +3 -1
  32. package/dist/commands/secrets.js +100 -32
  33. package/dist/commands/sessions-inject.d.ts +7 -4
  34. package/dist/commands/sessions-inject.js +18 -18
  35. package/dist/commands/sessions-picker.js +32 -3
  36. package/dist/commands/sessions-resume.d.ts +16 -0
  37. package/dist/commands/sessions-resume.js +14 -13
  38. package/dist/commands/sessions.d.ts +31 -0
  39. package/dist/commands/sessions.js +141 -16
  40. package/dist/commands/set.d.ts +15 -0
  41. package/dist/commands/set.js +79 -0
  42. package/dist/commands/setup-fleet.d.ts +21 -0
  43. package/dist/commands/setup-fleet.js +201 -0
  44. package/dist/commands/setup-secrets.d.ts +20 -0
  45. package/dist/commands/setup-secrets.js +223 -0
  46. package/dist/commands/setup.js +17 -3
  47. package/dist/commands/skills.js +1 -1
  48. package/dist/commands/sync.js +7 -2
  49. package/dist/commands/teams-picker.js +0 -1
  50. package/dist/commands/teams.d.ts +33 -1
  51. package/dist/commands/teams.js +208 -44
  52. package/dist/commands/versions.js +10 -5
  53. package/dist/commands/view.d.ts +1 -0
  54. package/dist/commands/view.js +11 -0
  55. package/dist/index.js +5 -8
  56. package/dist/lib/acp/harnesses.js +0 -7
  57. package/dist/lib/activity.d.ts +156 -0
  58. package/dist/lib/activity.js +282 -0
  59. package/dist/lib/agents.d.ts +9 -2
  60. package/dist/lib/agents.js +72 -63
  61. package/dist/lib/browser/service.js +3 -0
  62. package/dist/lib/browser/types.d.ts +7 -0
  63. package/dist/lib/capabilities.js +6 -2
  64. package/dist/lib/crabbox/lease.js +2 -2
  65. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  66. package/dist/lib/crabbox/setup-copy.js +4 -4
  67. package/dist/lib/daemon.d.ts +18 -0
  68. package/dist/lib/daemon.js +47 -6
  69. package/dist/lib/devices/fleet.d.ts +1 -1
  70. package/dist/lib/devices/fleet.js +1 -1
  71. package/dist/lib/doctor-diff.d.ts +20 -0
  72. package/dist/lib/doctor-diff.js +6 -1
  73. package/dist/lib/drift.d.ts +31 -11
  74. package/dist/lib/drift.js +58 -7
  75. package/dist/lib/events.d.ts +8 -1
  76. package/dist/lib/events.js +10 -1
  77. package/dist/lib/exec.js +35 -8
  78. package/dist/lib/feed-post.d.ts +28 -1
  79. package/dist/lib/feed-post.js +110 -2
  80. package/dist/lib/fleet/apply.js +8 -1
  81. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  82. package/dist/lib/fleet/auth-sync.js +25 -11
  83. package/dist/lib/fleet/remote-login.js +5 -0
  84. package/dist/lib/git.d.ts +14 -14
  85. package/dist/lib/git.js +41 -44
  86. package/dist/lib/hooks.d.ts +42 -0
  87. package/dist/lib/hooks.js +137 -93
  88. package/dist/lib/hosts/dispatch.d.ts +28 -1
  89. package/dist/lib/hosts/dispatch.js +33 -5
  90. package/dist/lib/hosts/option.js +2 -2
  91. package/dist/lib/hosts/passthrough.d.ts +22 -1
  92. package/dist/lib/hosts/passthrough.js +238 -13
  93. package/dist/lib/hosts/run-target.d.ts +2 -0
  94. package/dist/lib/hosts/run-target.js +1 -0
  95. package/dist/lib/hq/floor.d.ts +1 -1
  96. package/dist/lib/hq/floor.js +6 -1
  97. package/dist/lib/mcp.js +3 -71
  98. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  99. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  100. package/dist/lib/menubar/notify-desktop.js +41 -5
  101. package/dist/lib/merged-resources.d.ts +11 -0
  102. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  103. package/dist/lib/permissions.d.ts +0 -32
  104. package/dist/lib/permissions.js +4 -157
  105. package/dist/lib/picker.js +1 -1
  106. package/dist/lib/platform/process.d.ts +16 -5
  107. package/dist/lib/platform/process.js +54 -0
  108. package/dist/lib/plugins.d.ts +0 -8
  109. package/dist/lib/plugins.js +4 -110
  110. package/dist/lib/project-resources.js +4 -1
  111. package/dist/lib/refresh.d.ts +5 -3
  112. package/dist/lib/refresh.js +7 -5
  113. package/dist/lib/resources/commands.js +4 -1
  114. package/dist/lib/resources/mcp.js +0 -4
  115. package/dist/lib/resources/permissions.d.ts +1 -1
  116. package/dist/lib/resources/permissions.js +1 -5
  117. package/dist/lib/resources/rules.js +4 -1
  118. package/dist/lib/resources/skills.js +4 -1
  119. package/dist/lib/resources/subagents.js +4 -0
  120. package/dist/lib/resources/types.d.ts +1 -1
  121. package/dist/lib/routines.d.ts +20 -0
  122. package/dist/lib/routines.js +6 -0
  123. package/dist/lib/run-defaults.d.ts +1 -0
  124. package/dist/lib/run-defaults.js +9 -0
  125. package/dist/lib/runner.d.ts +0 -12
  126. package/dist/lib/runner.js +49 -10
  127. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  128. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  129. package/dist/lib/secrets/agent.d.ts +16 -0
  130. package/dist/lib/secrets/agent.js +105 -3
  131. package/dist/lib/secrets/audit.d.ts +46 -0
  132. package/dist/lib/secrets/audit.js +56 -0
  133. package/dist/lib/secrets/bundles.d.ts +11 -8
  134. package/dist/lib/secrets/bundles.js +31 -23
  135. package/dist/lib/secrets/index.d.ts +2 -1
  136. package/dist/lib/secrets/index.js +8 -2
  137. package/dist/lib/secrets/remote.d.ts +75 -2
  138. package/dist/lib/secrets/remote.js +139 -5
  139. package/dist/lib/secrets/sync.js +3 -3
  140. package/dist/lib/session/active.d.ts +110 -26
  141. package/dist/lib/session/active.js +161 -50
  142. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  143. package/dist/lib/session/actor-sidecar.js +101 -0
  144. package/dist/lib/session/db.d.ts +4 -1
  145. package/dist/lib/session/db.js +65 -8
  146. package/dist/lib/session/discover.d.ts +4 -0
  147. package/dist/lib/session/discover.js +13 -0
  148. package/dist/lib/session/hook-sessions.d.ts +9 -0
  149. package/dist/lib/session/hook-sessions.js +57 -8
  150. package/dist/lib/session/pid-registry.d.ts +13 -0
  151. package/dist/lib/session/render.d.ts +2 -0
  152. package/dist/lib/session/render.js +1 -1
  153. package/dist/lib/session/types.d.ts +16 -0
  154. package/dist/lib/staleness/detectors/commands.js +2 -2
  155. package/dist/lib/staleness/detectors/permissions.js +1 -45
  156. package/dist/lib/staleness/writers/commands.js +4 -5
  157. package/dist/lib/staleness/writers/hooks.js +1 -1
  158. package/dist/lib/startup/command-registry.d.ts +1 -2
  159. package/dist/lib/startup/command-registry.js +2 -4
  160. package/dist/lib/subagents-registry.js +5 -12
  161. package/dist/lib/subagents.d.ts +0 -10
  162. package/dist/lib/subagents.js +0 -12
  163. package/dist/lib/teams/agents.d.ts +0 -2
  164. package/dist/lib/teams/agents.js +10 -27
  165. package/dist/lib/teams/index.d.ts +1 -1
  166. package/dist/lib/teams/index.js +1 -1
  167. package/dist/lib/types.d.ts +17 -9
  168. package/dist/lib/usage.d.ts +28 -5
  169. package/dist/lib/usage.js +271 -8
  170. package/dist/lib/versions.js +7 -1
  171. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  172. package/dist/lib/watchdog/watchdog.js +25 -13
  173. package/package.json +1 -2
  174. package/dist/commands/check.d.ts +0 -15
  175. package/dist/commands/check.js +0 -180
  176. package/dist/commands/resources.d.ts +0 -5
@@ -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';
28
+ import { readSessionActorRecord } from './actor-sidecar.js';
29
+ import { loadHookSessionIndex, resolveHookSessionRecord, readStateSessionRecord } from './hook-sessions.js';
29
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
@@ -82,6 +96,16 @@ const ACTIVE_MTIME_WINDOW_MS = 2 * 60_000;
82
96
  * transcript just because a GUI app service with the same basename is alive.
83
97
  */
84
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;
85
109
  /** Executables we recognize as agent CLIs when scanning the process table. */
86
110
  const AGENT_CLI_NAMES = {
87
111
  claude: 'claude',
@@ -309,46 +333,68 @@ export function sessionFileTimes(sessionFile) {
309
333
  return {};
310
334
  }
311
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
+ }
312
359
  /**
313
360
  * The ONE place a fallback status is decided when no rich transcript state is
314
- * available — a non-Claude/Codex kind we cannot parse, or a Claude/Codex tail
315
- * that was empty or unreadable. Honest by construction: it never asserts a status
316
- * 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`.
317
364
  *
318
- * - Resolvable transcript, readable mtime the MEASURED freshness signal:
319
- * written within ACTIVE_MTIME_WINDOW_MS `running`, else `idle`.
320
- * - Resolvable transcript whose `stat` throws (file vanished / permission) we
321
- * genuinely cannot tell `unknown`. (This branch previously returned
322
- * `running`, which contradicted the `idle` default one branch up.)
323
- * - No resolvable transcript but the process is alive → alive-but-opaque
324
- * `unknown`. This is the truthful answer for a live gemini / droid / cursor /
325
- * opencode whose format we don't parse NOT a fabricated `idle` (which the
326
- * UI reads as "done and waiting"), and it never lies as `running` either.
327
- * - 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`.
328
373
  */
329
- export function resolveFallbackStatus(sessionFile, pidAlive) {
330
- if (!sessionFile)
331
- return pidAlive ? 'unknown' : 'idle';
332
- try {
333
- const mtimeMs = fs.statSync(sessionFile).mtimeMs;
334
- return Date.now() - mtimeMs < ACTIVE_MTIME_WINDOW_MS ? 'running' : 'idle';
335
- }
336
- catch {
337
- return 'unknown';
338
- }
374
+ export function resolveFallbackStatus(sessionFile, pidAlive, nowMs = Date.now()) {
375
+ const { mtimeMs } = sessionFileTimes(sessionFile);
376
+ return lifecycleStatus(pidAlive, mtimeMs, nowMs) ?? 'running';
339
377
  }
340
378
  /**
341
- * Locate the live transcript for an agent process. Claude files are keyed by
342
- * cwd (+ optional session uuid); Codex files are date-partitioned, so we resolve
343
- * 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`.
344
389
  */
345
390
  export function findSessionFileForKind(kind, cwd, sessionId) {
346
391
  if (!cwd)
347
392
  return undefined;
348
393
  if (kind === 'claude')
349
394
  return findClaudeSessionFile(cwd, sessionId);
350
- if (kind === 'codex')
351
- return latestSessionFileForCwd('codex', cwd, { maxAgeMs: ACTIVE_SESSION_STALE_MS });
395
+ if (isSessionTrackedAgent(kind)) {
396
+ return latestSessionFileForCwd(kind, cwd, { maxAgeMs: ACTIVE_SESSION_STALE_MS });
397
+ }
352
398
  return undefined;
353
399
  }
354
400
  /** Recover the session UUID from a transcript filename (Claude `<uuid>.jsonl`, Codex `rollout-…-<uuid>.jsonl`). */
@@ -358,28 +404,64 @@ function sessionIdFromFile(file) {
358
404
  return undefined;
359
405
  return path.basename(file).match(UUID_RE)?.[0];
360
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;
361
410
  /**
362
- * Read a session file's tail ONCE and derive both the inferred state and the
363
- * output-token throughput from it. State needs the normalized event model;
364
- * throughput needs the raw lines the event model drops (Codex `token_count`), so
365
- * both come off the same {@link readSessionTailWithRaw} read. Only Claude/Codex
366
- * 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.
367
417
  */
368
- 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) {
369
443
  if (!sessionFile)
370
444
  return {};
371
- const agent = kind === 'codex' ? 'codex' : 'claude';
372
- const { events, content } = readSessionTailWithRaw(sessionFile, agent);
373
- if (events.length === 0)
374
- return {};
375
445
  let mtimeMs;
376
446
  try {
377
447
  mtimeMs = fs.statSync(sessionFile).mtimeMs;
378
448
  }
379
449
  catch { /* vanished between calls */ }
380
- const state = inferSessionState(events, { cwd, pidAlive, mtimeMs, activeWindowMs: ACTIVE_MTIME_WINDOW_MS });
381
- const tokPerSec = computeTokPerSec(content, agent);
382
- 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) };
383
465
  }
384
466
  /** Map inferred activity onto the coarse ActiveStatus used by the renderer and counts. */
385
467
  function statusFromActivity(activity) {
@@ -387,17 +469,23 @@ function statusFromActivity(activity) {
387
469
  }
388
470
  /**
389
471
  * Fold a computed SessionState onto an active-session row: rich status +
390
- * preview + PR/worktree/ticket badges. With no state (unreadable/non-Claude/
391
- * Codex file) it degrades to {@link resolveFallbackStatus}, which needs
392
- * `pidAlive` to tell an alive-but-opaque process (`unknown`) from a dead one
393
- * (`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`.
394
476
  */
395
477
  function applyState(base, state, fallbackFile, pidAlive) {
396
478
  if (!state)
397
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);
398
486
  return {
399
487
  ...base,
400
- status: statusFromActivity(state.activity),
488
+ status: life ?? statusFromActivity(state.activity),
401
489
  activity: state.activity,
402
490
  awaitingReason: state.awaitingReason,
403
491
  question: state.question,
@@ -512,6 +600,10 @@ export async function listTeamsActive() {
512
600
  lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
513
601
  teamName: a.taskName,
514
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)),
515
607
  }, state, sessionFile, pidAlive);
516
608
  });
517
609
  }
@@ -540,7 +632,8 @@ export async function listTerminalsActive() {
540
632
  // back to the stale cached id — the duplicate-card fix comes from
541
633
  // pickSessionFile no longer borrowing a sibling, not from this lookup. Kept as
542
634
  // a forward-looking hook for the cases where the pid does line up.
543
- const resolvedId = readPidSessionEntry(t.pid)?.sessionId ?? t.sessionId;
635
+ const pidEntry = readPidSessionEntry(t.pid);
636
+ const resolvedId = pidEntry?.sessionId ?? t.sessionId;
544
637
  const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, resolvedId);
545
638
  // Prefer label from live terminal, fall back to Claude's session label
546
639
  const label = t.label ?? (t.sessionId ? labelMap.get(t.sessionId) : undefined) ?? undefined;
@@ -566,6 +659,7 @@ export async function listTerminalsActive() {
566
659
  startedAtMs: t.startedAtMs,
567
660
  lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
568
661
  windowId: t.windowId,
662
+ owner: resolveOwner(pidEntry?.actor, resolvedId),
569
663
  }, state, sessionFile, pidAlive);
570
664
  });
571
665
  }
@@ -967,7 +1061,7 @@ export async function listUnattributedActive(attributed) {
967
1061
  const context = host && UI_HOSTS.has(host) ? 'terminal' : 'headless';
968
1062
  // pidAlive is true by construction: this pid was just enumerated from the
969
1063
  // live process table, so an opaque (non-parseable) kind resolves to
970
- // `unknown`, not a fake `idle`.
1064
+ // `running`, not a fake `idle` or blanket `unknown`.
971
1065
  const { state, tokPerSec } = computeLiveSignals(kind, sessionFile, cwd, true);
972
1066
  const { birthtimeMs, mtimeMs } = sessionFileTimes(sessionFile);
973
1067
  // Durable run name from `agents run --name`, resolved by the run's session id
@@ -996,6 +1090,7 @@ export async function listUnattributedActive(attributed) {
996
1090
  startedAtMs: hookRec?.ts ?? birthtimeMs,
997
1091
  lastActivityMs: mtimeMs,
998
1092
  pidCount: 1 + (foldedByRoot.get(pid) ?? 0),
1093
+ owner: resolveOwner(entry?.actor, resolvedId),
999
1094
  }, state, sessionFile, true));
1000
1095
  }
1001
1096
  // Housekeeping: drop registry files for pids that have since died.
@@ -1098,9 +1193,24 @@ export async function listTmuxAgentSessions() {
1098
1193
  continue;
1099
1194
  const meta = readSessionMeta(sessName);
1100
1195
  const liveEntry = liveByPane.get(pane);
1101
- const id = resolvePaneIdentity(pane, meta, liveEntry, getHookIndex);
1196
+ let id = resolvePaneIdentity(pane, meta, liveEntry, getHookIndex);
1102
1197
  if (!id)
1103
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
+ }
1104
1214
  // Dedupe by resolved session id; an as-yet-unresolved id (a hookless/lagging
1105
1215
  // split) keys on the unique pane so it still surfaces as its own row.
1106
1216
  const dedupKey = id.sessionId ?? pane;
@@ -1143,6 +1253,7 @@ export async function listTmuxAgentSessions() {
1143
1253
  startedAtMs: birthtimeMs,
1144
1254
  lastActivityMs: mtimeMs,
1145
1255
  provenance,
1256
+ owner: resolveOwner(liveEntry?.actor, id.sessionId ?? sessionIdFromFile(sessionFile)),
1146
1257
  }, state, sessionFile, pidAlive));
1147
1258
  }
1148
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;
@@ -45,6 +46,8 @@ export interface SessionRow {
45
46
  recent_directories_touched: string | null;
46
47
  linear_project: string | null;
47
48
  linear_project_url: string | null;
49
+ actor: string | null;
50
+ initiated_by: string | null;
48
51
  }
49
52
  /** File stat snapshot used to detect changes between scan runs. */
50
53
  export interface ScanStamp {