@phnx-labs/agents-cli 1.22.22 → 1.22.24

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 (138) hide show
  1. package/CHANGELOG.md +402 -0
  2. package/README.md +13 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/attach.d.ts +2 -0
  5. package/dist/commands/attach.js +32 -7
  6. package/dist/commands/defaults.js +2 -0
  7. package/dist/commands/doctor.js +20 -7
  8. package/dist/commands/exec.js +100 -34
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +108 -5
  12. package/dist/commands/focus.js +455 -32
  13. package/dist/commands/fork.d.ts +20 -2
  14. package/dist/commands/fork.js +91 -64
  15. package/dist/commands/go.d.ts +39 -1
  16. package/dist/commands/go.js +112 -7
  17. package/dist/commands/harness-wizard.d.ts +206 -0
  18. package/dist/commands/harness-wizard.js +403 -0
  19. package/dist/commands/harness.d.ts +12 -0
  20. package/dist/commands/harness.js +97 -101
  21. package/dist/commands/insights.js +6 -2
  22. package/dist/commands/inspect.js +39 -5
  23. package/dist/commands/menubar.js +6 -1
  24. package/dist/commands/models.js +1 -0
  25. package/dist/commands/modes.d.ts +12 -0
  26. package/dist/commands/modes.js +147 -0
  27. package/dist/commands/resume.js +10 -3
  28. package/dist/commands/secrets-sync.js +11 -13
  29. package/dist/commands/secrets.d.ts +2 -0
  30. package/dist/commands/secrets.js +79 -47
  31. package/dist/commands/sessions-browser.d.ts +35 -0
  32. package/dist/commands/sessions-browser.js +140 -14
  33. package/dist/commands/sessions-resume.d.ts +23 -3
  34. package/dist/commands/sessions-resume.js +73 -19
  35. package/dist/commands/sessions.d.ts +91 -5
  36. package/dist/commands/sessions.js +359 -125
  37. package/dist/commands/setup-secrets.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/view.js +2 -0
  40. package/dist/commands/watchdog.js +13 -2
  41. package/dist/index.js +2 -1
  42. package/dist/lib/agent-modes.d.ts +49 -0
  43. package/dist/lib/agent-modes.js +70 -0
  44. package/dist/lib/agents.d.ts +1 -1
  45. package/dist/lib/agents.js +132 -0
  46. package/dist/lib/codex-policy.d.ts +17 -0
  47. package/dist/lib/codex-policy.js +48 -0
  48. package/dist/lib/crabbox/lease.d.ts +25 -0
  49. package/dist/lib/crabbox/lease.js +62 -0
  50. package/dist/lib/daemon.js +70 -0
  51. package/dist/lib/doctor-diff.d.ts +3 -0
  52. package/dist/lib/doctor-diff.js +15 -13
  53. package/dist/lib/event-stream.d.ts +3 -1
  54. package/dist/lib/event-stream.js +14 -1
  55. package/dist/lib/exec.d.ts +16 -0
  56. package/dist/lib/exec.js +175 -62
  57. package/dist/lib/feed-broadcast.d.ts +1 -20
  58. package/dist/lib/feed-broadcast.js +31 -1
  59. package/dist/lib/hooks/cache.js +36 -3
  60. package/dist/lib/hooks.d.ts +19 -7
  61. package/dist/lib/hooks.js +100 -40
  62. package/dist/lib/hosts/session-index.d.ts +4 -0
  63. package/dist/lib/hosts/session-index.js +7 -0
  64. package/dist/lib/manifest.d.ts +12 -2
  65. package/dist/lib/manifest.js +60 -5
  66. package/dist/lib/mcp.js +44 -0
  67. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  68. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  69. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  70. package/dist/lib/menubar/install-menubar.js +128 -6
  71. package/dist/lib/models.d.ts +0 -5
  72. package/dist/lib/models.js +48 -0
  73. package/dist/lib/plugin-marketplace.js +9 -0
  74. package/dist/lib/pricing/prices.json +119 -92
  75. package/dist/lib/pricing/table.js +13 -0
  76. package/dist/lib/refresh.d.ts +5 -0
  77. package/dist/lib/refresh.js +37 -33
  78. package/dist/lib/remote-agents-json.d.ts +29 -1
  79. package/dist/lib/remote-agents-json.js +47 -10
  80. package/dist/lib/resource-inventory.d.ts +79 -0
  81. package/dist/lib/resource-inventory.js +122 -0
  82. package/dist/lib/resources/mcp.js +2 -0
  83. package/dist/lib/resources/permissions.js +3 -0
  84. package/dist/lib/resources/types.d.ts +2 -1
  85. package/dist/lib/resources.js +8 -5
  86. package/dist/lib/run-defaults.d.ts +2 -0
  87. package/dist/lib/run-defaults.js +23 -2
  88. package/dist/lib/runner.js +50 -36
  89. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  90. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  91. package/dist/lib/secrets/bundles.js +157 -65
  92. package/dist/lib/secrets/filestore.d.ts +5 -3
  93. package/dist/lib/secrets/filestore.js +12 -8
  94. package/dist/lib/secrets/index.d.ts +15 -1
  95. package/dist/lib/secrets/index.js +148 -53
  96. package/dist/lib/secrets/reaper.d.ts +97 -0
  97. package/dist/lib/secrets/reaper.js +219 -0
  98. package/dist/lib/secrets/remote.d.ts +29 -0
  99. package/dist/lib/secrets/remote.js +37 -1
  100. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  101. package/dist/lib/secrets/sync-passphrase.js +78 -0
  102. package/dist/lib/session/active.d.ts +36 -1
  103. package/dist/lib/session/active.js +60 -19
  104. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  105. package/dist/lib/session/actor-sidecar.js +67 -4
  106. package/dist/lib/session/db.d.ts +1 -1
  107. package/dist/lib/session/db.js +32 -1
  108. package/dist/lib/session/discover.js +168 -0
  109. package/dist/lib/session/parse.d.ts +10 -0
  110. package/dist/lib/session/parse.js +98 -0
  111. package/dist/lib/session/recovery.d.ts +37 -0
  112. package/dist/lib/session/recovery.js +95 -0
  113. package/dist/lib/session/remote-list.d.ts +10 -1
  114. package/dist/lib/session/remote-list.js +2 -8
  115. package/dist/lib/session/remote.d.ts +57 -3
  116. package/dist/lib/session/remote.js +90 -26
  117. package/dist/lib/session/resume-command.d.ts +6 -0
  118. package/dist/lib/session/resume-command.js +8 -0
  119. package/dist/lib/session/session-cache.d.ts +173 -0
  120. package/dist/lib/session/session-cache.js +399 -0
  121. package/dist/lib/session/types.d.ts +1 -1
  122. package/dist/lib/session/types.js +1 -1
  123. package/dist/lib/session/width.d.ts +1 -1
  124. package/dist/lib/session/width.js +12 -2
  125. package/dist/lib/shims.d.ts +2 -2
  126. package/dist/lib/shims.js +62 -8
  127. package/dist/lib/staleness/writers/hooks.js +1 -1
  128. package/dist/lib/startup/command-registry.d.ts +1 -0
  129. package/dist/lib/startup/command-registry.js +2 -0
  130. package/dist/lib/sync-umbrella.d.ts +5 -0
  131. package/dist/lib/sync-umbrella.js +5 -4
  132. package/dist/lib/tmux/session.d.ts +4 -2
  133. package/dist/lib/tmux/session.js +5 -5
  134. package/dist/lib/types.d.ts +3 -1
  135. package/dist/lib/usage.d.ts +13 -0
  136. package/dist/lib/usage.js +215 -0
  137. package/dist/lib/versions.js +13 -2
  138. package/package.json +1 -1
