@phnx-labs/agents-cli 1.22.23 → 1.22.25

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 (143) hide show
  1. package/CHANGELOG.md +359 -0
  2. package/README.md +19 -8
  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/cloud.js +9 -5
  7. package/dist/commands/defaults.js +2 -0
  8. package/dist/commands/doctor.d.ts +24 -0
  9. package/dist/commands/doctor.js +105 -9
  10. package/dist/commands/exec.js +99 -44
  11. package/dist/commands/feed.d.ts +23 -0
  12. package/dist/commands/feed.js +65 -2
  13. package/dist/commands/focus.d.ts +34 -3
  14. package/dist/commands/focus.js +301 -57
  15. package/dist/commands/go.d.ts +14 -1
  16. package/dist/commands/go.js +49 -5
  17. package/dist/commands/insights.js +6 -2
  18. package/dist/commands/inspect.js +39 -5
  19. package/dist/commands/menubar.js +14 -1
  20. package/dist/commands/models.js +1 -0
  21. package/dist/commands/modes.d.ts +12 -0
  22. package/dist/commands/modes.js +147 -0
  23. package/dist/commands/routines.d.ts +3 -0
  24. package/dist/commands/routines.js +70 -47
  25. package/dist/commands/run-cloud.js +1 -1
  26. package/dist/commands/secrets-sync.js +11 -13
  27. package/dist/commands/secrets.d.ts +2 -0
  28. package/dist/commands/secrets.js +54 -17
  29. package/dist/commands/sessions-browser.d.ts +36 -1
  30. package/dist/commands/sessions-browser.js +165 -19
  31. package/dist/commands/sessions-resume.d.ts +2 -0
  32. package/dist/commands/sessions-resume.js +28 -12
  33. package/dist/commands/sessions.d.ts +23 -1
  34. package/dist/commands/sessions.js +58 -61
  35. package/dist/commands/setup-secrets.js +1 -1
  36. package/dist/commands/setup-watchdog.js +5 -10
  37. package/dist/commands/setup.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/teams.js +3 -2
  40. package/dist/commands/view.js +2 -0
  41. package/dist/commands/watchdog.d.ts +3 -4
  42. package/dist/commands/watchdog.js +26 -66
  43. package/dist/index.js +38 -2
  44. package/dist/lib/agent-modes.d.ts +49 -0
  45. package/dist/lib/agent-modes.js +70 -0
  46. package/dist/lib/agents.js +126 -21
  47. package/dist/lib/cloud/cursor.d.ts +79 -0
  48. package/dist/lib/cloud/cursor.js +228 -0
  49. package/dist/lib/cloud/registry.js +2 -0
  50. package/dist/lib/cloud/types.d.ts +7 -2
  51. package/dist/lib/cloud/types.js +14 -0
  52. package/dist/lib/crabbox/cli.d.ts +2 -2
  53. package/dist/lib/crabbox/config.d.ts +7 -8
  54. package/dist/lib/crabbox/config.js +14 -14
  55. package/dist/lib/crabbox/lease.d.ts +11 -4
  56. package/dist/lib/crabbox/lease.js +40 -8
  57. package/dist/lib/crabbox/setup-copy.d.ts +5 -0
  58. package/dist/lib/crabbox/setup-copy.js +17 -1
  59. package/dist/lib/daemon.js +27 -1
  60. package/dist/lib/device-config.js +7 -0
  61. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  62. package/dist/lib/devices/doctor-findings.js +40 -1
  63. package/dist/lib/doctor-diff.d.ts +3 -0
  64. package/dist/lib/doctor-diff.js +15 -13
  65. package/dist/lib/event-stream.d.ts +3 -1
  66. package/dist/lib/event-stream.js +14 -1
  67. package/dist/lib/events.d.ts +9 -0
  68. package/dist/lib/events.js +58 -0
  69. package/dist/lib/exec.d.ts +15 -3
  70. package/dist/lib/exec.js +112 -19
  71. package/dist/lib/feed-outcome.d.ts +3 -0
  72. package/dist/lib/feed-outcome.js +18 -10
  73. package/dist/lib/feed.d.ts +4 -0
  74. package/dist/lib/hooks/cache.js +36 -3
  75. package/dist/lib/hooks.d.ts +19 -7
  76. package/dist/lib/hooks.js +88 -38
  77. package/dist/lib/hosts/passthrough.d.ts +21 -0
  78. package/dist/lib/hosts/passthrough.js +39 -12
  79. package/dist/lib/hosts/session-index.d.ts +4 -0
  80. package/dist/lib/hosts/session-index.js +7 -0
  81. package/dist/lib/manifest.d.ts +12 -2
  82. package/dist/lib/manifest.js +60 -5
  83. package/dist/lib/mcp.js +5 -1
  84. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  85. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
  86. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  87. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  88. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  89. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  90. package/dist/lib/menubar/install-menubar.js +128 -6
  91. package/dist/lib/menubar/snapshot.d.ts +15 -0
  92. package/dist/lib/menubar/snapshot.js +40 -0
  93. package/dist/lib/plugins.js +13 -1
  94. package/dist/lib/refresh.d.ts +5 -0
  95. package/dist/lib/refresh.js +37 -33
  96. package/dist/lib/resource-inventory.d.ts +79 -0
  97. package/dist/lib/resource-inventory.js +122 -0
  98. package/dist/lib/resources/mcp.js +3 -0
  99. package/dist/lib/resources.js +8 -5
  100. package/dist/lib/routine-process-cleanup.d.ts +9 -0
  101. package/dist/lib/routine-process-cleanup.js +73 -0
  102. package/dist/lib/run-defaults.d.ts +2 -0
  103. package/dist/lib/run-defaults.js +23 -2
  104. package/dist/lib/runner.js +27 -22
  105. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  106. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  107. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  108. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  109. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  110. package/dist/lib/secrets/bundles.js +158 -85
  111. package/dist/lib/secrets/filestore.d.ts +7 -3
  112. package/dist/lib/secrets/filestore.js +25 -8
  113. package/dist/lib/secrets/index.js +34 -8
  114. package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
  115. package/dist/lib/secrets/rc-hygiene.js +14 -1
  116. package/dist/lib/secrets/reaper.d.ts +10 -0
  117. package/dist/lib/secrets/reaper.js +50 -15
  118. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  119. package/dist/lib/secrets/sync-passphrase.js +78 -0
  120. package/dist/lib/session/active.d.ts +4 -0
  121. package/dist/lib/session/db.js +8 -2
  122. package/dist/lib/session/recovery.d.ts +37 -0
  123. package/dist/lib/session/recovery.js +95 -0
  124. package/dist/lib/session/remote-list.d.ts +2 -0
  125. package/dist/lib/session/remote-list.js +1 -0
  126. package/dist/lib/session/session-cache.d.ts +4 -4
  127. package/dist/lib/session/session-cache.js +4 -4
  128. package/dist/lib/shims.d.ts +2 -2
  129. package/dist/lib/shims.js +45 -6
  130. package/dist/lib/signin-badge.js +2 -0
  131. package/dist/lib/startup/command-registry.d.ts +16 -0
  132. package/dist/lib/startup/command-registry.js +44 -0
  133. package/dist/lib/sync-umbrella.d.ts +5 -0
  134. package/dist/lib/sync-umbrella.js +5 -4
  135. package/dist/lib/teams/agents.js +1 -1
  136. package/dist/lib/teams/parsers.d.ts +1 -1
  137. package/dist/lib/tmux/session.d.ts +4 -2
  138. package/dist/lib/tmux/session.js +5 -5
  139. package/dist/lib/types.d.ts +3 -1
  140. package/dist/lib/versions.js +16 -1
  141. package/dist/lib/watchdog/service.d.ts +17 -0
  142. package/dist/lib/watchdog/service.js +39 -0
  143. package/package.json +1 -1
