@phnx-labs/agents-cli 1.22.56 → 1.22.57

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 (61) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +4 -4
  3. package/dist/bootstrap.js +3 -1
  4. package/dist/cli/command-registry.d.ts +0 -1
  5. package/dist/cli/command-registry.js +0 -3
  6. package/dist/commands/exec.js +1 -1
  7. package/dist/commands/hooks.js +4 -4
  8. package/dist/commands/insights.d.ts +7 -5
  9. package/dist/commands/insights.js +16 -9
  10. package/dist/commands/perf.d.ts +16 -7
  11. package/dist/commands/perf.js +29 -20
  12. package/dist/commands/rules.js +1 -1
  13. package/dist/commands/ssh.js +24 -14
  14. package/dist/commands/trash.d.ts +2 -2
  15. package/dist/commands/trash.js +2 -6
  16. package/dist/commands/versions.d.ts +2 -2
  17. package/dist/commands/versions.js +1 -10
  18. package/dist/commands/view.d.ts +2 -2
  19. package/dist/commands/view.js +7 -6
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +9 -0
  22. package/dist/lib/accounting/usage-ingest.d.ts +1 -0
  23. package/dist/lib/accounting/usage-ingest.js +75 -0
  24. package/dist/lib/accounting/usage-sync.d.ts +69 -0
  25. package/dist/lib/accounting/usage-sync.js +129 -0
  26. package/dist/lib/accounting/usage.d.ts +48 -2
  27. package/dist/lib/accounting/usage.js +72 -1
  28. package/dist/lib/agent-spec/agents.js +1 -1
  29. package/dist/lib/analytics/mix-commands.d.ts +8 -7
  30. package/dist/lib/analytics/mix-commands.js +50 -73
  31. package/dist/lib/daemon/daemon.js +5 -0
  32. package/dist/lib/daemon/runner.js +9 -8
  33. package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
  34. package/dist/lib/daemon/usage-sync-service.js +36 -0
  35. package/dist/lib/daemon-services.d.ts +1 -1
  36. package/dist/lib/daemon-services.js +5 -0
  37. package/dist/lib/device-config.d.ts +17 -6
  38. package/dist/lib/device-config.js +25 -11
  39. package/dist/lib/devices/pool.d.ts +4 -3
  40. package/dist/lib/devices/pool.js +13 -5
  41. package/dist/lib/exec.d.ts +6 -41
  42. package/dist/lib/exec.js +6 -41
  43. package/dist/lib/git.d.ts +13 -1
  44. package/dist/lib/git.js +36 -7
  45. package/dist/lib/harness/adapter.d.ts +7 -7
  46. package/dist/lib/harness/adapters/claude.js +3 -2
  47. package/dist/lib/hosts/remote-cmd.d.ts +9 -0
  48. package/dist/lib/hosts/remote-cmd.js +22 -0
  49. package/dist/lib/perf/db.d.ts +1 -1
  50. package/dist/lib/perf/db.js +1 -1
  51. package/dist/lib/session/active.d.ts +3 -31
  52. package/dist/lib/session/active.js +8 -68
  53. package/dist/lib/session/db.d.ts +4 -35
  54. package/dist/lib/session/db.js +4 -35
  55. package/dist/lib/session/discover.d.ts +6 -58
  56. package/dist/lib/session/discover.js +5 -43
  57. package/dist/lib/session/parse.d.ts +1 -19
  58. package/dist/lib/session/parse.js +2 -15
  59. package/dist/lib/startup/command-registry.d.ts +8 -2
  60. package/dist/lib/startup/command-registry.js +12 -4
  61. package/package.json +1 -1