@@ -177,3 +177,32 @@ export declare function keychainWriteFailureMessage(host: string, bundle: string
177
177
  export declare function verifyRemoteKeychainPush(target: string, bundle: string, pushedKeys: string[], opts?: {
178
178
  osLookupName?: string;
179
179
  }): RemoteKeychainWriteVerification;
180
+ /**
181
+ * The `bash -lc` command + stdin payload that drives a **file-backed** remote
182
+ * import for `secrets export --host … --remote-backend file`.
183
+ *
184
+ * The file store is passphrase-free by default: with `AGENTS_SECRETS_PASSPHRASE`
185
+ * unset the remote `agents secrets import --backend file` auto-provisions the
186
+ * remote's own machine-local key (0600 under `~/.agents/.secrets-key/`), so its
187
+ * reads are HEADLESS — no passphrase, no Touch ID. A passphrase is therefore
188
+ * OPTIONAL and only forwarded when the operator sets one locally (opt-in, e.g. to
189
+ * key the bundle off-disk under a shared secret):
190
+ *
191
+ * - **No passphrase** → the remote runs `import … --backend file` directly with
192
+ * ONLY the .env on stdin. No `read`/`export AGENTS_SECRETS_PASSPHRASE`
193
+ * prologue, so `AGENTS_SECRETS_PASSPHRASE` stays UNSET on the remote → the
194
+ * machine-local key path → headless reads.
195
+ * - **Passphrase set** → forward it as the FIRST stdin line, consumed by
196
+ * `IFS= read -r` (so it never lands in argv / `ps` / remote shell history),
197
+ * then the .env. The remote then keys the bundle under that shared passphrase.
198
+ *
199
+ * Pure — no I/O — so the exact command string and stdin ordering are unit-testable
200
+ * against the SSH boundary the same way `remoteSecretsRaw` is.
201
+ */
202
+ export declare function buildRemoteFileImportCommand(bundle: string, dotenv: string, opts?: {
203
+ passphrase?: string;
204
+ force?: boolean;
205
+ }): {
206
+ remoteCmd: string;
207
+ input: string;
208
+ };
@@ -15,7 +15,7 @@
15
15
  * file-backend passphrase travels over ssh stdin (first line) so it never lands
16
16
  * in argv / `ps` / remote shell history. Nothing is persisted locally.
17
17
  */
18
- import { sshExec, sshStream, assertValidSshTarget } from '../ssh-exec.js';
18
+ import { sshExec, sshStream, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
19
  import { resolveHost } from '../hosts/registry.js';
20
20
  import { emitSecretAudit } from './audit.js';
21
21
  import { sshTargetFor } from '../hosts/types.js';
@@ -348,3 +348,39 @@ export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {})
348
348
  }
349
349
  return evaluateKeychainWriteVerification(pushedKeys, { ok: true, keys });
350
350
  }