@@ -22,11 +22,35 @@
22
22
  * apply pending sync.
23
23
  */
24
24
  import type { Command } from 'commander';
25
+ import { type DoctorFinding } from '../lib/devices/doctor-findings.js';
25
26
  import { type VersionResourceReport } from '../lib/doctor-diff.js';
26
27
  import { type DuplicateVersionHook } from '../lib/hooks.js';
27
28
  import { type SyncStatusRow, type OrphanRow } from '../lib/drift.js';
28
29
  import { type FetchStatusMarker } from '../lib/auto-pull.js';
29
30
  export declare function wrapLine(prefix: string, text: string, width?: number): string[];
31
+ export declare function asRemoteSecretFindings(raw: unknown, device: string): DoctorFinding[];
32
+ /**
33
+ * Fetch a device's harness inventory by running its top-level `agents doctor
34
+ * --json` (which carries the `fleet` field, RUSH-2027). Separate from
35
+ * {@link probeFleetTarget} — that forwards `teams doctor --json` for per-agent
36
+ * readiness, a different payload. Returns the parsed {@link FleetInventory} or
37
+ * null (unreachable, non-zero exit, unparseable, or an older CLI with no
38
+ * `fleet` field); a null is skipped by the comparator, never a false gap.
39
+ */
40
+ /**
41
+ * How long one remote `doctor --json` probe may take.
42
+ *
43
+ * 180s, matching `ChildProcess.doctorTimeout`, which was set to 180 for this
44
+ * same command for this same reason. The previous 30s sat BELOW the command's
45
+ * real cost — 56s measured on `yosemite-m0`, 136s on an idle box — so every slow
46
+ * device silently contributed nothing at all: no inventory, no sign-in, no
47
+ * divergence, no secret findings. A ceiling under the true cost does not make a
48
+ * probe cheap, it makes it always fail.
49
+ *
50
+ * This does not multiply by device count: `fanOutDevices` is `Promise.all`, so
51
+ * whole-fleet wall time is bounded near the slowest box, not 180s x N.
52
+ */
53
+ export declare const FLEET_INVENTORY_TIMEOUT_MS = 180000;
30
54
  export type IssueSeverity = 'critical' | 'warning' | 'info';
