@phnx-labs/agents-cli 1.22.23 → 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 (88) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +5 -4
  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 +5 -0
  8. package/dist/commands/exec.js +80 -28
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +34 -3
  12. package/dist/commands/focus.js +300 -56
  13. package/dist/commands/go.d.ts +14 -1
  14. package/dist/commands/go.js +49 -5
  15. package/dist/commands/insights.js +6 -2
  16. package/dist/commands/inspect.js +39 -5
  17. package/dist/commands/menubar.js +6 -1
  18. package/dist/commands/models.js +1 -0
  19. package/dist/commands/modes.d.ts +12 -0
  20. package/dist/commands/modes.js +147 -0
  21. package/dist/commands/secrets-sync.js +11 -13
  22. package/dist/commands/secrets.d.ts +2 -0
  23. package/dist/commands/secrets.js +54 -17
  24. package/dist/commands/sessions-browser.d.ts +35 -0
  25. package/dist/commands/sessions-browser.js +140 -14
  26. package/dist/commands/sessions-resume.d.ts +2 -0
  27. package/dist/commands/sessions-resume.js +26 -11
  28. package/dist/commands/sessions.d.ts +10 -0
  29. package/dist/commands/sessions.js +34 -59
  30. package/dist/commands/setup-secrets.js +1 -1
  31. package/dist/commands/sync.js +246 -42
  32. package/dist/commands/view.js +2 -0
  33. package/dist/index.js +2 -1
  34. package/dist/lib/agent-modes.d.ts +49 -0
  35. package/dist/lib/agent-modes.js +70 -0
  36. package/dist/lib/doctor-diff.d.ts +3 -0
  37. package/dist/lib/doctor-diff.js +15 -13
  38. package/dist/lib/event-stream.d.ts +3 -1
  39. package/dist/lib/event-stream.js +14 -1
  40. package/dist/lib/exec.d.ts +12 -0
  41. package/dist/lib/exec.js +88 -9
  42. package/dist/lib/hooks/cache.js +36 -3
  43. package/dist/lib/hooks.d.ts +19 -7
  44. package/dist/lib/hooks.js +88 -38
  45. package/dist/lib/hosts/session-index.d.ts +4 -0
  46. package/dist/lib/hosts/session-index.js +7 -0
  47. package/dist/lib/manifest.d.ts +12 -2
  48. package/dist/lib/manifest.js +60 -5
  49. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  54. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  55. package/dist/lib/menubar/install-menubar.js +128 -6
  56. package/dist/lib/refresh.d.ts +5 -0
  57. package/dist/lib/refresh.js +37 -33
  58. package/dist/lib/resource-inventory.d.ts +79 -0
  59. package/dist/lib/resource-inventory.js +122 -0
  60. package/dist/lib/resources.js +8 -5
  61. package/dist/lib/run-defaults.d.ts +2 -0
  62. package/dist/lib/run-defaults.js +23 -2
  63. package/dist/lib/runner.js +22 -17
  64. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  65. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  66. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  67. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  68. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  69. package/dist/lib/secrets/bundles.js +157 -65
  70. package/dist/lib/secrets/filestore.d.ts +5 -3
  71. package/dist/lib/secrets/filestore.js +12 -8
  72. package/dist/lib/secrets/index.js +34 -8
  73. package/dist/lib/secrets/reaper.d.ts +10 -0
  74. package/dist/lib/secrets/reaper.js +50 -15
  75. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  76. package/dist/lib/secrets/sync-passphrase.js +78 -0
  77. package/dist/lib/session/recovery.d.ts +37 -0
  78. package/dist/lib/session/recovery.js +95 -0
  79. package/dist/lib/shims.d.ts +2 -2
  80. package/dist/lib/shims.js +24 -5
  81. package/dist/lib/startup/command-registry.d.ts +1 -0
  82. package/dist/lib/startup/command-registry.js +2 -0
  83. package/dist/lib/sync-umbrella.d.ts +5 -0
  84. package/dist/lib/sync-umbrella.js +5 -4
  85. package/dist/lib/tmux/session.d.ts +4 -2
  86. package/dist/lib/tmux/session.js +5 -5
  87. package/dist/lib/types.d.ts +2 -0
  88. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,198 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.24