351
+ /**
352
+ * The `bash -lc` command + stdin payload that drives a **file-backed** remote
353
+ * import for `secrets export --host … --remote-backend file`.
354
+ *
355
+ * The file store is passphrase-free by default: with `AGENTS_SECRETS_PASSPHRASE`
356
+ * unset the remote `agents secrets import --backend file` auto-provisions the
357
+ * remote's own machine-local key (0600 under `~/.agents/.secrets-key/`), so its
358
+ * reads are HEADLESS — no passphrase, no Touch ID. A passphrase is therefore
359
+ * OPTIONAL and only forwarded when the operator sets one locally (opt-in, e.g. to
360
+ * key the bundle off-disk under a shared secret):
361
+ *
362
+ * - **No passphrase** → the remote runs `import … --backend file` directly with
363
+ * ONLY the .env on stdin. No `read`/`export AGENTS_SECRETS_PASSPHRASE`
364
+ * prologue, so `AGENTS_SECRETS_PASSPHRASE` stays UNSET on the remote → the
365
+ * machine-local key path → headless reads.
366
+ * - **Passphrase set** → forward it as the FIRST stdin line, consumed by
367
+ * `IFS= read -r` (so it never lands in argv / `ps` / remote shell history),
368
+ * then the .env. The remote then keys the bundle under that shared passphrase.
369
+ *
370
+ * Pure — no I/O — so the exact command string and stdin ordering are unit-testable
371
+ * against the SSH boundary the same way `remoteSecretsRaw` is.
372
+ */
373
+ export function buildRemoteFileImportCommand(bundle, dotenv, opts = {}) {
374
+ const force = opts.force ? ' --force' : '';
375
+ const importCmd = `agents secrets import ${shellQuote(bundle)} --from - --backend file${force}`;
376
+ const passphrase = opts.passphrase ?? '';
377
+ if (passphrase) {
378
+ // Opt-in shared passphrase: read it off the FIRST stdin line, export it, then
379
+ // let `import --from -` read the .env remainder.
380
+ const remoteAgents = `IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ${importCmd}`;
381
+ return { remoteCmd: `bash -lc ${shellQuote(remoteAgents)}`, input: `${passphrase}\n${dotenv}` };
382
+ }
383
+ // No passphrase: NO prologue — AGENTS_SECRETS_PASSPHRASE stays unset on the
384
+ // remote, so the file store falls back to its machine-local key (headless).
385
+ return { remoteCmd: `bash -lc ${shellQuote(importCmd)}`, input: dotenv };
386
+ }
@@ -0,0 +1,27 @@
1
+ /** The current variable for transport/sync passphrases. */
2
+ export declare const SYNC_PASSPHRASE_ENV = "AGENTS_SYNC_PASSPHRASE";
3
+ /** The file-store master key, honoured for sync only as a deprecated fallback. */
4
+ export declare const LEGACY_PASSPHRASE_ENV = "AGENTS_SECRETS_PASSPHRASE";
5
+ /** Reset the one-shot warning latches. Tests only — production never re-warns. */
6
+ export declare function resetSyncPassphraseWarnings(): void;
7
+ /** Where a resolved sync passphrase came from, so callers can report honestly. */
8
+ export type SyncPassphraseSource = 'sync-env' | 'legacy-env' | null;
9
+ export interface ResolvedSyncPassphrase {
10
+ value: string | null;
11
+ source: SyncPassphraseSource;
12
+ }
13
+ /**
14
+ * Read the transport passphrase from the environment, preferring the current
15
+ * variable and falling back to the deprecated one with a single warning.
16
+ * Returns `{ value: null }` when neither is set — the caller decides whether to
17
+ * prompt (TTY) or fail (headless). Never prompts, never throws.
18
+ */
19
+ export declare function resolveSyncPassphraseFromEnv(): ResolvedSyncPassphrase;
20
+ /**
21
+ * The one-shot reminder that an env-sourced passphrase is readable by other
22
+ * same-user processes. Separate from the deprecation notice above so a caller
23
+ * using the CURRENT variable still gets the readability warning exactly once.
24
+ */
25
+ export declare function warnEnvPassphraseReadableOnce(): void;
26
+ /** The message a headless caller shows when no passphrase is available. */
27
+ export declare function missingSyncPassphraseMessage(): string;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The passphrase that seals a bundle for TRANSPORT — `secrets push`/`pull` and
3
+ * the portable `export --to-file` / `import --from-file` envelope.
4
+ *
5
+ * This is deliberately a DIFFERENT secret from the file store's master key
6
+ * (`AGENTS_SECRETS_PASSPHRASE`, `filestore.ts:getPassphrase`). The two were one
7
+ * variable, and that overload is what put a master key into `~/.zshenv` on seven
8
+ * worker boxes (RUSH-1968): the file store stopped needing a passphrase once it
9
+ * auto-provisioned a machine-local key, but headless `push`/`pull` still hard-
10
+ * failed without one, so an operator exported the master key fleet-wide to make
11
+ * sync work — handing every same-user process the key to the whole store.
12
+ *
13
+ * Splitting them means a box that only needs headless sync sets
14
+ * `AGENTS_SYNC_PASSPHRASE` and never has the store's master key in its
15
+ * environment at all.
16
+ *
17
+ * Resolution order:
18
+ * 1. `AGENTS_SYNC_PASSPHRASE` — the current name.
19
+ * 2. `AGENTS_SECRETS_PASSPHRASE` — deprecated fallback, warns once.
20
+ * 3. (caller prompts, or fails)
21
+ *
22
+ * The fallback exists so already-scripted CI and release automation keep working
23
+ * across the upgrade; it is not a second supported spelling.
24
+ */
25
+ import chalk from 'chalk';
26
+ /** The current variable for transport/sync passphrases. */
27
+ export const SYNC_PASSPHRASE_ENV = 'AGENTS_SYNC_PASSPHRASE';
28
+ /** The file-store master key, honoured for sync only as a deprecated fallback. */
29
+ export const LEGACY_PASSPHRASE_ENV = 'AGENTS_SECRETS_PASSPHRASE';
30
+ /** Warn at most once per process: a `--all` push over many bundles must not
31
+ * flood stderr with the same notice. */
32
+ let deprecatedVarWarned = false;
33
+ /** Same, for the "this came from an env var at all" readability notice. */
34
+ let envPassphraseWarned = false;
35
+ /** Reset the one-shot warning latches. Tests only — production never re-warns. */
36
+ export function resetSyncPassphraseWarnings() {
37
+ deprecatedVarWarned = false;
38
+ envPassphraseWarned = false;
39
+ }
40
+ /**
41
+ * Read the transport passphrase from the environment, preferring the current
42
+ * variable and falling back to the deprecated one with a single warning.
43
+ * Returns `{ value: null }` when neither is set — the caller decides whether to
44
+ * prompt (TTY) or fail (headless). Never prompts, never throws.
45
+ */
46
+ export function resolveSyncPassphraseFromEnv() {
47
+ const current = process.env[SYNC_PASSPHRASE_ENV];
48
+ if (current)
49
+ return { value: current, source: 'sync-env' };
50
+ const legacy = process.env[LEGACY_PASSPHRASE_ENV];
51
+ if (legacy) {
52
+ if (!deprecatedVarWarned) {
53
+ deprecatedVarWarned = true;
54
+ process.stderr.write(chalk.yellow(`warn: ${LEGACY_PASSPHRASE_ENV} is deprecated for sync — it is the file store's master key, ` +
55
+ `not a transport passphrase. Set ${SYNC_PASSPHRASE_ENV} instead; the old name still works ` +
56
+ 'for now. Keeping the master key in the environment exposes the whole store to every ' +
57
+ 'same-user process (RUSH-1968).\n'));
58
+ }
59
+ return { value: legacy, source: 'legacy-env' };
60
+ }
61
+ return { value: null, source: null };
62
+ }
63
+ /**
64
+ * The one-shot reminder that an env-sourced passphrase is readable by other
65
+ * same-user processes. Separate from the deprecation notice above so a caller
66
+ * using the CURRENT variable still gets the readability warning exactly once.
67
+ */
68
+ export function warnEnvPassphraseReadableOnce() {
69
+ if (envPassphraseWarned)
70
+ return;
71
+ envPassphraseWarned = true;
72
+ process.stderr.write(chalk.yellow('warn: using a sync passphrase from the environment. Env vars are readable by other ' +
73
+ 'same-user processes (/proc, ps, crash dumps, CI logs) — rotate after CI use.\n'));
74
+ }
75
+ /** The message a headless caller shows when no passphrase is available. */
76
+ export function missingSyncPassphraseMessage() {
77
+ return `A sync passphrase is required. Run from a TTY, or set ${SYNC_PASSPHRASE_ENV}.`;
78
+ }
@@ -2,7 +2,7 @@ import type { CloudTaskStatus } from '../cloud/types.js';
2
2
  import { type PidSessionEntry } from './pid-registry.js';