31
55
  /**
32
56
  * One triaged health finding. `severity`/`category`/`subject`/`impact`/`fix` are
@@ -8,7 +8,7 @@ import { enterDoctorOverviewGate, writeDoctorOverviewCache } from '../lib/device
8
8
  import { fleetDialTarget } from '../lib/devices/connect.js';
9
9
  import { compareFleetInventories } from '../lib/devices/fleet-divergence.js';
10
10
  import { collectLocalFleetInventory } from '../lib/devices/fleet-inventory.js';
11
- import { buildLocalFindings, fleetDivergenceToFindings, signInToFindings, renderFindings, } from '../lib/devices/doctor-findings.js';
11
+ import { buildLocalFindings, fleetDivergenceToFindings, signInToFindings, renderFindings, remediationFor, FINDING_SEVERITY, } from '../lib/devices/doctor-findings.js';
12
12
  import { getCliVersion } from '../lib/version.js';
13
13
  import { resolveHost } from '../lib/hosts/registry.js';
14
14
  import { sshExecAsync } from '../lib/ssh-exec.js';
@@ -31,7 +31,7 @@ import { listCliStatus, listCliStatusAsync } from '../lib/cli-resources.js';
31
31
  import { setHelpSections } from '../lib/help.js';
32
32
  import { heal, healChangedAnything } from '../lib/heal.js';
33
33
  import { getEffectiveExecutionPolicy } from '../lib/platform/winpath.js';
34
- import { scanUserRcFiles } from '../lib/secrets/rc-hygiene.js';
34
+ import { scanUserRcFiles, masterPassphraseInEnv } from '../lib/secrets/rc-hygiene.js';
35
35
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
36
36
  import { readRepoBehindMarkers } from '../lib/auto-pull.js';
37
37
  import * as fs from 'fs';
@@ -80,6 +80,70 @@ function printWrappedLine(prefix, text) {
80
80
  for (const line of wrapLine(prefix, text))
81
81
  console.log(chalk.gray(line));
82
82
  }
83
+ /**
84
+ * Narrow a remote `findings` array to the secret-hygiene rows, or `[]`.
85
+ *
86
+ * Deliberately NOT "forward every remote finding". The aggregator already
87
+ * rebuilds a remote's sign-in rows from its inventory and its divergence rows
88
+ * from the comparator, so forwarding wholesale would double them; and pulling a
89
+ * remote's orphan/drift rows into a fleet readout is a much larger UX change
90
+ * than this fix. The two secret kinds are the ones that are BOTH unrecomputable
91
+ * centrally and security-relevant, which is exactly why they were being lost.
92
+ *
93
+ * **The remote contributes exactly one thing: the KIND.** Severity, message and
94
+ * remediation are all generated HERE. That is not defensiveness for its own
95
+ * sake — the remote runs its own agents-cli, whose version and integrity we do
96
+ * not control, and each forwarded string is load-bearing in a different way:
97
+ *
98
+ * - `remediation` is a command a human copies and runs. Accepting it from the
99
+ * wire is an injection channel, full stop.
100
+ * - `message` is the one place a secret VALUE could re-enter a readout that
101
+ * otherwise guarantees it never prints one. A local builder cannot leak what
102
+ * it never receives.
103
+ * - `severity` decides the CRITICAL section, so a remote could otherwise
104
+ * promote its own warning and bury real criticals under it.
105
+ * - `device` is overwritten with the name we dialled, so no box can pin a
106
+ * finding on another.
107
+ *
108
+ * The cost is detail: the fleet row says which box and which kind, not which
109
+ * file and line. Run `agents doctor` on that box for the specifics — the
110
+ * message says so.
111
+ */
112
+ const REMOTE_FORWARDED_KINDS = ['rc-secret-export', 'env-secret-export'];
113
+ /** Canonical, locally-authored text for a forwarded kind. Never the remote's. */
114
+ const REMOTE_SECRET_MESSAGE = {
115
+ 'rc-secret-export': 'a credential-shaped export was found in this box\'s shell rc files'
116
+ + ' — run `agents doctor` there for the file and line',
117
+ 'env-secret-export': 'AGENTS_SECRETS_PASSPHRASE is set in this box\'s process environment'
118
+ + ' — run `agents doctor` there for detail',
119
+ };
120
+ export function asRemoteSecretFindings(raw, device) {
121
+ if (!Array.isArray(raw))
122
+ return [];
123
+ const seen = new Set();
124
+ const out = [];
125
+ for (const item of raw) {
126
+ if (!item || typeof item !== 'object' || Array.isArray(item))
127
+ continue;
128
+ const kind = item.kind;
129
+ if (typeof kind !== 'string')
130
+ continue;
131
+ if (!REMOTE_FORWARDED_KINDS.includes(kind))
132
+ continue;
133
+ if (seen.has(kind))
134
+ continue; // one row per kind per box
135
+ seen.add(kind);
136
+ const k = kind;
137
+ const base = {
138
+ severity: FINDING_SEVERITY[k],
139
+ kind: k,
140
+ device,
141
+ message: REMOTE_SECRET_MESSAGE[k],
142
+ };
143
+ out.push({ ...base, remediation: remediationFor({ ...base, remediation: '' }) });
144
+ }
145
+ return out;
146
+ }
83
147
  async function resolveFleetTargets(opts) {
84
148
  const singleName = opts.host || opts.device;
85
149
  if (singleName) {
@@ -155,15 +219,32 @@ async function probeFleetTarget(target) {
155
219
  * null (unreachable, non-zero exit, unparseable, or an older CLI with no
156
220
  * `fleet` field); a null is skipped by the comparator, never a false gap.
157
221
  */
222
+ /**
223
+ * How long one remote `doctor --json` probe may take.
224
+ *
225
+ * 180s, matching `ChildProcess.doctorTimeout`, which was set to 180 for this
226
+ * same command for this same reason. The previous 30s sat BELOW the command's
227
+ * real cost — 56s measured on `yosemite-m0`, 136s on an idle box — so every slow
228
+ * device silently contributed nothing at all: no inventory, no sign-in, no
229
+ * divergence, no secret findings. A ceiling under the true cost does not make a
230
+ * probe cheap, it makes it always fail.
231
+ *
232
+ * This does not multiply by device count: `fanOutDevices` is `Promise.all`, so
233
+ * whole-fleet wall time is bounded near the slowest box, not 180s x N.
234
+ */
235
+ export const FLEET_INVENTORY_TIMEOUT_MS = 180_000;
158
236
  async function probeFleetInventory(target) {
159
237
  const isWin = /^win/i.test((target.os ?? '').trim());
160
238
  const remoteCmd = buildRemoteAgentsInvocation(['doctor', '--json'], undefined, isWin ? 'windows' : undefined, isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
161
- const res = await sshExecAsync(target.sshTarget, remoteCmd, { timeoutMs: 30000, multiplex: true });
239
+ const res = await sshExecAsync(target.sshTarget, remoteCmd, { timeoutMs: FLEET_INVENTORY_TIMEOUT_MS, multiplex: true });
162
240
  if (res.code !== 0)
163
241
  return null;
164
242
  try {
165
243
  const parsed = JSON.parse(res.stdout);
166
- return asFleetInventory(parsed.fleet);
244
+ return {
245
+ inventory: asFleetInventory(parsed.fleet),
246
+ secretFindings: asRemoteSecretFindings(parsed.findings, target.name),
247
+ };
167
248
  }
168
249
  catch {
169
250
  return null;
@@ -252,19 +333,22 @@ async function runDevicesDoctor(opts) {
252
333
  fanOutDevices(targets, probeFleetTarget),
253
334
  fanOutDevices(targets, probeFleetInventory),
254
335
  ]);
255
- const inventoryByName = new Map();
336
+ const payloadByName = new Map();
256
337
  for (const r of inventoryResults)
257
- inventoryByName.set(r.name, r.status === 'ok' ? (r.value ?? null) : null);
338
+ payloadByName.set(r.name, r.status === 'ok' ? (r.value ?? null) : null);
258
339
  results.push(...remoteResults.map((r) => {
259
- const inventory = inventoryByName.get(r.name) ?? undefined;
340
+ const payload = payloadByName.get(r.name) ?? null;
341
+ const inventory = payload?.inventory ?? undefined;
342
+ const secretFindings = payload?.secretFindings;
260
343
  if (r.status === 'ok' && r.value)
261
- return { ...r.value, inventory: inventory ?? undefined };
344
+ return { ...r.value, inventory, secretFindings };
262
345
  return {
263
346
  name: r.name,
264
347
  online: false,
265
348
  error: r.error ?? String(r.reason ?? 'skipped'),
266
349
  agents: {},
267
- inventory: inventory ?? undefined,
350
+ inventory,
351
+ secretFindings,
268
352
  };
269
353
  }));
270
354
  // Cross-device divergence: compare every device's inventory against the local
@@ -314,6 +398,7 @@ async function runDevicesDoctor(opts) {
314
398
  duplicateHooks: inspectDuplicateVersionHooks(cwd),
315
399
  hostClis: toHostCliInput(listCliStatus(cwd)),
316
400
  rcSecrets: scanUserRcFiles(),
401
+ masterPassphraseInEnv: masterPassphraseInEnv(),
317
402
  execPolicy: process.platform === 'win32'
318
403
  ? { platform: process.platform, policy: getEffectiveExecutionPolicy() }
319
404
  : undefined,
@@ -335,6 +420,11 @@ async function runDevicesDoctor(opts) {
335
420
  });
336
421
  continue;
337
422
  }
423
+ // Secret hygiene the aggregator cannot see for itself — the remote's shell
424
+ // rc files and its process environment. Without this the fleet readout
425
+ // reports a leaking box as clean (RUSH-1968).
426
+ if (r.secretFindings?.length)
427
+ findings.push(...r.secretFindings);
338
428
  if (r.inventory?.signIn) {
339
429
  findings.push(...signInToFindings(r.name, r.inventory.signIn));
340
430
  accounts[r.name] = r.inventory.signIn;
@@ -848,6 +938,11 @@ function renderTargetText(report, options) {
848
938
  // sees what was checked. options.requestedKinds drives this.
849
939
  if (options.requestedKinds && !options.requestedKinds.has(kind))
850
940
  continue;
941
+ if (kind === 'hooks' && report.hookInventory) {
942
+ const wired = report.hookInventory.wiringSupported ? String(report.hookInventory.wired.length) : 'unknown';
943
+ const unmanaged = report.hookInventory.unmanaged.length > 0 ? ` · unmanaged ${report.hookInventory.unmanaged.length}` : '';
944
+ console.log(chalk.gray(` inventory: capable ${report.hookInventory.capable ? 'yes' : 'no'} · on-disk ${report.hookInventory.onDisk.length} · wired ${wired}${unmanaged}`));
945
+ }
851
946
  renderKindSection(kind, rows, report.layers, options);
852
947
  // A hook file can reconcile "ok" above yet be absent from settings.json — a
853
948
  // present-but-dead hook. Surface that right under the hooks section.
@@ -1384,6 +1479,7 @@ export function registerDoctorCommand(program) {
1384
1479
  duplicateHooks,
1385
1480
  hostClis: toHostCliInput(hostClis),
1386
1481
  rcSecrets: scanUserRcFiles(),
1482
+ masterPassphraseInEnv: masterPassphraseInEnv(),
1387
1483
  // getEffectiveExecutionPolicy spawns powershell — a doomed process on
1388
1484
  // POSIX, where the advisory never applies. Probe only on Windows.
1389
1485
  execPolicy: process.platform === 'win32'
@@ -532,7 +532,7 @@ export function registerRunCommand(program) {
532
532
  .option('--headless', 'Force headless mode. Auto-enabled when a prompt is provided; pass explicitly to stay headless with no prompt (reads the prompt from stdin).', false)
533
533
  .option('--no-auth-check', 'Skip the pre-launch "looks logged out" warning on an interactive run (advisory; never blocks anyway). Also silenced by AGENTS_NO_AUTH_CHECK=1.')
534
534
  .option('-i, --interactive', 'Force interactive mode even when a prompt is provided. Mutually exclusive with --headless.')
535
- .option('--resume [id]', 'Resume a previous conversation. Full IDs resolve locally first, then fleet-wide. Claude, Codex, Grok, Kimi, Droid, and Cursor use version-gated native resume; other agents replay via /continue. Pair with a prompt to continue headlessly.')
535
+ .option('--resume [id]', 'Recover a previous conversation on its origin device. The exact healthy origin uses native resume; otherwise a healthy version of the same harness replays via /continue. Pair with a prompt to continue headlessly.')
536
536
  .option('--session-id <id>', 'Force a NEW conversation to use this exact session UUID (Claude only). This CREATES a session — to resume an existing one, use --resume.')
537
537
  .option('--name <slug>', 'Name the run — seeds the session label so it shows up as `<name>` in `agents sessions` and resolves by it (and `agents hosts logs <name>` for --host runs) instead of an opaque id. An agent-generated title later refines the label; your name shows until then. Optional.')
538
538
  .option('--notify', 'Post a desktop notification when a headless run finishes. Fired by this process on exit, so it survives whatever launched the run (the menu bar dispatching it, a terminal you closed).')
@@ -568,8 +568,8 @@ export function registerRunCommand(program) {
568
568
  .option('--bare', 'With --lease, skip copying your local ~/.agents setup (skills/hooks/commands/MCP) onto the box.')
569
569
  .option('--tailscale', 'Lease the box onto your tailnet (reachable only over Tailscale) rather than a public IP.')
570
570
  .option('--no-tailscale', 'Force a public-IP lease even when a reuse context would default to Tailscale.')
571
- .option('--cloud', 'Vendor cloud placement: dispatch to the agent\'s native cloud (claude→rush, codex→codex, droid→factory, antigravity→antigravity) and stream the result. Same dispatch as `agents cloud run --agent <agent>`; tracked by `agents cloud list/status/logs`. Same as --where cloud. Mutually exclusive with --host/--lease and local-run flags.')
572
- .option('--provider <id>', 'With --cloud: override the agent\'s native cloud provider (rush | codex | factory | antigravity | host).')
571
+ .option('--cloud', 'Vendor cloud placement: dispatch to the agent\'s native cloud (claude→rush, codex→codex, cursor→cursor, droid→factory, antigravity→antigravity) and stream the result. Same dispatch as `agents cloud run --agent <agent>`; tracked by `agents cloud list/status/logs`. Same as --where cloud. Mutually exclusive with --host/--lease and local-run flags.')
572
+ .option('--provider <id>', 'With --cloud: override the agent\'s native cloud provider (rush | codex | cursor | factory | antigravity | host).')
573
573
  .option('--repo <owner/repo>', 'With --cloud: GitHub repository. Repeatable for multi-repo dispatch (Rush Cloud only).', (val, prev) => [...prev, val], [])
574
574
  .option('--branch <name>', 'With --cloud: target git branch.')
575
575
  .option('--cloud-env <id>', 'With --cloud: Codex Cloud environment ID (run\'s --env is the KEY=VAL passthrough, so the cloud env id gets its own flag).');
@@ -647,16 +647,18 @@ export function registerRunCommand(program) {
647
647
  agents run claude "fix the bug" -- --custom-flag
648
648
  `,
649
649
  notes: `
650
- Modes (not every agent supports every mode — check agents.yaml capabilities):
650
+ Modes (not every agent supports every mode — run \`agents modes <agent>\`):
651
651
  plan read-only investigation; no writes, no shell side-effects
652
652
  edit may edit files; prompts for shell / risky operations
653
653
  auto smart classifier auto-approves safe ops, prompts for risky (claude, copilot)
654
654
  skip bypass every permission prompt (dangerously-skip-permissions)
655
655
  Legacy 'full' is silently rewritten to 'skip'.
656
+ List per-harness support + native flags: agents modes · agents modes claude
657
+ Models (cheap|default|best|ultra): agents models <agent[@version]>
656
658
 
657
659
  Headless plan support (a prompt makes the run headless):
658
- plan works headless on claude, codex, droid, opencode.
659
- kimi, grok, cursor, antigravity have no headless plan mode — a headless
660
+ plan works headless on claude, codex, cursor, droid, opencode.
661
+ kimi, grok, antigravity have no headless plan mode — a headless
660
662
  --mode plan auto-downgrades to --mode auto (with a stderr warning).
661
663
  Interactive plan (omit the prompt) works everywhere it is listed.
662
664
 
@@ -689,7 +691,7 @@ export function registerRunCommand(program) {
689
691
  given. --cloud is mutually exclusive with --host/--lease and with
690
692
  local-run flags (--loop, --resume, --secrets, --terminal, …).
691
693
 
692
- Resume: --resume <id> resolves full IDs locally first, then fleet-wide, and restores the source version/device/mode. Claude, Codex, Grok, Kimi, Droid, and Cursor use version-gated native resume; others replay via /continue. agents resume <id> infers the harness too.
694
+ Resume: --resume <id> resolves full IDs locally first, then fleet-wide, and recovers on the source device with its cwd/mode. The exact healthy origin version uses native resume; otherwise a healthy version of the same harness replays via /continue. agents resume <id> infers the harness too.
693
695
 
694
696
  Passthrough: everything after -- is forwarded verbatim to the underlying agent CLI.
695
697
  agents run kimi -- --plan --some-native-flag value
@@ -835,6 +837,7 @@ export function registerRunCommand(program) {
835
837
  }
836
838
  let autoHarnessRequested = normalizedAgentSpec === RUN_AUTO_KEYWORD;
837
839
  let resolvedResumeSource;
840
+ let resolvedRecoveryTarget;
838
841
  // Concrete resume ids resolve BEFORE placement. Full UUIDs take the local
839
842
  // SQLite fast path; only a local miss fans out to the fleet. This lets a
840
843
  // command entered on zion discover that the owning version-home is on a
@@ -890,32 +893,40 @@ export function registerRunCommand(program) {
890
893
  process.stderr.write(chalk.yellow(`[agents] session ${resolvedResumeSource.shortId} predates stored launch modes; using --mode ${options.mode}\n`));
891
894
  }
892
895
  const { machineId } = await import('../lib/machine-id.js');
896
+ const { sessionRecoveryDestinationMatches, sessionRecoveryPeer, } = await import('../lib/session/recovery.js');
893
897
  const sourceMachine = resolvedResumeSource.machine;
898
+ const sourcePeer = sessionRecoveryPeer(resolvedResumeSource);
894
899
  const explicitPlacement = hostTargetGiven(options).length > 0;
895
- if (sourceMachine && sourceMachine !== machineId() && !explicitPlacement) {
896
- options.host = sourceMachine;
900
+ if (sourcePeer && !explicitPlacement) {
901
+ options.host = sourcePeer;
897
902
  }
898
- else if (!autoHarnessRequested && sourceMachine && sourceMachine !== machineId() && explicitPlacement && !hostTargetGiven(options).includes(sourceMachine)) {
899
- console.error(chalk.red(`Strict resume must run on ${sourceMachine}, where session ${resolvedResumeSource.shortId} is owned. ` +
900
- `Use agents run auto --resume ${resolvedResumeSource.id} to hand off elsewhere.`));
903
+ else if (sourcePeer && explicitPlacement && !hostTargetGiven(options).some((host) => sessionRecoveryDestinationMatches(resolvedResumeSource, host))) {
904
+ console.error(chalk.red(`Session ${resolvedResumeSource.shortId} must recover on ${sourcePeer}, where its indexed transcript and version history are owned; ` +
905
+ `the requested device was ${hostTargetGiven(options).join(', ')}.`));
901
906
  process.exit(1);
902
907
  }
903
- // On the owning machine, `auto` first asks the existing account router
904
- // whether the exact source version is healthy. If it is, native resume
905
- // wins. If not, keep `auto` so the normal harness/account router selects
906
- // a healthy local target and the later resume block performs /continue.
907
- if (autoHarnessRequested && (!sourceMachine || sourceMachine === machineId())) {
908
- const sourceAgent = resolvedResumeSource.agent;
909
- if (sourceAgent in AGENTS && resolvedResumeSource.version) {
910
- const { resolveRunVersion } = await import('../lib/rotate.js');
911
- const health = await resolveRunVersion(sourceAgent, 'available', resolvedResumeSource.cwd ?? process.cwd());
912
- if (!health.exhausted && health.version === resolvedResumeSource.version) {
913
- normalizedAgentSpec = `${sourceAgent}@${resolvedResumeSource.version}`;
914
- autoHarnessRequested = false;
915
- if (!options.quiet)
916
- process.stderr.write(chalk.gray(`[agents] auto resume → native ${normalizedAgentSpec} on ${sourceMachine ?? machineId()}\n`));
917
- }
908
+ // Recovery is resolved on the device that owns the transcript. A remote
909
+ // dispatch is pinned to the source HARNESS (not `run auto`'s cross-harness
910
+ // picker); the peer repeats this block with the injected SessionMeta and
911
+ // chooses its own healthy version. Locally, resolve it now.
912
+ const sourceAgent = resolvedResumeSource.agent;
913
+ if (!sourcePeer) {
914
+ try {
915
+ const { resolveSessionRecovery } = await import('../lib/session/recovery.js');
916
+ resolvedRecoveryTarget = await resolveSessionRecovery(resolvedResumeSource);
917
+ }
918
+ catch (err) {
919
+ console.error(chalk.red(err.message));
920
+ process.exit(1);
918
921
  }
922
+ normalizedAgentSpec = `${resolvedRecoveryTarget.agent}@${resolvedRecoveryTarget.version}`;
923
+ autoHarnessRequested = false;
924
+ if (!options.quiet)
925
+ process.stderr.write(chalk.gray(`[agents] session recovery → ${resolvedRecoveryTarget.mode} ${normalizedAgentSpec} on ${sourceMachine ?? machineId()} · ${resolvedRecoveryTarget.reason}\n`));
926
+ }
927
+ else if (autoHarnessRequested) {
928
+ normalizedAgentSpec = sourceAgent;
929
+ autoHarnessRequested = false;
919
930
  }
920
931
  }
921
932
  if (autoHarnessRequested) {
@@ -1008,19 +1019,21 @@ export function registerRunCommand(program) {
1008
1019
  const leaseSecretsBundle = process.env.AGENTS_LEASE_SECRETS_BUNDLE;
1009
1020
  const nowSecs = Math.floor(Date.now() / 1000);
1010
1021
  let reuseSlug = options.box;
1011
- // The profile this run's pool/box carries: the repo's .crabbox.yaml
1012
- // `profile:` when declared (what crabbox warmup would label a fresh box
1013
- // with), else crabbox's default. Passing it to leaseAndRun makes the
1014
- // pool-reuse match interchangeable with a fresh warmup.
1022
+ // Lease runs share one default warm pool across repositories. The generic
1023
+ // `.crabbox.yaml profile:` remains available to repo sandbox/CI scripts;
1024
+ // `leaseProfile:` is the explicit opt-in for a dedicated lease hot box.
1015
1025
  const repoRoot = gitToplevel(leaseCwd);
1016
- const { readCrabboxRepoProfile } = await import('../lib/crabbox/config.js');
1017
- const poolProfile = repoRoot ? readCrabboxRepoProfile(repoRoot) : undefined;
1026
+ const { readCrabboxLeaseProfile } = await import('../lib/crabbox/config.js');
1027
+ const poolProfile = repoRoot ? readCrabboxLeaseProfile(repoRoot) : 'default';
1018
1028
  if (options.lease && !reuseSlug && !options.fresh) {
1019
- const { crabboxList } = await import('../lib/crabbox/cli.js');
1020
- const { reusableBoxes, formatBoxRow } = await import('./lease.js');
1029
+ const { crabboxList, poolReusableBoxes } = await import('../lib/crabbox/cli.js');
1030
+ const { formatBoxRow } = await import('./lease.js');
1021
1031
  let warm = [];
1022
1032
  try {
1023
- warm = reusableBoxes(crabboxList({ secretsBundle: leaseSecretsBundle }), nowSecs);
1033
+ warm = poolReusableBoxes(crabboxList({ secretsBundle: leaseSecretsBundle }), {
1034
+ profile: poolProfile,
1035
+ nowSecs,
1036
+ });
1024
1037
  }
1025
1038
  catch {
1026
1039
  warm = []; // crabbox unavailable / no creds → the pool check in leaseAndRun decides
@@ -1091,7 +1104,7 @@ export function registerRunCommand(program) {
1091
1104
  }
1092
1105
  }
1093
1106
  const { detectSignedInRuntimes, resolveClaudeCredentialsBlob, inferLeaseRuntime, profileNeedsBaseRuntimeCredentials } = await import('../lib/crabbox/runtimes.js');
1094
- const { leaseAndRun } = await import('../lib/crabbox/lease.js');
1107
+ const { leaseAndRun, leaseWorkspaceId } = await import('../lib/crabbox/lease.js');
1095
1108
  const { boxAddress } = await import('./lease.js');
1096
1109
  const { getConfiguredRunStrategy, resolveRunVersion } = await import('../lib/rotate.js');
1097
1110
  const { profileExists, readProfile, resolveProfileEnv } = await import('../lib/profiles.js');
@@ -1186,7 +1199,7 @@ export function registerRunCommand(program) {
1186
1199
  ? 'the box is kept after the run'
1187
1200
  : options.fresh
1188
1201
  ? 'the box is destroyed after the run'
1189
- : 'a fresh box is destroyed after the run; a reused pool box is kept';
1202
+ : 'the shared-pool box is kept after the run';
1190
1203
  console.error(chalk.gray(`${boxLifecycle} · shipping ${whatShips}${credentialRuntimes.length > 0 && leaseEmail ? ` (${leaseEmail})` : ''}; ${boxAfterRun}.`));
1191
1204
  // Read the Claude OAuth token from the local Keychain (silent) so it can be
1192
1205
  // written to ~/.claude/.credentials.json on the box — otherwise Claude boots
@@ -1268,6 +1281,7 @@ export function registerRunCommand(program) {
1268
1281
  reuseBox: reuseSlug,
1269
1282
  fresh: options.fresh,
1270
1283
  profile: poolProfile,
1284
+ workspaceId: leaseWorkspaceId(repoRoot ?? leaseCwd),
1271
1285
  copySetup,
1272
1286
  netMode,
1273
1287
  onData: (chunk) => router.push(chunk),
@@ -2217,10 +2231,44 @@ export function registerRunCommand(program) {
2217
2231
  session = picked.session;
2218
2232
  forceInteractive = true; // bare resume always lands in the agent's TUI
2219
2233
  }
2220
- // Native resume is valid only for the source harness + exact isolated
2221
- // version. `auto` may have selected another healthy harness/account; in
2222
- // that case keep the target version and hand off through /continue.
2223
- const canResumeNatively = session.agent === agent && nativeResume(agent, session.version);
2234
+ // Bare `run <harness> --resume` learns the chosen SessionMeta only after
2235
+ // the host-placement phase above. If the picker chose a synced session
2236
+ // from another device, route the recovery command now; resolving local
2237
+ // candidates would otherwise native-resume through this device's
2238
+ // unrelated isolated home.
2239
+ if (!resolvedResumeSource) {
2240
+ const { sessionRecoveryPeer, sessionRecoveryRunArgs, } = await import('../lib/session/recovery.js');
2241
+ const peer = sessionRecoveryPeer(session);
2242
+ if (peer) {
2243
+ const { runOnPeer } = await import('../lib/session/remote-list.js');
2244
+ const routed = await runOnPeer(sessionRecoveryRunArgs(session), peer, { tty: true });
2245
+ if (routed === 'no-target') {
2246
+ console.error(chalk.red(`Cannot recover session ${session.shortId}: origin device ${peer} is not a registered reachable peer.`));
2247
+ process.exitCode = 1;
2248
+ }
2249
+ return;
2250
+ }
2251
+ }
2252
+ // Bare interactive --resume selects the SessionMeta only down here, so
2253
+ // it has not gone through the early concrete-id resolver. Resolve it now;
2254
+ // concrete ids reuse the exact same target chosen above.
2255
+ if (!resolvedRecoveryTarget) {
2256
+ try {
2257
+ const { resolveSessionRecovery } = await import('../lib/session/recovery.js');
2258
+ resolvedRecoveryTarget = await resolveSessionRecovery(session);
2259
+ }
2260
+ catch (err) {
2261
+ console.error(chalk.red(err.message));
2262
+ process.exit(1);
2263
+ }
2264
+ }
2265
+ if (resolvedRecoveryTarget.agent !== agent) {
2266
+ console.error(chalk.red(`Session ${session.shortId} belongs to ${resolvedRecoveryTarget.agent}, not ${agent}. ` +
2267
+ `Use: agents run auto --resume ${session.id}`));
2268
+ process.exit(1);
2269
+ }
2270
+ version = resolvedRecoveryTarget.version;
2271
+ const canResumeNatively = resolvedRecoveryTarget.mode === 'native';
2224
2272
  if (canResumeNatively) {
2225
2273
  version = session.version;
2226
2274
  resumeNative = true;
@@ -2330,6 +2378,12 @@ export function registerRunCommand(program) {
2330
2378
  console.error(chalk.red(`agents: ${agent}@${launchTarget} is not runnable and could not be repaired. Try: ${hint}`));
2331
2379
  process.exit(1);
2332
2380
  }
2381
+ if (resolvedRecoveryTarget && healed !== launchTarget) {
2382
+ console.error(chalk.red(`agents: session recovery target ${agent}@${launchTarget} became unavailable on ` +
2383
+ `${resolvedResumeSource?.machine ?? 'this device'}; refusing to resume through another version home. ` +
2384
+ `Retry the command so recovery can select a healthy ${agent} version.`));
2385
+ process.exit(1);
2386
+ }
2333
2387
  // Always adopt the healed version explicitly. In the version-undefined
2334
2388
  // path a fallback re-pins the GLOBAL default, but `resolveVersion`
2335
2389
  // prefers a PROJECT pin — so leaving `version` undefined would let the
@@ -2404,7 +2458,7 @@ export function registerRunCommand(program) {
2404
2458
  process.exit(1);
2405
2459
  }
2406
2460
  // Default CLI mode is the generic 'plan'. Agents without a read-only
2407
- // mode (antigravity, cursor, kiro, …) degrade via resolveMode to their
2461
+ // mode (antigravity, kiro, …) degrade via resolveMode to their
2408
2462
  // safest native mode (modes[0], typically edit). That covers both the
2409
2463
  // implicit default and an explicit `--mode plan`, so multi-agent
2410
2464
  // scripts can pass a uniform plan flag without per-agent branching.
@@ -2447,7 +2501,8 @@ export function registerRunCommand(program) {
2447
2501
  console.error(chalk.gray(`Pass --mode plan explicitly if you really want a read-only run.`));
2448
2502
  process.exit(1);
2449
2503
  }
2450
- const effort = options.effort;
2504
+ const effortSource = runCmd.getOptionValueSource('effort');
2505
+ const effort = (effortSource === 'default' && runDefaults.effort ? runDefaults.effort : options.effort);
2451
2506
  if (!['low', 'medium', 'high', 'xhigh', 'max', 'auto'].includes(effort)) {
2452
2507
  console.error(chalk.red(`Invalid effort: ${effort}. Use 'low', 'medium', 'high', 'xhigh', 'max', or 'auto'`));
2453
2508
  process.exit(1);
@@ -42,6 +42,8 @@ export declare function prepareLocalFeedBlocks(localBlocks: OpenBlock[], opts: {
42
42
  dispatch: OpenBlock[];
43
43
  filter: ReturnType<typeof filterBlocksForFeed>;
44
44
  };
45
+ /** Specific host app + routine provenance for one feed row. */
46
+ export declare function formatFeedRuntime(block: Pick<OpenBlock, 'runtime' | 'origin' | 'routineName'>): string;
45
47
  /** Human summary line for one outcome rollup. */
46
48
  export declare function formatOutcomeHeader(group: OutcomeGroup): string;
47
49
  /** Map active sessions into the lightweight hints outcome enrichment needs. */
@@ -59,7 +61,28 @@ export declare function sessionHintsFromActive(sessions: Array<{
59
61
  worktree?: {
60
62
  slug?: string;
61
63
  };
64
+ host?: string;
65
+ origin?: 'cli' | 'routine';
66
+ routineName?: string;
62
67
  }>): SessionOutcomeHint[];
68
+ /**
69
+ * True when a SQLite open/query failed because another writer holds the lock.
70
+ * better-sqlite3 surfaces this as message "database is locked" and/or code
71
+ * SQLITE_BUSY (RUSH-2006).
72
+ */
73
+ export declare function isSqliteBusyError(err: unknown): boolean;
74
+ /**
75
+ * Load session metas for feed outcome enrichment. The sessions index can be
76
+ * locked by a concurrent scanner/daemon — enrichment is best-effort, so a lock
77
+ * degrades to an empty list instead of crashing `agents feed` (RUSH-2006).
78
+ *
79
+ * `load` is injected so tests can throw a real lock-shaped error without
80
+ * mocking the session module.
81
+ */
82
+ export declare function loadSessionMetasForFeedEnrichment<T>(load: () => Promise<T[]>): Promise<{
83
+ metas: T[];
84
+ skippedLock: boolean;
85
+ }>;
63
86
  export declare function registerFeedCommand(program: Command): void;
64
87
  /** Feed view selector (RUSH-2015): decisions, progress, or both. */
65
88
  export type FeedFilter = 'needs' | 'updates' | 'all';
@@ -16,6 +16,7 @@ import { notifyUrgentBlock } from '../lib/notify.js';
16
16
  import { gcMailbox } from '../lib/mailbox-gc.js';
17
17
  import { isValidMailboxId } from '../lib/mailbox.js';
18
18
  import { getActiveSessions } from '../lib/session/active.js';
19
+ import { backfillActiveRowsFromMeta } from './sessions.js';
19
20
  import { mailboxIdForActiveSession } from '../lib/mailbox-target.js';
20
21
  import { GLYPH, masthead } from '../lib/comms-render.js';
21
22
  import { discoverSessions } from '../lib/session/discover.js';
@@ -154,7 +155,7 @@ export function prepareLocalFeedBlocks(localBlocks, opts) {
154
155
  }
155
156
  function renderBlock(b, localHost, indent = '') {
156
157
  const host = b.host !== localHost ? chalk.yellow(` [${b.host}]`) : '';
157
- const runtime = chalk.gray(b.runtime);
158
+ const runtime = chalk.gray(formatFeedRuntime(b));
158
159
  const age = chalk.gray(relTime(b.ts));
159
160
  const cls = b.blockClass ? chalk.gray(`(${b.blockClass})`) : '';
160
161
  const consequence = b.consequence && b.consequence !== 'normal' ? chalk.red(`[${b.consequence}]`) : '';
@@ -218,6 +219,21 @@ function renderBlock(b, localHost, indent = '') {
218
219
  }
219
220
  console.log();
220
221
  }
222
+ /** Specific host app + routine provenance for one feed row. */
223
+ export function formatFeedRuntime(block) {
224
+ const host = {
225
+ ghostty: 'Ghostty',
226
+ iterm: 'iTerm',
227
+ terminal: 'terminal',
228
+ kitty: 'Kitty',
229
+ codium: 'VSCodium',
230
+ code: 'VS Code',
231
+ cursor: 'Cursor',
232
+ }[block.runtime.toLowerCase()] ?? block.runtime;
233
+ if (block.origin !== 'routine')
234
+ return host;
235
+ return `${host} · routine${block.routineName ? `:${block.routineName}` : ''}`;
236
+ }
221
237
  /** Human summary line for one outcome rollup. */
222
238
  export function formatOutcomeHeader(group) {
223
239
  const { agents, open, answered, parked } = group.counts;
@@ -251,8 +267,44 @@ export function sessionHintsFromActive(sessions) {
251
267
  prUrl: s.pr?.url,
252
268
  worktreeSlug: s.worktree?.slug,
253
269
  project: s.cwd ? projectKeyFromCwd(s.cwd) : undefined,
270
+ host: s.host,
271
+ origin: s.origin,
272
+ routineName: s.routineName,
254
273
  }));
255
274
  }
275
+ /**
276
+ * True when a SQLite open/query failed because another writer holds the lock.
277
+ * better-sqlite3 surfaces this as message "database is locked" and/or code
278
+ * SQLITE_BUSY (RUSH-2006).
279
+ */
280
+ export function isSqliteBusyError(err) {
281
+ if (err == null)
282
+ return false;
283
+ const msg = err instanceof Error ? err.message : String(err);
284
+ const code = typeof err === 'object' && err !== null && 'code' in err
285
+ ? String(err.code ?? '')
286
+ : '';
287
+ return /SQLITE_BUSY|database is locked/i.test(msg) || /SQLITE_BUSY/i.test(code);
288
+ }
289
+ /**
290
+ * Load session metas for feed outcome enrichment. The sessions index can be
291
+ * locked by a concurrent scanner/daemon — enrichment is best-effort, so a lock
292
+ * degrades to an empty list instead of crashing `agents feed` (RUSH-2006).
293
+ *
294
+ * `load` is injected so tests can throw a real lock-shaped error without
295
+ * mocking the session module.
296
+ */
297
+ export async function loadSessionMetasForFeedEnrichment(load) {
298
+ try {
299
+ return { metas: await load(), skippedLock: false };
300
+ }
301
+ catch (err) {
302
+ if (isSqliteBusyError(err)) {
303
+ return { metas: [], skippedLock: true };
304
+ }
305
+ throw err;
306
+ }
307
+ }
256
308
  export function registerFeedCommand(program) {
257
309
  const feed = program
258
310
  .command('feed')
@@ -446,7 +498,18 @@ export function registerFeedCommand(program) {
446
498
  if (includeLocal) {
447
499
  sessions = await getActiveSessions();
448
500
  }
449
- const sessionMetas = includeLocal && sessions.length > 0 ? await discoverSessions({ all: true, limit: 5000 }) : [];
501
+ // discoverSessions touches sessions.db; under concurrent scan pressure it
502
+ // can throw SQLITE_BUSY. Outcome enrichment is best-effort — degrade with
503
+ // a warning rather than crash the whole feed (RUSH-2006).
504
+ let sessionMetas = [];
505
+ if (includeLocal && sessions.length > 0) {
506
+ const loaded = await loadSessionMetasForFeedEnrichment(() => discoverSessions({ all: true, limit: 5000 }));
507
+ if (loaded.skippedLock) {
508
+ console.error(chalk.yellow('Feed: session index is locked; skipping local outcome enrichment'));
509
+ }
510
+ sessionMetas = loaded.metas;
511
+ backfillActiveRowsFromMeta(sessions, new Map(sessionMetas.map((meta) => [meta.id, meta])));
512
+ }
450
513
  const localSignals = buildSessionSignals(sessions, sessionMetas);
451
514
  if (opts.pause || opts.kill) {
452
515
  if (!includeLocal) {