4
+
5
+ - **`agents sessions focus` recovers dead panes and shares the sessions browser's selectors (GH-2108).** A retained tmux `remain-on-exit` pane is probed through `#{pane_dead}` immediately before attach, so dead or missing panes no longer open a `Pane is dead` screen. `focus` accepts session ids, topic/path searches, `agent@version` selectors (including per-device `latest`/`oldest`), device, project/time, team/routine, skill/plugin, favorites, and the complete live-state union. Focus, resume, attach, and `run --resume` now use one recovery decision on the origin device: a healthy exact origin performs native resume; otherwise balanced selection chooses a healthy version of the same harness and sends `/continue <id>` to read the indexed transcript, including transcripts retained under version trash. Host-dispatched rows persist the dispatch host as their origin, and `attach` routes its detach-record cleanup there before resuming. No usable same-harness version fails with the device, origin version, and account-health reason. Source: `apps/cli/src/commands/focus.ts`, `apps/cli/src/commands/sessions-browser.ts`, `apps/cli/src/lib/session/recovery.ts`.
6
+
7
+ - **`agents secrets setup` no longer tells you to set `AGENTS_SECRETS_PASSPHRASE`, and
8
+ `docs/secrets.md` stops recommending the shell-rc export it flags as a leak
9
+ (RUSH-1968).** The file-backend note read `set AGENTS_SECRETS_PASSPHRASE for headless
10
+ encrypted-file reads`, implying a requirement; headless reads have worked with no
11
+ passphrase since the store began auto-provisioning a 0600 machine-local key, so it now
12
+ says so and names the real path. The docs were worse than merely stale: they called an
13
+ rc export *"Recommended for shared/CI machines"* and the 0600 key file *"identical to"*
14
+ it, which is how a master key ended up in `~/.zshenv` on seven worker boxes. That
15
+ equivalence is inverted — the key file is read by one process, an rc export is inherited
16
+ by every child and readable from `/proc/<pid>/environ` — and the section also named a
17
+ pre-#479 key path (`~/.agents/.cache/secrets/.passphrase`, now
18
+ `~/.agents/.secrets-key/passphrase`) and a TTY prompt step `getPassphrase` no longer
19
+ has. A new `docs-hygiene.test.ts` pins those claims against the shipped doc so the
20
+ advice cannot drift back. Source: `apps/cli/docs/secrets.md`,
21
+ `apps/cli/src/commands/setup-secrets.ts`, `apps/cli/src/lib/secrets/filestore.ts`.
22
+
23
+ - **`agents secrets push`/`pull`, `agents sync --secrets`, and `agents secrets
24
+ export --to-file` / `import --from-file` read `AGENTS_SYNC_PASSPHRASE` now;
25
+ `AGENTS_SECRETS_PASSPHRASE` is the file store's master key and nothing else
26
+ (RUSH-1968).** One variable meant two different secrets: the local file store's master
27
+ key, and the passphrase that seals a bundle for transport. The store stopped needing a
28
+ passphrase once it auto-provisioned a machine-local key, but headless `push`/`pull`
29
+ still hard-failed without one — so the only way to get unattended sync on a worker box
30
+ was to export the **master key** fleet-wide, handing every same-user process the key to
31
+ the whole store. Splitting them means a box that only needs headless sync sets
32
+ `AGENTS_SYNC_PASSPHRASE` and never has the master key in its environment. The old name
33
+ still works for sync as a deprecated fallback — warned exactly once per process, so a
34
+ `push --all` over many bundles does not flood stderr — so scripted CI and release
35
+ automation keep working across the upgrade. The headless error now names the new
36
+ variable (`A sync passphrase is required. Run from a TTY, or set
37
+ AGENTS_SYNC_PASSPHRASE.`), and `agents sync`'s skip line with it — it previously read
38
+ `no passphrase available`, naming nothing an operator could act on. Note the legacy
39
+ fallback only works where that value is also the store's master key, since the old name
40
+ still keys the store; that coupling is the thing being retired. Resolution moved to
41
+ one chokepoint so the once-per-process promise holds rather than being per-call-site. Also
42
+ corrects `SEC-29a`, which claimed the variable applied "exclusively" to the file and
43
+ age-vault backends: the age-vault backend never reads it (it is gated by `agents
44
+ login`), and sync plus the portable `--to-file` envelope were two more consumers — the
45
+ new `SEC-29b` states the split as a normative invariant. Source:
46
+ `apps/cli/src/lib/secrets/sync-passphrase.ts`, `apps/cli/src/commands/secrets-sync.ts`,
47
+ `apps/cli/src/commands/sync.ts`, `apps/cli/src/lib/sync-umbrella.ts`.
48
+
49
+ - Added `--team` as an alias for `agents sessions --teams`. (RUSH-1995)
50
+ - Added absolute `agents secrets unlock --until <date>` expiry, mutually exclusive with relative `--ttl`. (RUSH-1960)
51
+ - Added persisted per-agent-version reasoning effort defaults and surfaced them in `agents view`. (RUSH-2005)
52
+
53
+ - **`agents feed` no longer crashes when the session index is locked (RUSH-2006).**
54
+ Outcome enrichment calls `discoverSessions` against `sessions.db`; under concurrent
55
+ scanner/daemon pressure that open can throw `SQLITE_BUSY` / "database is locked"
56
+ and take down the whole feed. A lock error now degrades to an empty meta set with a
57
+ stderr warning so blocks still render. Source: `apps/cli/src/commands/feed.ts`.
58
+
59
+ - **`agents events --event` / filtered activity reads no longer miss matches under
60
+ `--limit` (RUSH-2093).** The unified reader capped activity records *before*
61
+ applying the event-type filter, so a rare match older than the newest-N routine
62
+ rows (e.g. one `pr.opened` under twenty `file.edited`) was silently dropped. Event
63
+ types are now passed into `readRecentActivity` so the cap counts matching rows; a
64
+ non-`activity` `--module` skips the activity half entirely. Source:
65
+ `apps/cli/src/lib/event-stream.ts`.
66
+
67
+ - **`agents mcp add` no longer strips comments from `agents.yaml`.** The
68
+ write path (`writeManifest` / `serializeManifest` in `src/lib/manifest.ts`)
69
+ used plain `yaml.stringify`, which dropped every hand-written comment on
70
+ every add. It now round-trips via `yaml.parseDocument` and edits only the
71
+ keys that changed — the same approach `serializeCentral` already uses for
72
+ the central meta file (RUSH-2090).
73
+
74
+ - **Hook cache refresh is now single-flight, backs off on failure, and logs its
75
+ real exit code (RUSH-2121).** The background refresh in the hook cache shim
76
+ acquires an atomic `mkdir` lock, so concurrent hook invocations no longer
77
+ stampede into N parallel refreshes; a persistently-failing refresh records a
78
+ failure timestamp and is skipped until a 60s backoff elapses instead of
79
+ re-firing on every invocation; and the refresh now emits a `hook.cache.refresh`
80
+ event carrying the subshell's real exit code instead of a hardcoded `0`.
81
+ Source: `apps/cli/src/lib/hooks/cache.ts`.
82
+
83
+ - **`agents sync --host all` no longer fails every peer with `unknown option
84
+ '--json'`.** Fleet fan-out injects `--json` on each remote so the roster can
85
+ parse per-device results, but `sync` never registered the flag. Register
86
+ `--json` on `agents sync` and emit a machine-readable umbrella/agent/repo
87
+ payload so peers accept the flag and return parseable stdout (RUSH-2216).
88
+ Source: `apps/cli/src/commands/sync.ts`.
89
+
90
+ - **A wedged keychain can no longer pile up `agents` processes (RUSH-2231, RUSH-2232).**
91
+ A stalled macOS `coreauthd` used to hang the signed keychain helper's XPC receive
92
+ forever, so every secrets-touching `agents` command blocked and dozens of helper
93
+ processes plus their `<defunct>` zombies accumulated and made the machine sluggish.
94
+ Two fixes: **(Layer 1)** every keychain-helper `spawnSync` is now bounded and
95
+ hard-killed (SIGKILL) on timeout — 8s for never-prompt verbs (`has`/`set`/`delete`/
96
+ `list*`), 60s for the may-prompt reads (`get`/`get-batch`/`migrate-*`) — surfacing a
97
+ typed timeout error and arming the read back-off instead of hanging.
98
+ **(Layer 3)** the daemon reaps the backlog: a 5-minute tick kills orphaned helpers
99
+ (reparented to PID 1, past a 30s grace) and, two-sweep-debounced, the helper child of
100
+ an `agents` proc stuck past 90s (child first, escalating to the parent only if it
101
+ stays wedged) — never touching a process whose start-time can't be captured or whose
102
+ path doesn't match the helper. Any keychain touch now also opportunistically starts
103
+ the daemon so the reaper runs even on a secrets-only box. Source:
104
+ `apps/cli/src/lib/secrets/index.ts`, `apps/cli/src/lib/secrets/reaper.ts`,
105
+ `apps/cli/src/lib/daemon.ts`.
106
+
107
+ - **`agents inspect` and `agents doctor` now report harness-scoped hook inventory as capable, on-disk, wired, and unmanaged state.** Their JSON output carries the same state sets from `getResourceInventory`, so a harness with hook files or native wiring no longer collapses to a misleading `Hooks (0)` summary. Inventory is keyed by agent harness and installed version, not by configured model. Source: `apps/cli/src/lib/resource-inventory.ts`, `apps/cli/src/commands/inspect.ts`, `apps/cli/src/lib/doctor-diff.ts`.
108
+
109
+ - **Fix: the keychain-helper reaper never reaped on macOS.** The daemon reaper shelled `ps -o etimes` — a GNU/Linux procps keyword that macOS `ps` rejects with a non-zero exit — so `execFileSync` threw on every tick and `reapOrphanedKeychainProcesses` returned `reaped: 0` on its only supported platform, leaving orphaned/wedged `Agents CLI` helper processes to pile up. Switched to the portable BSD `etime` keyword (`[[dd-]hh:]mm:ss`) with a parser to seconds. This also un-quarantines the darwin integration test that #2153 had to `it.skip` to unblock releases: that test's symlink fixture was correct all along — it read `reaped: 0` only because the reaper's own `ps etimes` call threw before parsing anything. Verified on macOS 15.4.1: 20/20 reaper tests pass, the integration test reaping a real orphaned sleeper. Source: `apps/cli/src/lib/secrets/reaper.ts`, `reaper.test.ts`.
110
+
111
+ - **`agents modes [agent[@version]]` lists the permission modes a harness accepts.** The modes analog of `agents models`: for Claude / Codex / Cursor / …, shows which `--mode plan|edit|auto|skip` values work, the native CLI flags, the native default (`*`), any configured `run.defaults` mode, and degrade notes (e.g. plan→edit on Cursor). `agents inspect` also prints the mode list next to capabilities, and `agents run --help` / `agents models` point at the new command. Source: `apps/cli/src/commands/modes.ts`, `apps/cli/src/lib/agent-modes.ts`.
112
+
113
+ - **`agents insights --all` now works as an alias for `--since all`.** It previously
114
+ exited with `unknown option '--all'` — a hard failure in the middle of a report the
115
+ user asked for, on the spelling most people reach for first. An explicit `--since`
116
+ still wins, so `--all --since 7d` resolves to 7d rather than silently contradicting
117
+ itself. Source: `apps/cli/src/commands/insights.ts`.
118
+
119
+ - **Coexisting agents-cli installs no longer fight over the menu-bar helper.** The
120
+ helper lives at one path in Application Support, but every install on the box runs
121
+ the startup self-heal, and both the version stamp and the plist's baked
122
+ `AGENTS_ENTRY` record whichever copy acted last — so each copy read the others'
123
+ marks as drift and recopied the app bundle over them. Recopying replaces the
124
+ executable under the running helper and kills it, launchd `KeepAlive` restarts it,
125
+ and the next copy repeats it: a new pid every 5-15 seconds, 578 launches in one
126
+ observed helper log, and a status item that never stayed visible while
127
+ `agents menubar status` still reported `running: yes` (a pid always existed). The
128
+ plist's `AGENTS_ENTRY` is now treated as the owner and only the owner reinstalls
129
+ freely; a same-install upgrade keeps its entry path, so `npm update` still installs
130
+ the new helper normally. Another install still gets there — immediately if the
131
+ recorded owner is gone from disk, otherwise at most once an hour — so a stale copy
132
+ that merely still sits on disk can't freeze the menu bar for whichever install the
133
+ user actually upgrades. Repairs (a missing helper executable, a Developer-ID heal)
134
+ are never gated, and `agents menubar setup` bypasses the gate as the immediate
135
+ manual fix. An ad-hoc/dev-signed build never wins the timed takeover — recopying
136
+ an un-notarized bundle over a good one gets it rejected as "damaged" — though it
137
+ can still adopt a helper whose owner is gone. Two installs that are both invoked
138
+ regularly still trade ownership at the cooldown, so the helper restarts about once
139
+ an hour until one is removed; that is bounded rather than converged, and the real
140
+ fix remains a single install. `agents menubar status` no longer promises that a
141
+ stale helper "runs on next `agents` startup", which is not guaranteed on a
142
+ multi-install box. Fixes #2109. Source:
143
+ `apps/cli/src/lib/menubar/install-menubar.ts`.
144
+
145
+ - **Muse Code global binary is visible to `agents view` / `isVersionInstalled`.**
146
+ Muse installs a single self-updating launcher at `~/.local/bin/muse` (same
147
+ shape as droid), but `getBinaryPath` still resolved a version-home
148
+ `node_modules/.bin/muse` that never exists. After `agents import muse` /
149
+ `agents add muse`, version dirs now resolve to that global path so managed
150
+ view, collapse, and live-version bookkeeping match what actually executes;
151
+ install no longer writes a self-referential shim symlink. Source:
152
+ `apps/cli/src/lib/versions.ts`.
153
+
154
+ - **Muse multi-version isolation matches Claude/Codex (XDG pin, not bare symlink).**
155
+ Claude/Codex isolate per version with `CLAUDE_CONFIG_DIR` / `CODEX_HOME`.
156
+ Muse has no dedicated config env; it reads `$XDG_CONFIG_HOME/muse` and
157
+ `$XDG_DATA_HOME/muse`. After `agents import muse`, `~/.config/muse` is a
158
+ symlink into the version home — Muse refuses that path with
159
+ `Agent Definition filesystem source failed: SymlinkOrReparse` and exits 1
160
+ (the Zion `agents run muse@0.1.0` failure). Managed launches now pin
161
+ `XDG_CONFIG_HOME` + `XDG_DATA_HOME` into the version home from `buildExecEnv`,
162
+ the main shim, and versioned aliases (`muse@0.1.0`), the same way Claude pins
163
+ `CLAUDE_CONFIG_DIR`. Muse is also listed in `CONFIG_ENV_ISOLATED_AGENTS` so
164
+ `--isolated` installs are honest. Source: `apps/cli/src/lib/{exec,shims}.ts`.
165
+
166
+ - **Scheduled routines now run on a per-account `claude setup-token` when one is
167
+ provisioned, instead of throwing it away.** `buildRoutineSpawnEnv` unconditionally
168
+ deleted `CLAUDE_CODE_OAUTH_TOKEN`, so even after `buildExecEnv` injected a long-lived,
169
+ non-rotating setup-token from the reserved file-backed `auth` bundle
170
+ (`resolveClaudeSetupToken`), a routine still fell back to the version home's rotating
171
+ `.credentials.json` login. With one Claude account signed into several version homes
172
+ (or several fleet boxes), that rotating login is the single-use-refresh-token
173
+ revocation storm — one home's refresh silently revokes every sibling copy, so an
174
+ unattended routine keeps landing on a just-revoked token and dies with `auth_failed:
175
+ OAuth access token has been revoked` / `Please run /login`, even though `agents view`
176
+ shows the account healthy. The delete now distinguishes the two flavours: a
177
+ per-account setup-token (keyed to this home's own account) is re-asserted and KEPT; an
178
+ inherited *ambient* token (a shared value the daemon env happened to carry — the
179
+ RUSH-1822 fleet-logout path) is still stripped so a routine never runs on it.
180
+ Provision the token with `/fleet:mint-auth`. Source:
181
+ `apps/cli/src/lib/runner.ts` (`buildRoutineSpawnEnv`).
182
+
183
+ - **Resolved secrets no longer appear in `ps` for tmux-launched agents
184
+ (RUSH-2100).** An interactive `agents run` wraps the agent in tmux via
185
+ `exec env K=V … <agent>`, which put the entire exec env — every resolved
186
+ secrets-bundle value — into the pane's command line, readable by any process of
187
+ the same user. On one fleet box six live processes carried
188
+ `AGENTS_SECRETS_PASSPHRASE`, the key that decrypts every file-backed bundle on
189
+ that machine. The pane now sources a `0600` env file and unlinks it before
190
+ `exec`, so only the file path is argv-visible; a missing file aborts the pane
191
+ rather than launching half-configured. Every key routes through the file, not a
192
+ curated "secret-bearing" subset, so a newly added credential is covered without
193
+ anyone maintaining a list. Source: `apps/cli/src/lib/exec.ts`,
194
+ `apps/cli/docs/specifications.md` (SEC-8a).
195
+
3
196
  ## 1.22.23
4
197
 
5
198
  - **Daemon-warmed cross-surface session-status cache (RUSH-2062).** Menubar,
package/README.md CHANGED
@@ -303,6 +303,7 @@ agents sessions --idle # stopped between turns (fleet-wide)
303
303
  agents sessions --orphan # agent outlived its terminal client
304
304
  agents sessions --crashed # terminal and agent disappeared uncleanly
305
305
  agents sessions focus a1b2c3d4 # jump back into one — attach in place, or resume
306
+ agents sessions focus claude@latest --device yosemite-s0 # pick latest there
306
307
  ```
307
308
 
308
309
  On a terminal, `agents sessions --active` (and a bare `agents sessions`) open the **interactive session browser** — one filter you drive with single keys, re-pulled live across the fleet:
@@ -313,7 +314,7 @@ On a terminal, `agents sessions --active` (and a bare `agents sessions`) open th
313
314
  | `r` | running only | `--active` |
314
315
  | `f` | favorites only | `--favorites` |
315
316
  | `*` | star / unstar the highlighted session | `agents sessions favorite <id>` |
316
- | `c` | team sessions | `--teams` |
317
+ | `c` | team sessions | `--team` (alias: `--teams`) |
317
318
  | `a` | agent (cycles) | `-a` |
318
319
  | `d` | device (cycles) | `--device` |
319
320
  | `p` | this repo ↔ all dirs | `--all` |
@@ -332,7 +333,7 @@ Filters **stack** (they AND together), the active set shows in the header, and t
332
333
  | --- | --- |
333
334
  | ![sessions browser, preview hidden](assets/demos/sessions-preview-before.png) | ![sessions browser, preview open with a links line](assets/demos/sessions-preview-after.png) |
334
335
 
335
- Each live session resolves to `working`, `waiting_input` (with why -- a question, a plan review, or a permission prompt), `idle`, or a lifecycle state such as `orphaned`, `crashed`, `closed`, `abandoned`, `queued`, or `unknown`. Pass the matching flag (`--working`, `--idle`, `--waiting`, `--orphan`, `--crashed`, `--closed`, `--abandoned`, `--queued`, `--unknown`) directly; each implies `--active`, and several flags form a union. The fleet fan-out is already the default; `--local` opts out. `--all` instead widens historical directory and time scope. Rows also carry badges for the PR, worktree, and ticket. `agents sessions focus [id]` attaches the live pane in place -- the tmux split locally or over SSH, or its Ghostty tab -- and falls back to a fresh tab + resume when the terminal is gone.
336
+ Each live session resolves to `working`, `waiting_input` (with why -- a question, a plan review, or a permission prompt), `idle`, or a lifecycle state such as `orphaned`, `crashed`, `closed`, `abandoned`, `queued`, or `unknown`. Pass the matching flag (`--working`, `--idle`, `--waiting`, `--orphan`, `--crashed`, `--closed`, `--abandoned`, `--queued`, `--unknown`) directly; each implies `--active`, and several flags form a union. The fleet fan-out is already the default; `--local` opts out. `--all` instead widens historical directory and time scope. Rows also carry badges for the PR, worktree, and ticket. `agents sessions focus [selector]` accepts the same agent/version, device, time, team, project, skill/plugin, favorite, and live-state filters as the session browser. A unique id focuses directly; an agent/version or text selector always opens the preview picker. Immediately before attach it checks the tmux pane process: a living pane is joined in place, while a dead/missing pane enters recovery instead of showing tmux's `Pane is dead` screen.
336
337
 
337
338
  Landing on a session cold? `agents sessions <id>` prints a catch-up digest: an inferred title, files changed grouped by directory (created / modified / deleted), a histogram of which tools did the work (including parsed Bash commands -- `git`, `npm`, `ffmpeg`, `ssh`, and so on), and the last test verdict -- the signals to reload a task in seconds.
338
339
 
@@ -351,11 +352,11 @@ agents resume 019fd0c8-b3e9-77a2-a1a4-444698c4d897 # original harness/version/d
351
352
  agents run auto --resume 019fd0c8-b3e9-77a2-a1a4-444698c4d897 # adapt if its account is unavailable
352
353
  ```
353
354
 
354
- `agents sessions resume` reopens several sessions in whatever terminal you're in -- auto-detected across iTerm, Ghostty, tmux, and the VSCodium agent-terminal, or forced with `--iterm` / `--ghostty` / `--tmux` / `--vscodium`. `agents resume <id>` resumes one session without requiring you to name its harness: exact IDs take a local SQLite fast path, then resolve fleet-wide and restore the source harness, version, device, cwd, and recorded launch mode. Back them with **tmux** and the runs turn durable: detach, close your editor, reboot the GUI -- the session is still alive to `agents tmux attach`. The whole `agents tmux` subsystem (persistent multiplexer sessions that survive editor restarts and can be shared with other tools) sits underneath.
355
+ `agents sessions resume` reopens several sessions in whatever terminal you're in -- auto-detected across iTerm, Ghostty, tmux, and the VSCodium agent-terminal, or forced with `--iterm` / `--ghostty` / `--tmux` / `--vscodium`. `agents resume <id>` resumes one session without requiring you to name its harness: exact IDs take a local SQLite fast path, then resolve fleet-wide and recover on the source device. If the origin version is installed, signed in, and healthy, its isolated home performs native resume. Otherwise a healthy version of the **same harness** starts with `/continue <id>`, which reads the indexed transcript even when the old version home is retained under version trash. It never native-resumes from a different isolated home. Back them with **tmux** and the runs turn durable: detach, close your editor, reboot the GUI -- the session is still alive to `agents tmux attach`. The whole `agents tmux` subsystem (persistent multiplexer sessions that survive editor restarts and can be shared with other tools) sits underneath.
355
356
 
356
357
  ### Send an agent to the background — and bring it back
357
358
 
358
- Running 30 agents and drowning in terminal tabs? `agents sessions detach <id>` stops a session's interactive process and keeps it working **headless** in the background -- it drives its task to done unattended, no tab, lower cost. `agents sessions attach <id>` brings it back: version-pinned resume into a live TUI, the same session and full history (including whatever it did while backgrounded).
359
+ Running 30 agents and drowning in terminal tabs? `agents sessions detach <id>` stops a session's interactive process and keeps it working **headless** in the background -- it drives its task to done unattended, no tab, lower cost. `agents sessions attach <id>` brings it back through the same origin-device recovery decision: native resume in the exact healthy origin home, or same-harness `/continue` when that home is unavailable, with the full indexed history (including whatever it did while backgrounded).
359
360
 
360
361
  ```
361
362
  agents sessions detach a1b2c3d4 # go headless in the background, keep working
Binary file
@@ -6,5 +6,7 @@
6
6
  * history, the exact inverse of `agents sessions detach`.
7
7
  */
8
8
  import type { Command } from 'commander';
9
+ import type { SessionMeta } from '../lib/session/types.js';
9
10
  export declare function registerAttachCommand(program: Command): void;
10
11
  export declare function attachAction(id: string): Promise<void>;
12
+ export declare function attachRecoveryArgs(session: Pick<SessionMeta, 'id'>): string[];
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
- import { discoverSessions } from '../lib/session/discover.js';
3
- import { resumeSessionInPlace } from './sessions.js';
2
+ import { runOnPeer } from '../lib/session/remote-list.js';
3
+ import { sessionRecoveryPeer } from '../lib/session/recovery.js';
4
+ import { resolveSessionMetadataValue, resumeSessionInPlace } from './sessions.js';
4
5
  import { readDetachRecord, clearDetachRecord, isHeadlessAlive } from '../lib/session/detached.js';
5
6
  export function registerAttachCommand(program) {
6
7
  program
@@ -12,16 +13,37 @@ export function registerAttachCommand(program) {
12
13
  });
13
14
  }
14
15
  export async function attachAction(id) {
15
- const q = id.toLowerCase();
16
- // Rich meta carries the pinned version + origin cwd the resume needs.
17
- const metas = await discoverSessions({ all: true, since: '90d', limit: 2000 });
18
- const meta = metas.find((m) => m.id === id) ?? metas.find((m) => m.id.toLowerCase().startsWith(q));
19
- if (!meta) {
16
+ const outcome = await resolveSessionMetadataValue(id);
17
+ if (outcome.kind === 'partial') {
18
+ console.error(chalk.red(`Could not resolve session while these devices were unavailable: ${outcome.failedPeers.join(', ')}`));
19
+ process.exitCode = 2;
20
+ return;
21
+ }
22
+ if (outcome.kind === 'not-found') {
20
23
  console.error(chalk.red(`No session matching "${id}".`));
21
24
  console.error(chalk.gray(' See your sessions: agents sessions'));
22
25
  process.exitCode = 1;
23
26
  return;
24
27
  }
28
+ if (outcome.kind === 'ambiguous') {
29
+ console.error(chalk.red(`"${id}" matches ${outcome.candidates.length} sessions. Pass the full session id.`));
30
+ process.exitCode = 1;
31
+ return;
32
+ }
33
+ const meta = outcome.session;
34
+ // Route the WHOLE attach operation to the origin, not just its eventual
35
+ // resume. The detach record and headless PID are device-local; clearing them
36
+ // here would leave the real background continuation running beside a second
37
+ // process on the owning machine.
38
+ const peer = sessionRecoveryPeer(meta);
39
+ if (peer) {
40
+ const routed = await runOnPeer(attachRecoveryArgs(meta), peer, { tty: true });
41
+ if (routed === 'no-target') {
42
+ console.error(chalk.red(`Cannot attach ${meta.shortId}: origin device ${peer} is not a registered reachable peer.`));
43
+ process.exitCode = 1;
44
+ }
45
+ return;
46
+ }
25
47
  // If it's backgrounded, stop the headless continuation first so two processes
26
48
  // don't resume the same transcript at once.
27
49
  const rec = readDetachRecord(meta.id);
@@ -39,3 +61,6 @@ export async function attachAction(id) {
39
61
  console.log(chalk.gray(`Attaching ${meta.agent} ${meta.id.slice(0, 8)} — resuming interactively…`));
40
62
  await resumeSessionInPlace(meta);
41
63
  }
64
+ export function attachRecoveryArgs(session) {
65
+ return ['sessions', 'attach', session.id];
66
+ }
@@ -47,11 +47,13 @@ export function registerDefaultsCommands(program) {
47
47
  .description('Set defaults for an agent/version selector')
48
48
  .option('--mode <mode>', "Default mode: plan, edit, auto, skip. 'full' accepted as alias for skip.")
49
49
  .option('--model <model>', 'Default model or model alias, forwarded via --model')
50
+ .option('--effort <effort>', 'Default reasoning effort: low, medium, high, xhigh, max, or auto')
50
51
  .action((selector, options) => {
51
52
  try {
52
53
  const entry = setRunDefault(selector, {
53
54
  ...(options.mode !== undefined ? { mode: options.mode } : {}),
54
55
  ...(options.model !== undefined ? { model: options.model } : {}),
56
+ ...(options.effort !== undefined ? { effort: options.effort } : {}),
55
57
  });
56
58
  console.log(chalk.green('Set run default:'));
57
59
  console.log(` ${formatRunDefaultEntry(entry)}`);
@@ -848,6 +848,11 @@ function renderTargetText(report, options) {
848
848
  // sees what was checked. options.requestedKinds drives this.
849
849
  if (options.requestedKinds && !options.requestedKinds.has(kind))
850
850
  continue;
851
+ if (kind === 'hooks' && report.hookInventory) {
852
+ const wired = report.hookInventory.wiringSupported ? String(report.hookInventory.wired.length) : 'unknown';
853
+ const unmanaged = report.hookInventory.unmanaged.length > 0 ? ` · unmanaged ${report.hookInventory.unmanaged.length}` : '';
854
+ console.log(chalk.gray(` inventory: capable ${report.hookInventory.capable ? 'yes' : 'no'} · on-disk ${report.hookInventory.onDisk.length} · wired ${wired}${unmanaged}`));
855
+ }
851
856
  renderKindSection(kind, rows, report.layers, options);
852
857
  // A hook file can reconcile "ok" above yet be absent from settings.json — a
853
858
  // present-but-dead hook. Surface that right under the hooks section.
@@ -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).')
@@ -647,12 +647,14 @@ 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
660
  plan works headless on claude, codex, droid, opencode.
@@ -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) {
@@ -2217,10 +2228,44 @@ export function registerRunCommand(program) {
2217
2228
  session = picked.session;
2218
2229
  forceInteractive = true; // bare resume always lands in the agent's TUI
2219
2230
  }
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);
2231
+ // Bare `run <harness> --resume` learns the chosen SessionMeta only after
2232
+ // the host-placement phase above. If the picker chose a synced session
2233
+ // from another device, route the recovery command now; resolving local
2234
+ // candidates would otherwise native-resume through this device's
2235
+ // unrelated isolated home.
2236
+ if (!resolvedResumeSource) {
2237
+ const { sessionRecoveryPeer, sessionRecoveryRunArgs, } = await import('../lib/session/recovery.js');
2238
+ const peer = sessionRecoveryPeer(session);
2239
+ if (peer) {
2240
+ const { runOnPeer } = await import('../lib/session/remote-list.js');
2241
+ const routed = await runOnPeer(sessionRecoveryRunArgs(session), peer, { tty: true });
2242
+ if (routed === 'no-target') {
2243
+ console.error(chalk.red(`Cannot recover session ${session.shortId}: origin device ${peer} is not a registered reachable peer.`));
2244
+ process.exitCode = 1;
2245
+ }
2246
+ return;
2247
+ }
2248
+ }
2249
+ // Bare interactive --resume selects the SessionMeta only down here, so
2250
+ // it has not gone through the early concrete-id resolver. Resolve it now;
2251
+ // concrete ids reuse the exact same target chosen above.
2252
+ if (!resolvedRecoveryTarget) {
2253
+ try {
2254
+ const { resolveSessionRecovery } = await import('../lib/session/recovery.js');
2255
+ resolvedRecoveryTarget = await resolveSessionRecovery(session);
2256
+ }
2257
+ catch (err) {
2258
+ console.error(chalk.red(err.message));
2259
+ process.exit(1);
2260
+ }
2261
+ }
2262
+ if (resolvedRecoveryTarget.agent !== agent) {
2263
+ console.error(chalk.red(`Session ${session.shortId} belongs to ${resolvedRecoveryTarget.agent}, not ${agent}. ` +
2264
+ `Use: agents run auto --resume ${session.id}`));
2265
+ process.exit(1);
2266
+ }
2267
+ version = resolvedRecoveryTarget.version;
2268
+ const canResumeNatively = resolvedRecoveryTarget.mode === 'native';
2224
2269
  if (canResumeNatively) {
2225
2270
  version = session.version;
2226
2271
  resumeNative = true;
@@ -2330,6 +2375,12 @@ export function registerRunCommand(program) {
2330
2375
  console.error(chalk.red(`agents: ${agent}@${launchTarget} is not runnable and could not be repaired. Try: ${hint}`));
2331
2376
  process.exit(1);
2332
2377
  }
2378
+ if (resolvedRecoveryTarget && healed !== launchTarget) {
2379
+ console.error(chalk.red(`agents: session recovery target ${agent}@${launchTarget} became unavailable on ` +
2380
+ `${resolvedResumeSource?.machine ?? 'this device'}; refusing to resume through another version home. ` +
2381
+ `Retry the command so recovery can select a healthy ${agent} version.`));
2382
+ process.exit(1);
2383
+ }
2333
2384
  // Always adopt the healed version explicitly. In the version-undefined
2334
2385
  // path a fallback re-pins the GLOBAL default, but `resolveVersion`
2335
2386
  // prefers a PROJECT pin — so leaving `version` undefined would let the
@@ -2447,7 +2498,8 @@ export function registerRunCommand(program) {
2447
2498
  console.error(chalk.gray(`Pass --mode plan explicitly if you really want a read-only run.`));
2448
2499
  process.exit(1);
2449
2500
  }
2450
- const effort = options.effort;
2501
+ const effortSource = runCmd.getOptionValueSource('effort');
2502
+ const effort = (effortSource === 'default' && runDefaults.effort ? runDefaults.effort : options.effort);
2451
2503
  if (!['low', 'medium', 'high', 'xhigh', 'max', 'auto'].includes(effort)) {
2452
2504
  console.error(chalk.red(`Invalid effort: ${effort}. Use 'low', 'medium', 'high', 'xhigh', 'max', or 'auto'`));
2453
2505
  process.exit(1);
@@ -60,6 +60,24 @@ export declare function sessionHintsFromActive(sessions: Array<{
60
60
  slug?: string;
61
61
  };
62
62
  }>): SessionOutcomeHint[];
63
+ /**
64
+ * True when a SQLite open/query failed because another writer holds the lock.
65
+ * better-sqlite3 surfaces this as message "database is locked" and/or code
66
+ * SQLITE_BUSY (RUSH-2006).
67
+ */
68
+ export declare function isSqliteBusyError(err: unknown): boolean;
69
+ /**
70
+ * Load session metas for feed outcome enrichment. The sessions index can be
71
+ * locked by a concurrent scanner/daemon — enrichment is best-effort, so a lock
72
+ * degrades to an empty list instead of crashing `agents feed` (RUSH-2006).
73
+ *
74
+ * `load` is injected so tests can throw a real lock-shaped error without
75
+ * mocking the session module.
76
+ */
77
+ export declare function loadSessionMetasForFeedEnrichment<T>(load: () => Promise<T[]>): Promise<{
78
+ metas: T[];
79
+ skippedLock: boolean;
80
+ }>;
63
81
  export declare function registerFeedCommand(program: Command): void;
64
82
  /** Feed view selector (RUSH-2015): decisions, progress, or both. */
65
83
  export type FeedFilter = 'needs' | 'updates' | 'all';
@@ -253,6 +253,39 @@ export function sessionHintsFromActive(sessions) {
253
253
  project: s.cwd ? projectKeyFromCwd(s.cwd) : undefined,
254
254
  }));
255
255
  }
256
+ /**
257
+ * True when a SQLite open/query failed because another writer holds the lock.
258
+ * better-sqlite3 surfaces this as message "database is locked" and/or code
259
+ * SQLITE_BUSY (RUSH-2006).
260
+ */
261
+ export function isSqliteBusyError(err) {
262
+ if (err == null)
263
+ return false;
264
+ const msg = err instanceof Error ? err.message : String(err);
265
+ const code = typeof err === 'object' && err !== null && 'code' in err
266
+ ? String(err.code ?? '')
267
+ : '';
268
+ return /SQLITE_BUSY|database is locked/i.test(msg) || /SQLITE_BUSY/i.test(code);
269
+ }
270
+ /**
271
+ * Load session metas for feed outcome enrichment. The sessions index can be
272
+ * locked by a concurrent scanner/daemon — enrichment is best-effort, so a lock
273
+ * degrades to an empty list instead of crashing `agents feed` (RUSH-2006).
274
+ *
275
+ * `load` is injected so tests can throw a real lock-shaped error without
276
+ * mocking the session module.
277
+ */
278
+ export async function loadSessionMetasForFeedEnrichment(load) {
279
+ try {
280
+ return { metas: await load(), skippedLock: false };
281
+ }
282
+ catch (err) {
283
+ if (isSqliteBusyError(err)) {
284
+ return { metas: [], skippedLock: true };
285
+ }
286
+ throw err;
287
+ }
288
+ }
256
289
  export function registerFeedCommand(program) {
257
290
  const feed = program
258
291
  .command('feed')
@@ -446,7 +479,17 @@ export function registerFeedCommand(program) {
446
479
  if (includeLocal) {
447
480
  sessions = await getActiveSessions();
448
481
  }
449
- const sessionMetas = includeLocal && sessions.length > 0 ? await discoverSessions({ all: true, limit: 5000 }) : [];
482
+ // discoverSessions touches sessions.db; under concurrent scan pressure it
483
+ // can throw SQLITE_BUSY. Outcome enrichment is best-effort — degrade with
484
+ // a warning rather than crash the whole feed (RUSH-2006).
485
+ let sessionMetas = [];
486
+ if (includeLocal && sessions.length > 0) {
487
+ const loaded = await loadSessionMetasForFeedEnrichment(() => discoverSessions({ all: true, limit: 5000 }));
488
+ if (loaded.skippedLock) {
489
+ console.error(chalk.yellow('Feed: session index is locked; skipping local outcome enrichment'));
490
+ }
491
+ sessionMetas = loaded.metas;
492
+ }
450
493
  const localSignals = buildSessionSignals(sessions, sessionMetas);
451
494
  if (opts.pause || opts.kill) {
452
495
  if (!includeLocal) {