3
3
  import { type HookSessionIndex } from './hook-sessions.js';
4
4
  import { type SessionState, type SessionActivity, type AwaitingReason, type StructuredQuestion, type TodoProgress, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
5
- import { type SessionAttachment } from './types.js';
5
+ import { type SessionAgentId, type SessionAttachment } from './types.js';
6
6
  import { type SessionProvenance } from './provenance.js';
7
7
  import { type DeviceRegistry } from '../devices/registry.js';
8
8
  import { type Presence } from './detached.js';
@@ -111,6 +111,13 @@ export interface ActiveSession {
111
111
  attachments?: SessionAttachment[];
112
112
  sessionFile?: string;
113
113
  startedAtMs?: number;
114
+ /**
115
+ * Agent version (e.g. `2.1.207`) for the row's `agent version` cell. Not a
116
+ * live-scan signal — a running process does not report its own semver — so it
117
+ * is backfilled at render time from the indexed {@link SessionMeta} by session
118
+ * id (RUSH-2205), never asserted by a source.
119
+ */
120
+ version?: string;
114
121
  /**
115
122
  * Last-activity epoch — the transcript's last write (mtime). Distinct from
116
123
  * {@link startedAtMs} (session START): a session begun 3h ago but last touched
@@ -294,6 +301,16 @@ export declare const ACTIVE_SESSION_STALE_MS: number;
294
301
  * concern) — this is the lifecycle threshold, not the freshness window.
295
302
  */
296
303
  export declare const ABANDONED_STALE_MS: number;
304
+ /**
305
+ * Every process executable ("comm") name that identifies a given session-agent
306
+ * kind in the headless `ps`-scan. Driven off the AGENTS registry's `cliCommand`
307
+ * (the real executable name — e.g. `agy` for antigravity, `cursor-agent` for
308
+ * cursor) plus {@link EXTRA_SESSION_AGENT_COMMS} for members with no registry
309
+ * row. Exported so the completeness test can assert every {@link SESSION_AGENTS}
310
+ * member resolves — the fix for the harness-parity gap where bare-headless
311
+ * grok/kimi/antigravity/openclaw/hermes/rush were silently dropped.
312
+ */
313
+ export declare function sessionAgentComms(id: SessionAgentId): string[];
297
314
  /**
298
315
  * Resolve an agent kind from a process's reported executable. `comm` may be an
299
316
  * absolute path (shim-launched agents), and Windows image names carry an
@@ -600,6 +617,24 @@ export declare function foldHostLink(rows: ActiveSession[]): void;
600
617
  * renderer shows the short id. Pure over the array; exported for tests.
601
618
  */
602
619
  export declare function annotateOrchestratorLabels(sessions: ActiveSession[]): void;
620
+ /**
621
+ * Attach provenance (host / local-vs-SSH / tmux pane / reply rail) to every
622
+ * session that has a live pid. Mutates in place. Runs after dedupe so we probe
623
+ * each session once, not once per fork pid. Probes run in parallel — each is a
624
+ * single /proc read (Linux) or `ps` call (macOS); failures leave `provenance`
625
+ * undefined rather than blocking the listing. The probes use the same bounded
626
+ * concurrency as the adjacent per-PID lsof sweep so large session lists cannot
627
+ * spawn one `ps eww` subprocess per row at once on macOS.
628
+ *
629
+ * A row that already carries provenance (the tmux path, which knows its exact
630
+ * mux/reply from the pane) is not skipped — it is probe-and-MERGED. The tmux
631
+ * path can only stamp a `transport:'local'` placeholder because the pane alone
632
+ * doesn't reveal how the shell above it was reached; the process env does. So we
633
+ * still read the env and fill in the real SSH origin/term, while preserving the
634
+ * authoritative mux/reply the pane already gave us. Skipping this (the old
635
+ * behavior) is exactly why ssh-launched tmux sessions rendered as local.
636
+ */
637
+ export declare function enrichProvenance(sessions: ActiveSession[], probe?: (pid: number) => Promise<SessionProvenance | undefined>): Promise<void>;
603
638
  /**
604
639
  * Match an SSH client IP to a registered device (pure — testable with a plain
605
640
  * registry object). Returns the device name + ssh login user when the IP is a
@@ -25,7 +25,7 @@ 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 { readSessionActorRecord } from './actor-sidecar.js';
28
+ import { readSessionActorRecord, writeSessionAliasRecord } from './actor-sidecar.js';
29
29
  import { loadHookSessionIndex, resolveHookSessionRecord, readStateSessionRecord } from './hook-sessions.js';
30
30
  import { buildClaudeLabelMap, getAgentSessionDirs } from './discover.js';
31
31
  import { buildRunNameMap } from './run-names.js';
@@ -35,7 +35,8 @@ import { readSessionTailWithRaw } from './tail.js';
35
35
  import { parseSession } from './parse.js';
36
36
  import { computeTokPerSec } from './throughput.js';
37
37
  import { inferSessionState } from './state.js';
38
- import { isSessionTrackedAgent } from './types.js';
38
+ import { isSessionTrackedAgent, SESSION_AGENTS } from './types.js';
39
+ import { AGENTS } from '../agents.js';
39
40
  import { detectProvenance } from './provenance.js';
40
41
  import { loadDevices } from '../devices/registry.js';
41
42
  import { presenceFromStore } from './detached.js';
@@ -132,15 +133,47 @@ export const ABANDONED_STALE_MS = 2 * 24 * 60 * 60_000;
132
133
  * just reintroduce the same class of bug with a lower probability.
133
134
  */
134
135
  const TMUX_FIELD_SEP = ':';
135
- /** Executables we recognize as agent CLIs when scanning the process table. */
136
- const AGENT_CLI_NAMES = {
137
- claude: 'claude',
138
- codex: 'codex',
139
- gemini: 'gemini',
140
- 'cursor-agent': 'cursor',
141
- opencode: 'opencode',
142
- droid: 'droid',
136
+ /**
137
+ * Process comm names that are NOT derivable from the AGENTS registry's
138
+ * `cliCommand`. `rush` is a {@link SESSION_AGENTS} member with no AGENTS registry
139
+ * row (it is the Rush app, not a managed harness), so its executable name is
140
+ * mapped explicitly here. Everything else flows from the registry below.
141
+ */
142
+ const EXTRA_SESSION_AGENT_COMMS = {
143
+ rush: ['rush'],
143
144
  };
145
+ /**
146
+ * Every process executable ("comm") name that identifies a given session-agent
147
+ * kind in the headless `ps`-scan. Driven off the AGENTS registry's `cliCommand`
148
+ * (the real executable name — e.g. `agy` for antigravity, `cursor-agent` for
149
+ * cursor) plus {@link EXTRA_SESSION_AGENT_COMMS} for members with no registry
150
+ * row. Exported so the completeness test can assert every {@link SESSION_AGENTS}
151
+ * member resolves — the fix for the harness-parity gap where bare-headless
152
+ * grok/kimi/antigravity/openclaw/hermes/rush were silently dropped.
153
+ */
154
+ export function sessionAgentComms(id) {
155
+ const comms = new Set();
156
+ const cli = AGENTS[id]?.cliCommand;
157
+ if (cli)
158
+ comms.add(cli);
159
+ for (const extra of EXTRA_SESSION_AGENT_COMMS[id] ?? [])
160
+ comms.add(extra);
161
+ return [...comms];
162
+ }
163
+ /**
164
+ * Executables we recognize as agent CLIs when scanning the process table. Built
165
+ * once from {@link sessionAgentComms} across {@link SESSION_AGENTS} — a single
166
+ * derived source, not a second hand-maintained allowlist that drifts from the
167
+ * discovery surface (the harness-parity code-review rule).
168
+ */
169
+ const AGENT_CLI_NAMES = (() => {
170
+ const map = {};
171
+ for (const id of SESSION_AGENTS) {
172
+ for (const comm of sessionAgentComms(id))
173
+ map[comm] = id;
174
+ }
175
+ return map;
176
+ })();
144
177
  /**
145
178
  * Resolve an agent kind from a process's reported executable. `comm` may be an
146
179
  * absolute path (shim-launched agents), and Windows image names carry an
@@ -1310,7 +1343,7 @@ export async function listTmuxAgentSessions() {
1310
1343
  try {
1311
1344
  res = await runTmux({
1312
1345
  socket,
1313
- args: ['list-panes', '-a', '-F', ['#{pane_id}', '#{session_name}', '#{pane_pid}', '#{pane_current_path}'].join(TMUX_FIELD_SEP)],
1346
+ args: ['list-panes', '-a', '-F', ['#{pane_id}', '#{session_name}', '#{pane_pid}', '#{pane_dead}', '#{pane_current_path}'].join(TMUX_FIELD_SEP)],
1314
1347
  throwOnError: false,
1315
1348
  // A wedged tmux server must not hang the whole active-session scan. The
1316
1349
  // catch below turns a timeout into an empty tmux source (the other sources
@@ -1351,8 +1384,8 @@ export async function listTmuxAgentSessions() {
1351
1384
  // The path is the LAST field, so rejoin its tail: a directory containing the
1352
1385
  // separator must not truncate it (the earlier fields cannot contain one).
1353
1386
  const parts = line.split(TMUX_FIELD_SEP);
1354
- const [pane, sessName, pidRaw] = parts;
1355
- const curPath = parts.slice(3).join(TMUX_FIELD_SEP);
1387
+ const [pane, sessName, pidRaw, paneDeadRaw] = parts;
1388
+ const curPath = parts.slice(4).join(TMUX_FIELD_SEP);
1356
1389
  if (!pane || !sessName)
1357
1390
  continue;
1358
1391
  const meta = readSessionMeta(sessName);
@@ -1378,6 +1411,9 @@ export async function listTmuxAgentSessions() {
1378
1411
  if (backfilled)
1379
1412
  id = { ...id, sessionId: backfilled };
1380
1413
  }
1414
+ if (id.sessionId && shortIdFromName(sessName)) {
1415
+ writeSessionAliasRecord(id.sessionId, sessName);
1416
+ }
1381
1417
  // Dedupe by resolved session id; an as-yet-unresolved id (a hookless/lagging
1382
1418
  // split) keys on the unique pane so it still surfaces as its own row.
1383
1419
  const dedupKey = id.sessionId ?? pane;
@@ -1395,7 +1431,10 @@ export async function listTmuxAgentSessions() {
1395
1431
  // bug). Refuse to guess: an id-less pane surfaces as its own row instead.
1396
1432
  const sessionFile = id.sessionId ? findSessionFileForKind(id.agent, cwd, id.sessionId) : undefined;
1397
1433
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
1398
- const pidAlive = pid ? isPidAlive(pid, liveEntry?.startedAtMs) : true;
1434
+ // `remain-on-exit` retains the pane after its child exits. pane_dead is the
1435
+ // authoritative signal; a retained pane is diagnostic, not attachable.
1436
+ const paneDead = paneDeadRaw === '1';
1437
+ const pidAlive = !paneDead && (pid ? isPidAlive(pid, liveEntry?.startedAtMs) : true);
1399
1438
  const { state, tokPerSec } = computeLiveSignals(id.agent, sessionFile, cwd, pidAlive);
1400
1439
  const { birthtimeMs, mtimeMs } = sessionFileTimes(sessionFile);
1401
1440
  // The mux/reply rails are known exactly here (the pane IS a tmux pane), so we
@@ -1620,7 +1659,9 @@ function foldPresence(rows) {
1620
1659
  * session that has a live pid. Mutates in place. Runs after dedupe so we probe
1621
1660
  * each session once, not once per fork pid. Probes run in parallel — each is a
1622
1661
  * single /proc read (Linux) or `ps` call (macOS); failures leave `provenance`
1623
- * undefined rather than blocking the listing.
1662
+ * undefined rather than blocking the listing. The probes use the same bounded
1663
+ * concurrency as the adjacent per-PID lsof sweep so large session lists cannot
1664
+ * spawn one `ps eww` subprocess per row at once on macOS.
1624
1665
  *
1625
1666
  * A row that already carries provenance (the tmux path, which knows its exact
1626
1667
  * mux/reply from the pane) is not skipped — it is probe-and-MERGED. The tmux
@@ -1630,11 +1671,11 @@ function foldPresence(rows) {
1630
1671
  * authoritative mux/reply the pane already gave us. Skipping this (the old
1631
1672
  * behavior) is exactly why ssh-launched tmux sessions rendered as local.
1632
1673
  */
1633
- async function enrichProvenance(sessions) {
1634
- await Promise.all(sessions.map(async (s) => {
1674
+ export async function enrichProvenance(sessions, probe = detectProvenance) {
1675
+ await mapBounded(sessions, async (s) => {
1635
1676
  if (!s.pid)
1636
1677
  return;
1637
- const probed = await detectProvenance(s.pid);
1678
+ const probed = await probe(s.pid);
1638
1679
  if (!probed)
1639
1680
  return;
1640
1681
  if (!s.provenance) {
@@ -1649,7 +1690,7 @@ async function enrichProvenance(sessions) {
1649
1690
  }
1650
1691
  if (probed.term && !s.provenance.term)
1651
1692
  s.provenance.term = probed.term;
1652
- }));
1693
+ }, { concurrency: LSOF_CONCURRENCY });
1653
1694
  }
1654
1695
  /**
1655
1696
  * Match an SSH client IP to a registered device (pure — testable with a plain
@@ -7,6 +7,8 @@ export interface SessionActorRecord {
7
7
  initiatedBy?: 'human' | 'agent';
8
8
  /** Effective permissions mode used by the launcher. */
9
9
  mode?: SessionRunMode;
10
+ /** Stable wrapper names that resolve to this native session id. */
11
+ aliases?: string[];
10
12
  startedAtMs: number;
11
13
  }
12
14
  /**
@@ -15,6 +17,18 @@ export interface SessionActorRecord {
15
17
  * No-ops without a concrete session id (nothing to key on).
16
18
  */
17
19
  export declare function writeSessionActorRecord(record: SessionActorRecord): void;
20
+ export declare function writeSessionAliasRecord(sessionId: string, alias: string): void;
21
+ export type SessionAliasResolution = {
22
+ kind: 'resolved';
23
+ sessionId: string;
24
+ } | {
25
+ kind: 'ambiguous';
26
+ sessionIds: string[];
27
+ } | {
28
+ kind: 'not-found';
29
+ };
30
+ /** Resolve an exact alias, or a unique prefix/suffix of at least six chars. */
31
+ export declare function resolveSessionAlias(selector: string): SessionAliasResolution;
18
32
  /** Read one session's actor record. Returns undefined if absent/corrupt. */
19
33
  export declare function readSessionActorRecord(sessionId: string): SessionActorRecord | undefined;
20
34
  /**
@@ -33,6 +33,25 @@ function isSafeSessionId(sessionId) {
33
33
  function recordPath(sessionId) {
34
34
  return path.join(sidecarDir(), `${sessionId}.json`);
35
35
  }
36
+ function isSafeAlias(alias) {
37
+ return /^ag-[a-z][a-z0-9-]*-[0-9a-f]{8}$/i.test(alias);
38
+ }
39
+ function hasRecordData(record) {
40
+ return typeof record.actor === 'string'
41
+ || typeof record.mode === 'string'
42
+ || (Array.isArray(record.aliases) && record.aliases.some(alias => typeof alias === 'string'));
43
+ }
44
+ function normalizedAliases(aliases) {
45
+ if (!Array.isArray(aliases))
46
+ return [];
47
+ return [...new Set(aliases
48
+ .filter((alias) => typeof alias === 'string' && isSafeAlias(alias))
49
+ .map(alias => alias.toLowerCase()))];
50
+ }
51
+ function writeRecord(record) {
52
+ fs.mkdirSync(sidecarDir(), { recursive: true });
53
+ fs.writeFileSync(recordPath(record.sessionId), JSON.stringify(record), 'utf8');
54
+ }
36
55
  /**
37
56
  * Record the actor a session was launched under. Never throws — the sidecar is
38
57
  * an attribution optimization; a session with no record simply scans unattributed.
@@ -42,13 +61,57 @@ export function writeSessionActorRecord(record) {
42
61
  if (!isSafeSessionId(record.sessionId))
43
62
  return;
44
63
  try {
45
- fs.mkdirSync(sidecarDir(), { recursive: true });
46
- fs.writeFileSync(recordPath(record.sessionId), JSON.stringify(record), 'utf8');
64
+ const previous = readSessionActorRecord(record.sessionId);
65
+ writeRecord({
66
+ ...previous,
67
+ ...record,
68
+ aliases: normalizedAliases([...(previous?.aliases ?? []), ...(record.aliases ?? [])]),
69
+ });
47
70
  }
48
71
  catch {
49
72
  /* degrade to an unattributed row */
50
73
  }
51
74
  }
75
+ export function writeSessionAliasRecord(sessionId, alias) {
76
+ if (!isSafeSessionId(sessionId) || !isSafeAlias(alias))
77
+ return;
78
+ try {
79
+ const previous = readSessionActorRecord(sessionId);
80
+ writeRecord({
81
+ sessionId,
82
+ actor: previous?.actor,
83
+ initiatedBy: previous?.initiatedBy,
84
+ mode: previous?.mode,
85
+ aliases: normalizedAliases([...(previous?.aliases ?? []), alias]),
86
+ startedAtMs: previous?.startedAtMs ?? Date.now(),
87
+ });
88
+ }
89
+ catch {
90
+ /* the native id remains usable */
91
+ }
92
+ }
93
+ /** Resolve an exact alias, or a unique prefix/suffix of at least six chars. */
94
+ export function resolveSessionAlias(selector) {
95
+ const normalized = selector.trim().toLowerCase();
96
+ if (!normalized)
97
+ return { kind: 'not-found' };
98
+ const exact = new Set();
99
+ const fuzzy = new Set();
100
+ for (const record of loadSessionActorIndex().values()) {
101
+ for (const alias of normalizedAliases(record.aliases)) {
102
+ if (alias === normalized)
103
+ exact.add(record.sessionId);
104
+ else if (normalized.length >= 6 && (alias.startsWith(normalized) || alias.endsWith(normalized)))
105
+ fuzzy.add(record.sessionId);
106
+ }
107
+ }
108
+ const matches = exact.size > 0 ? [...exact] : [...fuzzy];
109
+ if (matches.length === 0)
110
+ return { kind: 'not-found' };
111
+ if (matches.length > 1)
112
+ return { kind: 'ambiguous', sessionIds: matches.sort() };
113
+ return { kind: 'resolved', sessionId: matches[0] };
114
+ }
52
115
  /** Read one session's actor record. Returns undefined if absent/corrupt. */
53
116
  export function readSessionActorRecord(sessionId) {
54
117
  if (!isSafeSessionId(sessionId))
@@ -63,7 +126,7 @@ export function readSessionActorRecord(sessionId) {
63
126
  try {
64
127
  const parsed = JSON.parse(raw);
65
128
  if (parsed && typeof parsed === 'object' && typeof parsed.sessionId === 'string' &&
66
- (typeof parsed.actor === 'string' || typeof parsed.mode === 'string')) {
129
+ hasRecordData(parsed)) {
67
130
  return parsed;
68
131
  }
69
132
  }
@@ -91,7 +154,7 @@ export function loadSessionActorIndex() {
91
154
  try {
92
155
  const parsed = JSON.parse(fs.readFileSync(path.join(sidecarDir(), f), 'utf8'));
93
156
  if (parsed && typeof parsed === 'object' && typeof parsed.sessionId === 'string' &&
94
- (typeof parsed.actor === 'string' || typeof parsed.mode === 'string')) {
157
+ hasRecordData(parsed)) {
95
158
  out.set(parsed.sessionId, parsed);
96
159
  }
97
160
  }
@@ -12,7 +12,7 @@ import { type IndexedToolCall } from './tool-calls.js';
12
12
  /** Current schema version; bumped when migrations are added. Exported so tests
13
13
  * assert against the constant instead of hardcoding a number that every bump
14
14
  * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
15
- export declare const SCHEMA_VERSION = 33;
15
+ export declare const SCHEMA_VERSION = 34;
16
16
  /**
17
17
  * Bump to force `agents sessions backfill resources` to re-derive every
18
18
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -26,7 +26,7 @@ const DB_PATH = getSessionsDbPath();
26
26
  /** Current schema version; bumped when migrations are added. Exported so tests
27
27
  * assert against the constant instead of hardcoding a number that every bump
28
28
  * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
29
- export const SCHEMA_VERSION = 33;
29
+ export const SCHEMA_VERSION = 34;
30
30
  /**
31
31
  * Bump to force `agents sessions backfill resources` to re-derive every
32
32
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -779,6 +779,37 @@ function migrateSchema(db, fromVersion) {
779
779
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_account_key ON sessions(account_key)`);
780
780
  backfillClaudeAccounts(db);
781
781
  }
782
+ if (fromVersion < 34) {
783
+ // v33 -> v34: claude-opus-5 and claude-sonnet-5 were missing from the pricing
784
+ // table. `getModelPricing` matches on a dash-bounded prefix, so neither could fall
785
+ // back to its Claude 4 entry: both resolved to null and every session using them
786
+ // priced to $0 -- silently, because an unpriced model contributes nothing rather
787
+ // than raising. On one real index that was 526 sessions, 478 of them the current
788
+ // default model.
789
+ //
790
+ // Adding the prices alone fixes nothing already indexed: cost_usd is computed at
791
+ // scan time, and the scanner skips any transcript whose (file_mtime_ms, file_size)
792
+ // is unchanged. Nor can those rows be repaired in place -- the row stores
793
+ // token_count and output_tokens, not the uncached-input / cache-read / cache-write
794
+ // split the price table needs -- so the figure has to come from re-reading the
795
+ // transcript.
796
+ //
797
+ // Flush ONLY the affected transcripts, not the whole ledger. A blanket
798
+ // `DELETE FROM scan_ledger` (what v5 -> v6 did when cost was introduced) would
799
+ // re-parse every session on the next scan and break the contract the other
800
+ // migrations here are tested against: adding a column must not invalidate warm
801
+ // session ledgers. Scoping it to the rows that actually mispriced keeps every
802
+ // other ledger entry warm and still guarantees the numbers correct themselves.
803
+ db.exec(`
804
+ DELETE FROM scan_ledger
805
+ WHERE file_path IN (
806
+ SELECT file_path FROM sessions
807
+ WHERE cost_usd IS NULL
808
+ AND file_path IS NOT NULL AND file_path <> ''
809
+ AND (model LIKE 'claude-opus-5%' OR model LIKE 'claude-sonnet-5%')
810
+ );
811
+ `);
812
+ }
782
813
  }
783
814
  /**
784
815
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its