package/dist/lib/git.js CHANGED
@@ -498,7 +498,7 @@ export async function getCurrentBranch(repoPath) {
498
498
  * repo" as a requested source before adopting it.
499
499
  */
500
500
  export function canonicalGitRemote(url) {
501
- return url
501
+ const canonical = url
502
502
  .trim()
503
503
  .replace(/\/+$/, '') // trailing slashes first, so a trailing-slash-after-.git still strips
504
504
  .replace(/\.git$/i, '')
@@ -506,6 +506,36 @@ export function canonicalGitRemote(url) {
506
506
  .replace(/^[^@/]+@/, '') // strip user@ (git@, ssh user)
507
507
  .replace(':', '/') // scp-style host:owner/repo → host/owner/repo (first colon only)
508
508
  .toLowerCase();
509
+ // Fold a renamed repo's old name onto its new one so both compare equal
510
+ // everywhere (see RENAMED_REMOTE_ALIASES).
511
+ return RENAMED_REMOTE_ALIASES[canonical] ?? canonical;
512
+ }
513
+ /**
514
+ * Git remotes that denote the SAME repository under an old and a new name,
515
+ * keyed by canonical `host/owner/repo`. `phnx-labs/.agents-system` was renamed
516
+ * to `phnx-labs/.agents` on GitHub (PHNX-3394); {@link DEFAULT_SYSTEM_REPO}
517
+ * still points at the pre-rename slug (GitHub's own redirect makes that
518
+ * resolve fine), so folding the new name onto it here means both compare equal
519
+ * everywhere remotes are compared: {@link sameGitRemote} (repo adoption),
520
+ * {@link isSystemRepoRemote} (the system-origin check), and the
521
+ * DotAgents-layer classifier in state.ts.
522
+ */
523
+ const RENAMED_REMOTE_ALIASES = {
524
+ 'github.com/phnx-labs/.agents': 'github.com/phnx-labs/.agents-system',
525
+ };
526
+ /**
527
+ * True when a git remote URL (any transport form: ssh, https, scp-style) points
528
+ * at the system DotAgents repo — {@link DEFAULT_SYSTEM_REPO}'s current slug OR
529
+ * its `phnx-labs/.agents` rename target (PHNX-3394), which
530
+ * {@link canonicalGitRemote} folds onto it via {@link RENAMED_REMOTE_ALIASES}.
531
+ * Pure string check with no git spawn, so it is unit-testable off a live
532
+ * checkout; {@link isSystemRepoOrigin} reads a dir's origin and delegates here.
533
+ */
534
+ export function isSystemRepoRemote(remote) {
535
+ if (!remote)
536
+ return false;
537
+ const c = canonicalGitRemote(remote);
538
+ return c === canonicalGitRemote(`https://github.com/${systemRepoSlug(DEFAULT_SYSTEM_REPO)}`);
509
539
  }
510
540
  /** True when two git remote URLs point at the same repo across transport forms. */
511
541
  export function sameGitRemote(a, b) {
@@ -1062,18 +1092,17 @@ export async function adoptUserRepoIfNeeded(dir, opts = {}) {
1062
1092
  return adoptRepoInPlace(dir, url);
1063
1093
  }
1064
1094
  /**
1065
- * Check if the repo's origin points to the system repo.
1095
+ * Check if the repo's origin points to the system repo — `phnx-labs/.agents-system`
1096
+ * or its GitHub rename target `phnx-labs/.agents` (PHNX-3394), across any
1097
+ * transport form. Reads the dir's origin and delegates the match to the pure
1098
+ * {@link isSystemRepoRemote}.
1066
1099
  */
1067
1100
  export async function isSystemRepoOrigin(dir) {
1068
1101
  try {
1069
1102
  const git = simpleGit(dir);
1070
1103
  const remotes = await git.getRemotes(true);
1071
1104
  const origin = remotes.find(r => r.name === 'origin');
1072
- if (!origin?.refs?.fetch)
1073
- return false;
1074
- const url = origin.refs.fetch.toLowerCase();
1075
- const currentSlug = systemRepoSlug(DEFAULT_SYSTEM_REPO).toLowerCase();
1076
- return url.includes(currentSlug);
1105
+ return isSystemRepoRemote(origin?.refs?.fetch);
1077
1106
  }
1078
1107
  catch {
1079
1108
  /* not a git repo or no remotes */
@@ -44,13 +44,13 @@ export interface ExecConfigEnvCtx {
44
44
  /** resolveInteractive(options) — computed once by the caller. */
45
45
  interactive: boolean;
46
46
  /**
47
- * The role marked on THIS machine (worker | personal | undefined), resolved
48
- * once by the caller from selfConfiguredDeviceRole(). A `personal` device is
49
- * the user's own interactive box: it holds a real per-version login and the
50
- * credential decision MUST defer to it for EVERY run — interactive OR headless
51
- * — never the worker-only setup-token (RUSH-2395). Injected as a plain value
52
- * (not imported) to keep the adapter import-leaf. Absent/undefined is treated
53
- * as non-personal (worker-equivalent).
47
+ * The role marked on THIS machine (worker | personal | desktop | undefined),
48
+ * resolved once by the caller from selfConfiguredDeviceRole(). A headed device
49
+ * (`personal` or `desktop` — see isHeadedDeviceRole) holds a real per-version
50
+ * login and the credential decision MUST defer to it for EVERY run — interactive
51
+ * OR headless — never the worker-only setup-token (RUSH-2395). Injected as a
52
+ * plain value (not imported) to keep the adapter import-leaf. Absent/undefined
53
+ * is treated as non-headed (worker-equivalent).
54
54
  */
55
55
  deviceRole?: ConfiguredDeviceRole;
56
56
  /**
@@ -1,5 +1,6 @@
1
1
  import * as path from 'path';
2
2
  import { stripForeignConfigDir } from '../adapter.js';
3
+ import { isHeadedDeviceRole } from '../../device-config.js';
3
4
  export const claudeAdapter = {
4
5
  id: 'claude',
5
6
  applyExecConfigEnv(result, ctx) {
@@ -47,8 +48,8 @@ export const claudeAdapter = {
47
48
  // path — agents.ts `isClaudeCredentialFileBlank`), so this path defers to
48
49
  // Claude Code, which reads its own ACL-trusted login item without a prompt and
49
50
  // asks a present human to log in only if the login is missing.
50
- const personalDevice = ctx.deviceRole === 'personal';
51
- if (ctx.interactive || personalDevice) {
51
+ const headedDevice = isHeadedDeviceRole(ctx.deviceRole);
52
+ if (ctx.interactive || headedDevice) {
52
53
  // Drop an INHERITED copy of OUR OWN setup-token: a launch from inside a
53
54
  // headless agent's shell inherits that agent's injected value via
54
55
  // sanitizeProcessEnv(process.env) and would keep authenticating as it,
@@ -177,6 +177,15 @@ export declare function buildWindowsAgentsCommand(cmd: WindowsAgentsCommand): st
177
177
  * (Credential Manager, or the headless file store when there's no logon
178
178
  * session), matching a local `agents secrets import`.
179
179
  */
180
+ /**
181
+ * Run `agents <args> --from <tmp>` on a Windows peer, feeding the ssh-piped stdin
182
+ * through a temp file — the `agents.ps1` shim does not forward piped stdin to the
183
+ * node process, so a verb that reads stdin must be handed a file instead. The
184
+ * generic sibling of {@link buildWindowsStdinImportCommand}; the receiving verb
185
+ * MUST accept `--from <path>` (see `usage-ingest.ts`). The temp file is removed in
186
+ * a `finally` so a throw mid-run never leaves the payload behind.
187
+ */
188
+ export declare function buildWindowsStdinAgentsCommand(args: string[]): string;
180
189
  export declare function buildWindowsStdinImportCommand(bundle: string, opts?: {
181
190
  force?: boolean;
182
191
  policyNever?: boolean;
@@ -323,6 +323,28 @@ export function buildWindowsAgentsCommand(cmd) {
323
323
  * (Credential Manager, or the headless file store when there's no logon
324
324
  * session), matching a local `agents secrets import`.
325
325
  */
326
+ /**
327
+ * Run `agents <args> --from <tmp>` on a Windows peer, feeding the ssh-piped stdin
328
+ * through a temp file — the `agents.ps1` shim does not forward piped stdin to the
329
+ * node process, so a verb that reads stdin must be handed a file instead. The
330
+ * generic sibling of {@link buildWindowsStdinImportCommand}; the receiving verb
331
+ * MUST accept `--from <path>` (see `usage-ingest.ts`). The temp file is removed in
332
+ * a `finally` so a throw mid-run never leaves the payload behind.
333
+ */
334
+ export function buildWindowsStdinAgentsCommand(args) {
335
+ const forwarded = args.map(powershellQuote).join(' ');
336
+ const script = [
337
+ POWERSHELL_PROGRESS_SILENCE,
338
+ '$in = [Console]::In.ReadToEnd()',
339
+ '$tmp = $null',
340
+ `try { $tmp = [System.IO.Path]::GetTempFileName(); [System.IO.File]::WriteAllText($tmp, $in); ` +
341
+ `& agents ${forwarded} --from $tmp; $code = $LASTEXITCODE } ` +
342
+ `finally { if ($tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } }`,
343
+ 'if ($null -eq $code) { $code = 1 }',
344
+ 'exit $code',
345
+ ].join('; ');
346
+ return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
347
+ }
326
348
  export function buildWindowsStdinImportCommand(bundle, opts = {}) {
327
349
  const force = opts.force ? ' --force' : '';
328
350
  const policy = opts.policyNever ? ' --policy never --i-understand' : '';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Disposable performance warehouse — SQLite under ~/.agents/.cache/perf/.
3
3
  *
4
- * Opened only by `agents perf` / `hooks profile` (read path). Writers use
4
+ * Opened only by `agents insights perf` / `hooks profile` (read path). Writers use
5
5
  * {@link recordSample} in `./spool.ts` (NDJSON, no SQLite).
6
6
  */
7
7
  import Database from '../sqlite.js';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Disposable performance warehouse — SQLite under ~/.agents/.cache/perf/.
3
3
  *
4
- * Opened only by `agents perf` / `hooks profile` (read path). Writers use
4
+ * Opened only by `agents insights perf` / `hooks profile` (read path). Writers use
5
5
  * {@link recordSample} in `./spool.ts` (NDJSON, no SQLite).
6
6
  */
7
7
  import * as fs from 'fs';
@@ -8,14 +8,7 @@ import { type SessionProvenance } from './provenance.js';
8
8
  import { type DeviceRegistry } from '../devices/registry.js';
9
9
  import { type Presence } from './detached.js';
10
10
  import { type HostLink } from './host-link.js';
11
- /**
12
- * The owner (actor id) to show for a session in `--active`. Prefers the actor
13
- * recorded on the live-attribution source (the pid registry / teammate record),
14
- * but falls back to the durable per-session actor sidecar — written at spawn and,
15
- * unlike the pid entry, NOT overwritten by the SessionStart hook's own by-pid
16
- * write. Without this fallback a real `agents run` shows no owner whenever the
17
- * hook's actor-less entry wins the by-pid file (RUSH-2018 fix).
18
- */
11
+ /** Prefer live actor attribution; the durable sidecar survives actor-less hook rewrites. */
19
12
  export declare function resolveOwner(pidActor: string | null | undefined, sessionId: string | undefined): string | undefined;
20
13
  /**
21
14
  * Per-PID `lsof` probes run bounded and staggered rather than as one parallel
@@ -830,18 +823,7 @@ export declare function sessionProcessIsLocal(s: Pick<ActiveSession, 'machine' |
830
823
  */
831
824
  export declare function sessionProcessHost(s: Pick<ActiveSession, 'machine' | 'offloadedFrom'>, self: string): string | undefined;
832
825
  export declare function foldHostLink(rows: ActiveSession[]): void;
833
- /**
834
- * The recap ladder (RUSH-3011): compute a row's shown {@link ActiveSession.title}
835
- * + {@link RecapSource} from the best available source, plus the cleaned first
836
- * prompt (`userPromptClean`/`userPromptKind`) and the `lastAgentLine`. Pure over
837
- * one row; exported for tests and folded in by {@link foldRecap}.
838
- *
839
- * Ladder, best-first: a `/rename`/harness `label` → the last assistant line →
840
- * the first-prompt topic. The `last` rung is agent-derived, so a session that
841
- * produced work stops showing its stale first prompt as the title. (`topic` is
842
- * the row's already-extracted first line, so image detection here is
843
- * path-based; a pure-attachment turn with no first-line text stays on `prompt`.)
844
- */
826
+ /** Labels win, then the last assistant line, then the first-prompt topic. */
845
827
  export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 'topic' | 'tail'>): {
846
828
  title?: string;
847
829
  recapSource?: RecapSource;
@@ -851,17 +833,7 @@ export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 't
851
833
  };
852
834
  /** Fold the recap ladder onto every row (see {@link deriveSessionRecap}). */
853
835
  export declare function foldRecap(rows: ActiveSession[]): void;
854
- /**
855
- * True when a crash-leaked orphan is genuinely DEAD and should be reaped from the
856
- * reconnectable set rather than shown as resumable forever (RUSH-3011 / issue #3b).
857
- *
858
- * The gate is `abandoned` (no transcript write in {@link ABANDONED_STALE_MS}) AND
859
- * a dead pid — exactly "past the stale threshold whose pid is gone". A live pid
860
- * (an idle-but-unfinished session, the highest-risk state) is NEVER reaped, and
861
- * neither is a recently-`closed`/`crashed` session that just exited (still
862
- * resumable). `pidAlive` absent (a cloud row or an older peer that can't prove
863
- * death) also stays un-reaped — reaping is fail-safe, never a guess.
864
- */
836
+ /** Reap only stale sessions with proven-dead pids; unknown or live processes remain recoverable. */
865
837
  export declare function isReapableOrphan(row: Pick<ActiveSession, 'status' | 'pidAlive'>): boolean;
866
838
  /**
867
839
  * Resolve each teams row's `orchestratorLabel` from the orchestrator's own row,
@@ -47,14 +47,7 @@ import { linearIssueUrl } from './linear.js';
47
47
  import { viewingInLabel } from './viewing-in.js';
48
48
  import { claudeProjectDirName } from '../project-key.js';
49
49
  const execFileAsync = promisify(execFile);
50
- /**
51
- * The owner (actor id) to show for a session in `--active`. Prefers the actor
52
- * recorded on the live-attribution source (the pid registry / teammate record),
53
- * but falls back to the durable per-session actor sidecar — written at spawn and,
54
- * unlike the pid entry, NOT overwritten by the SessionStart hook's own by-pid
55
- * write. Without this fallback a real `agents run` shows no owner whenever the
56
- * hook's actor-less entry wins the by-pid file (RUSH-2018 fix).
57
- */
50
+ /** Prefer live actor attribution; the durable sidecar survives actor-less hook rewrites. */
58
51
  export function resolveOwner(pidActor, sessionId) {
59
52
  return pidActor ?? (sessionId ? readSessionActorRecord(sessionId)?.actor : undefined) ?? undefined;
60
53
  }
@@ -476,18 +469,8 @@ export function isPidAlive(pid, startedAtMs) {
476
469
  return true;
477
470
  }
478
471
  /**
479
- * Read the live-terminals registry, dedupe by sessionId.
480
- *
481
- * A pid-alive entry is a live session. A pid-DEAD entry is normally noise — a
482
- * terminal that closed a moment ago, before its window republished — and is
483
- * dropped. But a dead pid whose owning window ALSO stopped republishing is the
484
- * signature of a crash: the window went down hard and never ran the teardown that
485
- * would have removed this entry. Those are KEPT, so the session reaches the
486
- * listing at all — it used to vanish outright, a VS Code crash simply erasing its
487
- * agents from `--active`. Such a row arrives as `closed` (dead pid) carrying the
488
- * stale `windowHeartbeatMs`, which is what {@link foldHostLink} promotes to
489
- * `crashed`. `pidDead` is local to the dedupe below: a live entry must win a dead
490
- * one for the same session.
472
+ * Keep dead entries only when their window heartbeat also stopped, proving a crash;
473
+ * a live duplicate always wins.
491
474
  */
492
475
  function readLiveTerminals() {
493
476
  let raw;
@@ -541,20 +524,8 @@ function readLiveTerminals() {
541
524
  const CLAUDE_SESSION_FILE_CACHE_MAX = 256;
542
525
  const claudeSessionFileCache = new Map();
543
526
  /**
544
- * Locate the active Claude session file for a process. If we know the session
545
- * UUID (from terminal env or team parent), prefer the exact match. Otherwise
546
- * fall back to the most-recent-mtime .jsonl in the project's folder.
547
- *
548
- * Searches EVERY version-home project root, not just the live `~/.claude`
549
- * symlink. `~/.claude` points at the currently-installed agent version; a
550
- * session launched under an EARLIER version keeps its transcript under that
551
- * version's home (`…/.history/versions/claude/<ver>/home/.claude/projects/`).
552
- * Resolving only `~/.claude/projects` meant that the instant a newer version
553
- * was installed, every still-running older-version session lost its transcript
554
- * here — no `sessionFile`, so no start/activity time, so `agents sessions`
555
- * rendered it `unknown` and the watchdog skipped it as "no activity timestamp".
556
- * `getAgentSessionDirs('claude','projects')` is the same version-aware enumerator
557
- * the rest of the CLI uses, so this stays in lockstep with discovery.
527
+ * Search every version home because the live ~/.claude symlink moves after upgrades
528
+ * while older running sessions keep writing to their original home.
558
529
  */
559
530
  function findClaudeSessionFile(cwd, sessionId) {
560
531
  // Only memoize when the exact session UUID is known. Without an id the
@@ -2080,17 +2051,7 @@ export function foldHostLink(rows) {
2080
2051
  }
2081
2052
  if (link === 'host-gone' && s.status === 'closed')
2082
2053
  s.status = 'crashed';
2083
- // Only idle/input_required are promoted — a `running` session keeps its
2084
- // status (SES-18a). Extending this to a running agent with no client was
2085
- // tried and reverted: since RUSH-3125 wraps every remote interactive run in
2086
- // a detached tmux pane, "running with zero attached clients" is the NORMAL
2087
- // steady state between check-ins, and that path writes no detach record, so
2088
- // `deliberatelyDetached` is false for it. Promoting it would relabel every
2089
- // remote agent as orphaned whenever nobody is looking — the over-reporting
2090
- // this file's header calls worthless. Telling a stranded agent from a
2091
- // healthy unattended one needs to know a client was EXPECTED and LOST, which
2092
- // no signal available here carries; that belongs with the peer-side pane
2093
- // ownership work, not this function.
2054
+ // A clientless running remote pane is normal; only stopped work can be called orphaned.
2094
2055
  else if (link === 'no-client' && (s.status === 'idle' || s.status === 'input_required')) {
2095
2056
  s.status = 'orphaned';
2096
2057
  }
@@ -2103,18 +2064,7 @@ function recapLine(s, max = 120) {
2103
2064
  return undefined;
2104
2065
  return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t;
2105
2066
  }
2106
- /**
2107
- * The recap ladder (RUSH-3011): compute a row's shown {@link ActiveSession.title}
2108
- * + {@link RecapSource} from the best available source, plus the cleaned first
2109
- * prompt (`userPromptClean`/`userPromptKind`) and the `lastAgentLine`. Pure over
2110
- * one row; exported for tests and folded in by {@link foldRecap}.
2111
- *
2112
- * Ladder, best-first: a `/rename`/harness `label` → the last assistant line →
2113
- * the first-prompt topic. The `last` rung is agent-derived, so a session that
2114
- * produced work stops showing its stale first prompt as the title. (`topic` is
2115
- * the row's already-extracted first line, so image detection here is
2116
- * path-based; a pure-attachment turn with no first-line text stays on `prompt`.)
2117
- */
2067
+ /** Labels win, then the last assistant line, then the first-prompt topic. */
2118
2068
  export function deriveSessionRecap(row) {
2119
2069
  const lastAgentLine = recapLine(row.tail?.length ? row.tail[row.tail.length - 1] : undefined);
2120
2070
  const { clean: userPromptClean, kind: userPromptKind } = classifyUserPrompt(row.topic ?? '');
@@ -2147,17 +2097,7 @@ export function foldRecap(rows) {
2147
2097
  s.lastAgentLine = recap.lastAgentLine;
2148
2098
  }
2149
2099
  }
2150
- /**
2151
- * True when a crash-leaked orphan is genuinely DEAD and should be reaped from the
2152
- * reconnectable set rather than shown as resumable forever (RUSH-3011 / issue #3b).
2153
- *
2154
- * The gate is `abandoned` (no transcript write in {@link ABANDONED_STALE_MS}) AND
2155
- * a dead pid — exactly "past the stale threshold whose pid is gone". A live pid
2156
- * (an idle-but-unfinished session, the highest-risk state) is NEVER reaped, and
2157
- * neither is a recently-`closed`/`crashed` session that just exited (still
2158
- * resumable). `pidAlive` absent (a cloud row or an older peer that can't prove
2159
- * death) also stays un-reaped — reaping is fail-safe, never a guess.
2160
- */
2100
+ /** Reap only stale sessions with proven-dead pids; unknown or live processes remain recoverable. */
2161
2101
  export function isReapableOrphan(row) {
2162
2102
  return row.status === 'abandoned' && row.pidAlive === false;
2163
2103
  }
@@ -567,46 +567,15 @@ interface TopCostSession {
567
567
  * vanished, mirroring querySessions' liveness filter.
568
568
  */
569
569
  export declare function topSessionsByCost(n: number, options?: QueryOptions): TopCostSession[];
570
- /** Look up a single session by its unique ID. */
571
570
  /**
572
- * Batch-resolve session ids to the machine each one runs on, in ONE indexed
573
- * query. `getActiveSessions` needs only this column for every live row, and
574
- * `getSessionById` would re-`prepare` a `SELECT *` and materialize a full
575
- * `SessionMeta` per id to read it — mirrors {@link findSessionsByShortIds}'s
576
- * single-round-trip pattern. Ids absent from the index are simply absent from
577
- * the map. Best-effort: an unavailable DB yields an empty map, so the live view
578
- * still renders (the caller then leaves rows attributed to this box).
571
+ * Read machine attribution in batches without materializing full sessions.
572
+ * Failure is best-effort so the live view can still render local attribution.
579
573
  */
580
574
  export declare function findSessionMachinesByIds(ids: string[]): Map<string, string>;
581
575
  export declare function getSessionById(id: string): SessionMeta | null;
582
- /**
583
- * Resolve a full-or-partial session id against the index, exact-first then
584
- * prefix — the DB-backed equivalent of resolveSessionById() that runs over the
585
- * SQLite table instead of a pre-loaded array. Matches both the full id and the
586
- * short id. An exact hit short-circuits so a complete id never also drags in its
587
- * prefix siblings. `scope` narrows by agent / version / project (cwd) so an
588
- * ambiguous prefix disambiguates against the caller's context.
589
- *
590
- * Routes through the full querySessions existence check (NOT skipExistenceCheck)
591
- * on purpose (RUSH-2436): that check now KEEPS a file-gone session whose user
592
- * turns still live in session_text (flagged archived) and only suppresses a
593
- * contentless phantom — so `agents sessions <id>` resolves an archived session
594
- * instead of failing with "No session found", while a phantom id still misses.
595
- */
576
+ /** Exact ids win over prefixes; the normal existence check preserves archived content but excludes phantoms. */
596
577
  export declare function findSessionsById(idQuery: string, scope?: Pick<QueryOptions, 'agent' | 'version' | 'cwd' | 'project'>): SessionMeta[];
597
- /**
598
- * Batch-resolve many 8-char short ids to their sessions in ONE indexed query.
599
- * The live-scan path (listTmuxAgentSessions) turns every `ag-<agent>-<shortid>`
600
- * tmux pane name back into a full session id this way, so it pays a single
601
- * `short_id IN (…)` round-trip per scan instead of N per-pane lookups.
602
- *
603
- * Returns a map keyed by short_id (lowercased). Short ids are the first 8 chars
604
- * of the lowercase session UUID (deriveShortId), so a lowercased `IN` matches and
605
- * still uses idx_sessions_short_id. When several sessions share a short id — only
606
- * time-ordered ids (ULID/UUIDv7) ever collide; random UUIDv4 short ids are unique
607
- * in practice — the most-recently-active one wins (the caller can further
608
- * disambiguate by cwd).
609
- */
578
+ /** Batch-resolve pane short ids; on collision the most recently active session wins. */
610
579
  export declare function findSessionsByShortIds(shortIds: string[]): Map<string, SessionMeta>;
611
580
  /** A single full-text search result with ranking score. */
612
581
  interface FtsHit {
@@ -3390,15 +3390,9 @@ export function topSessionsByCost(n, options = {}) {
3390
3390
  durationMs: r.duration_ms ?? 0,
3391
3391
  }));
3392
3392
  }
3393
- /** Look up a single session by its unique ID. */
3394
3393
  /**
3395
- * Batch-resolve session ids to the machine each one runs on, in ONE indexed
3396
- * query. `getActiveSessions` needs only this column for every live row, and
3397
- * `getSessionById` would re-`prepare` a `SELECT *` and materialize a full
3398
- * `SessionMeta` per id to read it — mirrors {@link findSessionsByShortIds}'s
3399
- * single-round-trip pattern. Ids absent from the index are simply absent from
3400
- * the map. Best-effort: an unavailable DB yields an empty map, so the live view
3401
- * still renders (the caller then leaves rows attributed to this box).
3394
+ * Read machine attribution in batches without materializing full sessions.
3395
+ * Failure is best-effort so the live view can still render local attribution.
3402
3396
  */
3403
3397
  export function findSessionMachinesByIds(ids) {
3404
3398
  const out = new Map();
@@ -3429,20 +3423,7 @@ export function getSessionById(id) {
3429
3423
  const row = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id);
3430
3424
  return row ? rowToMeta(row) : null;
3431
3425
  }
3432
- /**
3433
- * Resolve a full-or-partial session id against the index, exact-first then
3434
- * prefix — the DB-backed equivalent of resolveSessionById() that runs over the
3435
- * SQLite table instead of a pre-loaded array. Matches both the full id and the
3436
- * short id. An exact hit short-circuits so a complete id never also drags in its
3437
- * prefix siblings. `scope` narrows by agent / version / project (cwd) so an
3438
- * ambiguous prefix disambiguates against the caller's context.
3439
- *
3440
- * Routes through the full querySessions existence check (NOT skipExistenceCheck)
3441
- * on purpose (RUSH-2436): that check now KEEPS a file-gone session whose user
3442
- * turns still live in session_text (flagged archived) and only suppresses a
3443
- * contentless phantom — so `agents sessions <id>` resolves an archived session
3444
- * instead of failing with "No session found", while a phantom id still misses.
3445
- */
3426
+ /** Exact ids win over prefixes; the normal existence check preserves archived content but excludes phantoms. */
3446
3427
  export function findSessionsById(idQuery, scope = {}) {
3447
3428
  const q = idQuery.trim();
3448
3429
  if (!q)
@@ -3452,19 +3433,7 @@ export function findSessionsById(idQuery, scope = {}) {
3452
3433
  return exact;
3453
3434
  return querySessions({ ...scope, idPrefix: q });
3454
3435
  }
3455
- /**
3456
- * Batch-resolve many 8-char short ids to their sessions in ONE indexed query.
3457
- * The live-scan path (listTmuxAgentSessions) turns every `ag-<agent>-<shortid>`
3458
- * tmux pane name back into a full session id this way, so it pays a single
3459
- * `short_id IN (…)` round-trip per scan instead of N per-pane lookups.
3460
- *
3461
- * Returns a map keyed by short_id (lowercased). Short ids are the first 8 chars
3462
- * of the lowercase session UUID (deriveShortId), so a lowercased `IN` matches and
3463
- * still uses idx_sessions_short_id. When several sessions share a short id — only
3464
- * time-ordered ids (ULID/UUIDv7) ever collide; random UUIDv4 short ids are unique
3465
- * in practice — the most-recently-active one wins (the caller can further
3466
- * disambiguate by cwd).
3467
- */
3436
+ /** Batch-resolve pane short ids; on collision the most recently active session wins. */
3468
3437
  export function findSessionsByShortIds(shortIds) {
3469
3438
  const out = new Map();
3470
3439
  const uniq = [...new Set(shortIds.map((s) => s.trim().toLowerCase()).filter(Boolean))];
@@ -54,14 +54,7 @@ export interface DiscoverOptions {
54
54
  idExact?: string;
55
55
  /** Session id prefix — a targeted indexed lookup with no scan (RUSH-2477). */
56
56
  idPrefix?: string;
57
- /**
58
- * Cold-miss repair: when another live process already holds the scan claim,
59
- * wait (bounded) for that in-flight scan to finish before reading the index,
60
- * instead of returning the pre-scan snapshot (RUSH-2682). A caller repairing a
61
- * "not indexed yet" miss wants the fresh result the concurrent scan is about to
62
- * write, not the stale read that just missed. Ignored when THIS process wins
63
- * the claim (it scans itself) or no scan is in progress.
64
- */
57
+ /** On a cold miss, briefly await the scan already holding the single-flight claim. */
65
58
  waitForScan?: boolean;
66
59
  }
67
60
  /** Progress report emitted during incremental scanning. */
@@ -181,41 +174,15 @@ export declare function discoverSessions(options?: DiscoverOptions): Promise<Ses
181
174
  interface IncrementalScanResult {
182
175
  /** True when this process won the single-flight claim and ran the scan. */
183
176
  claimed: boolean;
184
- /**
185
- * Transcripts parsed this scan — i.e. those whose (mtime, size) changed. Zero
186
- * is the steady state on an idle box and does NOT mean the scan was skipped;
187
- * read `claimed` for that.
188
- *
189
- * Twelve of the 13 SESSION_AGENTS contribute, including OpenCode, whose
190
- * scanner filters to sessions whose own per-session stamp changed and reports
191
- * that batch — so a tick whose only changed sessions live there no longer
192
- * reports 0 (RUSH-2691). OpenClaw is the exception and contributes nothing:
193
- * its scanner has no change detection to report (a TTL gate, a fresh stamp
194
- * every run, and an entry list rebuilt as the current inventory), so counting
195
- * it would overstate rather than measure. See `scanOpenClawIncremental`.
196
- */
177
+ /** Changed transcripts parsed; zero with `claimed: true` is a successful no-op scan. */
197
178
  scanned: number;
198
179
  }
199
- /**
200
- * The write half of {@link discoverSessions}: claim the single-flight scan slot,
201
- * incrementally index this host's transcript dirs, and report what was parsed.
202
- *
203
- * Split out so a caller that only wants the index refreshed — the daemon's warm
204
- * tick — can run it WITHOUT the listing query `discoverSessions` ends with. That
205
- * query is not free: it applies a cwd filter, runs the `archived_at`-writing
206
- * existence check, and can issue a Linear fetch, none of which index anything
207
- * (RUSH-2691). Keeping one implementation here is also what stops the tick and
208
- * the foreground path from drifting apart.
209
- */
180
+ /** Separate write half so daemon warming does not pay for listing or external enrichment. */
210
181
  export declare function scanSessionsIncremental(options?: {
211
182
  agent?: SessionAgentId;
212
183
  onProgress?: (p: ScanProgress) => void;
213
184
  }): Promise<IncrementalScanResult>;
214
- /**
215
- * Poll until no live process holds the scan claim, or the bound elapses
216
- * (RUSH-2682). Bounded so a wedged/slow scan can never hang a foreground preview.
217
- * Exported for the cold-miss repair test.
218
- */
185
+ /** Bounded wait so a wedged scan cannot hang a foreground cold-miss repair. */
219
186
  export declare function waitForScanToSettle(timeoutMs?: number, pollMs?: number): Promise<boolean>;
220
187
  /** Read the current SQLite snapshot without scanning or parsing transcript files. */
221
188
  export declare function queryIndexedSessions(options?: DiscoverOptions, indexedOptions?: {
@@ -223,27 +190,8 @@ export declare function queryIndexedSessions(options?: DiscoverOptions, indexedO
223
190
  skipExistenceCheck?: boolean;
224
191
  }): Promise<SessionMeta[]>;
225
192
  /**
226
- * Resolve a full-or-partial session id against the LOCAL SQLite index only.
227
- *
228
- * A plain WAL read through `queryIndexedSessions` — same origin-machine
229
- * attribution and managed scoping every indexed read gets — with NO incremental
230
- * discovery scan (so none of `tryClaimScan`/`releaseScan`'s `BEGIN IMMEDIATE`
231
- * writer lock) and NO fleet SSH fan-out. This is the crash-restart storm path
232
- * (RUSH-2477): dozens of `sessions resume <id>` at once for a known local id must
233
- * each be a cheap read, never a writer-lock contender or a dial into the
234
- * not-yet-up tailnet. Exact id first, then prefix (matching `findSessionsById`),
235
- * so a complete id never also drags in its prefix siblings. Returns `[]` on a
236
- * genuine local miss, leaving the caller to fall back to the fleet resolver.
237
- *
238
- * The existence check is left ON (`skipExistenceCheck: false`), exactly as the old
239
- * `discoverSessions` path and `findSessionsById` do (RUSH-2436): it KEEPS a
240
- * file-gone session whose user turns still live in `session_text` (flagged
241
- * archived) and SUPPRESSES a contentless phantom — so a phantom id misses here and
242
- * falls through to the fleet resolver, instead of resolving to a row with no real
243
- * transcript to resume. For a present transcript — the storm's actual case, since
244
- * the crashed tabs' files are on disk — the check does no writes, so the lock-free
245
- * guarantee holds; it only writes to (un)archive a genuinely missing or resurrected
246
- * file, which is not the 20-at-once resume path.
193
+ * Resolve locally without scanning or fleet I/O, keeping concurrent crash recovery lock-light.
194
+ * The existence check preserves archived content while rejecting transcriptless phantoms.
247
195
  */
248
196
  export declare function resolveIndexedSessionById(idQuery: string): Promise<SessionMeta[]>;
249
197
  /**
@@ -131,12 +131,7 @@ async function applyJsonlAppend(filePath, fromOffset, wasDroppingOversizedLine,
131
131
  * between `agents sessions` calls but is still being written to.
132
132
  */
133
133
  const HOT_FILE_WINDOW_MS = 600_000;
134
- /**
135
- * Kill-switch: set `AGENTS_SESSIONS_NO_DIR_LEDGER=1` to force the old full-walk
136
- * path (readdir + per-file stat every dir, every run — the pre-A-2 behavior),
137
- * skipping the dir_ledger short-circuit entirely. One env var reverts a field
138
- * regression to today's behavior.
139
- */
134
+ /** Emergency kill-switch for the directory-ledger optimization. */
140
135
  function dirLedgerDisabled() {
141
136
  const v = process.env.AGENTS_SESSIONS_NO_DIR_LEDGER;
142
137
  return v === '1' || v === 'true';
@@ -175,17 +170,7 @@ export async function discoverSessions(options) {
175
170
  skipExistenceCheck: options?.skipExistenceCheck ?? false,
176
171
  });
177
172
  }
178
- /**
179
- * The write half of {@link discoverSessions}: claim the single-flight scan slot,
180
- * incrementally index this host's transcript dirs, and report what was parsed.
181
- *
182
- * Split out so a caller that only wants the index refreshed — the daemon's warm
183
- * tick — can run it WITHOUT the listing query `discoverSessions` ends with. That
184
- * query is not free: it applies a cwd filter, runs the `archived_at`-writing
185
- * existence check, and can issue a Linear fetch, none of which index anything
186
- * (RUSH-2691). Keeping one implementation here is also what stops the tick and
187
- * the foreground path from drifting apart.
188
- */
173
+ /** Separate write half so daemon warming does not pay for listing or external enrichment. */
189
174
  export async function scanSessionsIncremental(options) {
190
175
  // Touch the DB so the schema is ready and connection is cached for this run.
191
176
  getDB();
@@ -222,11 +207,7 @@ export async function scanSessionsIncremental(options) {
222
207
  scanned += n;
223
208
  return { claimed: true, scanned };
224
209
  }
225
- /**
226
- * Poll until no live process holds the scan claim, or the bound elapses
227
- * (RUSH-2682). Bounded so a wedged/slow scan can never hang a foreground preview.
228
- * Exported for the cold-miss repair test.
229
- */
210
+ /** Bounded wait so a wedged scan cannot hang a foreground cold-miss repair. */
230
211
  export async function waitForScanToSettle(timeoutMs = WAIT_FOR_SCAN_TIMEOUT_MS, pollMs = WAIT_FOR_SCAN_POLL_MS) {
231
212
  const deadline = Date.now() + timeoutMs;
232
213
  while (scanInProgressByLivePid()) {
@@ -259,27 +240,8 @@ export async function queryIndexedSessions(options, indexedOptions = {}) {
259
240
  return scopeToManaged(sessions, agents, options);
260
241
  }
261
242
  /**
262
- * Resolve a full-or-partial session id against the LOCAL SQLite index only.
263
- *
264
- * A plain WAL read through `queryIndexedSessions` — same origin-machine
265
- * attribution and managed scoping every indexed read gets — with NO incremental
266
- * discovery scan (so none of `tryClaimScan`/`releaseScan`'s `BEGIN IMMEDIATE`
267
- * writer lock) and NO fleet SSH fan-out. This is the crash-restart storm path
268
- * (RUSH-2477): dozens of `sessions resume <id>` at once for a known local id must
269
- * each be a cheap read, never a writer-lock contender or a dial into the
270
- * not-yet-up tailnet. Exact id first, then prefix (matching `findSessionsById`),
271
- * so a complete id never also drags in its prefix siblings. Returns `[]` on a
272
- * genuine local miss, leaving the caller to fall back to the fleet resolver.
273
- *
274
- * The existence check is left ON (`skipExistenceCheck: false`), exactly as the old
275
- * `discoverSessions` path and `findSessionsById` do (RUSH-2436): it KEEPS a
276
- * file-gone session whose user turns still live in `session_text` (flagged
277
- * archived) and SUPPRESSES a contentless phantom — so a phantom id misses here and
278
- * falls through to the fleet resolver, instead of resolving to a row with no real
279
- * transcript to resume. For a present transcript — the storm's actual case, since
280
- * the crashed tabs' files are on disk — the check does no writes, so the lock-free
281
- * guarantee holds; it only writes to (un)archive a genuinely missing or resurrected
282
- * file, which is not the 20-at-once resume path.
243
+ * Resolve locally without scanning or fleet I/O, keeping concurrent crash recovery lock-light.
244
+ * The existence check preserves archived content while rejecting transcriptless phantoms.
283
245
  */
284
246
  export async function resolveIndexedSessionById(idQuery) {
285
247
  const q = idQuery.trim();