@phnx-labs/agents-cli 1.22.22 → 1.22.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +402 -0
  2. package/README.md +13 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/attach.d.ts +2 -0
  5. package/dist/commands/attach.js +32 -7
  6. package/dist/commands/defaults.js +2 -0
  7. package/dist/commands/doctor.js +20 -7
  8. package/dist/commands/exec.js +100 -34
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +108 -5
  12. package/dist/commands/focus.js +455 -32
  13. package/dist/commands/fork.d.ts +20 -2
  14. package/dist/commands/fork.js +91 -64
  15. package/dist/commands/go.d.ts +39 -1
  16. package/dist/commands/go.js +112 -7
  17. package/dist/commands/harness-wizard.d.ts +206 -0
  18. package/dist/commands/harness-wizard.js +403 -0
  19. package/dist/commands/harness.d.ts +12 -0
  20. package/dist/commands/harness.js +97 -101
  21. package/dist/commands/insights.js +6 -2
  22. package/dist/commands/inspect.js +39 -5
  23. package/dist/commands/menubar.js +6 -1
  24. package/dist/commands/models.js +1 -0
  25. package/dist/commands/modes.d.ts +12 -0
  26. package/dist/commands/modes.js +147 -0
  27. package/dist/commands/resume.js +10 -3
  28. package/dist/commands/secrets-sync.js +11 -13
  29. package/dist/commands/secrets.d.ts +2 -0
  30. package/dist/commands/secrets.js +79 -47
  31. package/dist/commands/sessions-browser.d.ts +35 -0
  32. package/dist/commands/sessions-browser.js +140 -14
  33. package/dist/commands/sessions-resume.d.ts +23 -3
  34. package/dist/commands/sessions-resume.js +73 -19
  35. package/dist/commands/sessions.d.ts +91 -5
  36. package/dist/commands/sessions.js +359 -125
  37. package/dist/commands/setup-secrets.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/view.js +2 -0
  40. package/dist/commands/watchdog.js +13 -2
  41. package/dist/index.js +2 -1
  42. package/dist/lib/agent-modes.d.ts +49 -0
  43. package/dist/lib/agent-modes.js +70 -0
  44. package/dist/lib/agents.d.ts +1 -1
  45. package/dist/lib/agents.js +132 -0
  46. package/dist/lib/codex-policy.d.ts +17 -0
  47. package/dist/lib/codex-policy.js +48 -0
  48. package/dist/lib/crabbox/lease.d.ts +25 -0
  49. package/dist/lib/crabbox/lease.js +62 -0
  50. package/dist/lib/daemon.js +70 -0
  51. package/dist/lib/doctor-diff.d.ts +3 -0
  52. package/dist/lib/doctor-diff.js +15 -13
  53. package/dist/lib/event-stream.d.ts +3 -1
  54. package/dist/lib/event-stream.js +14 -1
  55. package/dist/lib/exec.d.ts +16 -0
  56. package/dist/lib/exec.js +175 -62
  57. package/dist/lib/feed-broadcast.d.ts +1 -20
  58. package/dist/lib/feed-broadcast.js +31 -1
  59. package/dist/lib/hooks/cache.js +36 -3
  60. package/dist/lib/hooks.d.ts +19 -7
  61. package/dist/lib/hooks.js +100 -40
  62. package/dist/lib/hosts/session-index.d.ts +4 -0
  63. package/dist/lib/hosts/session-index.js +7 -0
  64. package/dist/lib/manifest.d.ts +12 -2
  65. package/dist/lib/manifest.js +60 -5
  66. package/dist/lib/mcp.js +44 -0
  67. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  68. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  69. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  70. package/dist/lib/menubar/install-menubar.js +128 -6
  71. package/dist/lib/models.d.ts +0 -5
  72. package/dist/lib/models.js +48 -0
  73. package/dist/lib/plugin-marketplace.js +9 -0
  74. package/dist/lib/pricing/prices.json +119 -92
  75. package/dist/lib/pricing/table.js +13 -0
  76. package/dist/lib/refresh.d.ts +5 -0
  77. package/dist/lib/refresh.js +37 -33
  78. package/dist/lib/remote-agents-json.d.ts +29 -1
  79. package/dist/lib/remote-agents-json.js +47 -10
  80. package/dist/lib/resource-inventory.d.ts +79 -0
  81. package/dist/lib/resource-inventory.js +122 -0
  82. package/dist/lib/resources/mcp.js +2 -0
  83. package/dist/lib/resources/permissions.js +3 -0
  84. package/dist/lib/resources/types.d.ts +2 -1
  85. package/dist/lib/resources.js +8 -5
  86. package/dist/lib/run-defaults.d.ts +2 -0
  87. package/dist/lib/run-defaults.js +23 -2
  88. package/dist/lib/runner.js +50 -36
  89. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  90. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  91. package/dist/lib/secrets/bundles.js +157 -65
  92. package/dist/lib/secrets/filestore.d.ts +5 -3
  93. package/dist/lib/secrets/filestore.js +12 -8
  94. package/dist/lib/secrets/index.d.ts +15 -1
  95. package/dist/lib/secrets/index.js +148 -53
  96. package/dist/lib/secrets/reaper.d.ts +97 -0
  97. package/dist/lib/secrets/reaper.js +219 -0
  98. package/dist/lib/secrets/remote.d.ts +29 -0
  99. package/dist/lib/secrets/remote.js +37 -1
  100. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  101. package/dist/lib/secrets/sync-passphrase.js +78 -0
  102. package/dist/lib/session/active.d.ts +36 -1
  103. package/dist/lib/session/active.js +60 -19
  104. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  105. package/dist/lib/session/actor-sidecar.js +67 -4
  106. package/dist/lib/session/db.d.ts +1 -1
  107. package/dist/lib/session/db.js +32 -1
  108. package/dist/lib/session/discover.js +168 -0
  109. package/dist/lib/session/parse.d.ts +10 -0
  110. package/dist/lib/session/parse.js +98 -0
  111. package/dist/lib/session/recovery.d.ts +37 -0
  112. package/dist/lib/session/recovery.js +95 -0
  113. package/dist/lib/session/remote-list.d.ts +10 -1
  114. package/dist/lib/session/remote-list.js +2 -8
  115. package/dist/lib/session/remote.d.ts +57 -3
  116. package/dist/lib/session/remote.js +90 -26
  117. package/dist/lib/session/resume-command.d.ts +6 -0
  118. package/dist/lib/session/resume-command.js +8 -0
  119. package/dist/lib/session/session-cache.d.ts +173 -0
  120. package/dist/lib/session/session-cache.js +399 -0
  121. package/dist/lib/session/types.d.ts +1 -1
  122. package/dist/lib/session/types.js +1 -1
  123. package/dist/lib/session/width.d.ts +1 -1
  124. package/dist/lib/session/width.js +12 -2
  125. package/dist/lib/shims.d.ts +2 -2
  126. package/dist/lib/shims.js +62 -8
  127. package/dist/lib/staleness/writers/hooks.js +1 -1
  128. package/dist/lib/startup/command-registry.d.ts +1 -0
  129. package/dist/lib/startup/command-registry.js +2 -0
  130. package/dist/lib/sync-umbrella.d.ts +5 -0
  131. package/dist/lib/sync-umbrella.js +5 -4
  132. package/dist/lib/tmux/session.d.ts +4 -2
  133. package/dist/lib/tmux/session.js +5 -5
  134. package/dist/lib/types.d.ts +3 -1
  135. package/dist/lib/usage.d.ts +13 -0
  136. package/dist/lib/usage.js +215 -0
  137. package/dist/lib/versions.js +13 -2
  138. package/package.json +1 -1
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Daemon-warmed cross-surface cache for live session status (RUSH-2062).
3
+ *
4
+ * Problem: menubar / Factory / watchdog / CLI each independently run a full
5
+ * `sessions --active` gather (~9s wall / ~170MB) with no sharing. Measured on
6
+ * zion: back-to-back runs were 9.22s then 8.16s — no speedup, full SSH fan-out
7
+ * every call.
8
+ *
9
+ * Pattern mirrors {@link ../devices/stats-cache.ts} / {@link ../fleet-status.ts}:
10
+ *
11
+ * - **Daemon warm:** each daemon publishes THIS host's local active sessions
12
+ * on a short tick (`publishLocalActiveSessions`). Publish-own only — no
13
+ * cross-host SSH from the daemon (avoids the N² fan-out of RUSH-2061).
14
+ * - **Readers** (`loadLocalActiveSessions`, fleet path in `gatherActiveSessions`)
15
+ * serve the warm snapshot when it is within {@link DEFAULT_ACTIVE_CACHE_MAX_AGE_MS};
16
+ * `forceRefresh` / age expiry re-gathers live.
17
+ * - **Immutable memo:** per-session fields that only change when the transcript
18
+ * does (topic, label, cwd, …) are keyed on `(sessionId, transcriptMtimeMs)`.
19
+ * Live status fields (`status`, `activity`, `preview`, `pidAlive`, …) are
20
+ * NEVER stored in that memo — they only ride the short-lived snapshot.
21
+ *
22
+ * Best-effort disk IO: a missing/corrupt cache never throws; the caller falls
23
+ * through to a live gather.
24
+ */
25
+ import * as fs from 'fs';
26
+ import * as path from 'path';
27
+ import { getCacheDir } from '../state.js';
28
+ /** Snapshot file under `getCacheDir()` (regenerable, gitignored). */
29
+ const SNAPSHOT_FILE = '.active-sessions.json';
30
+ /** Immutable-field memo file (keyed by sessionId + transcript mtime). */
31
+ const IMMUTABLE_FILE = '.active-session-immutable.json';
32
+ /**
33
+ * How long a snapshot may be served before a reader re-gathers.
34
+ * Short on purpose: live status (running/idle/waiting) must not go stale.
35
+ * The daemon warm tick uses the same cadence (see {@link SESSION_CACHE_WARM_INTERVAL_MS}).
36
+ */
37
+ export const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS = 15_000;
38
+ /** Daemon warm interval — keep in sync with the setInterval in `lib/daemon.ts`. */
39
+ export const SESSION_CACHE_WARM_INTERVAL_MS = 15_000;
40
+ /** Kick off the first warm ~25s after daemon start (staggered off other ticks). */
41
+ export const SESSION_CACHE_WARM_KICKOFF_MS = 25_000;
42
+ /** Keys stored in the immutable memo (transcript-stable). */
43
+ export const IMMUTABLE_FIELD_KEYS = [
44
+ 'topic',
45
+ 'label',
46
+ 'name',
47
+ 'cwd',
48
+ 'project',
49
+ 'attachments',
50
+ 'startedAtMs',
51
+ 'version',
52
+ 'pr',
53
+ 'worktree',
54
+ 'ticket',
55
+ 'createdTickets',
56
+ 'spawnedTeam',
57
+ 'sessionFile',
58
+ 'owner',
59
+ 'assignedTask',
60
+ 'kind',
61
+ 'context',
62
+ ];
63
+ /**
64
+ * Live / volatile fields that MUST NOT be served from the immutable memo.
65
+ * They either change without a transcript write (pid death, attach state) or
66
+ * are short-window signals (preview, tok/s). The short snapshot TTL is the
67
+ * only cache that may carry them — and only as a whole-row snapshot.
68
+ */
69
+ export const LIVE_STATUS_KEYS = [
70
+ 'status',
71
+ 'activity',
72
+ 'preview',
73
+ 'tokPerSec',
74
+ 'awaitingReason',
75
+ 'question',
76
+ 'todos',
77
+ 'tail',
78
+ 'lastActivityMs',
79
+ 'hostLink',
80
+ 'presence',
81
+ 'pidAlive',
82
+ 'tmuxClients',
83
+ 'windowHeartbeatMs',
84
+ 'provenance',
85
+ 'rateLimited',
86
+ 'plan',
87
+ ];
88
+ // ── path overrides (test seam) ─────────────────────────────────────────────
89
+ let snapshotPathOverride = null;
90
+ let immutablePathOverride = null;
91
+ /** Test seam: redirect the snapshot file. Returns the previous override. */
92
+ export function setActiveSessionsSnapshotPathForTest(p) {
93
+ const prev = snapshotPathOverride;
94
+ snapshotPathOverride = p;
95
+ return prev;
96
+ }
97
+ /** Test seam: redirect the immutable-memo file. Returns the previous override. */
98
+ export function setImmutableMemoPathForTest(p) {
99
+ const prev = immutablePathOverride;
100
+ immutablePathOverride = p;
101
+ return prev;
102
+ }
103
+ function snapshotPath() {
104
+ return snapshotPathOverride ?? path.join(getCacheDir(), SNAPSHOT_FILE);
105
+ }
106
+ function immutablePath() {
107
+ return immutablePathOverride ?? path.join(getCacheDir(), IMMUTABLE_FILE);
108
+ }
109
+ // ── snapshot read / write ──────────────────────────────────────────────────
110
+ /** Read one scope from the snapshot file (best-effort; missing/corrupt → null). */
111
+ export function readActiveSessionsCache(scope) {
112
+ try {
113
+ const parsed = JSON.parse(fs.readFileSync(snapshotPath(), 'utf-8'));
114
+ if (!parsed || parsed.version !== 1 || !parsed.entries)
115
+ return null;
116
+ const entry = parsed.entries[scope];
117
+ if (!entry || !Array.isArray(entry.sessions) || typeof entry.capturedAt !== 'number')
118
+ return null;
119
+ return entry;
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ /**
126
+ * Persist a snapshot for one scope (best-effort). Other scopes are preserved
127
+ * so a local warm never drops a fleet snapshot a reader just wrote.
128
+ */
129
+ export function writeActiveSessionsCache(scope, sessions, opts = {}) {
130
+ const snap = {
131
+ version: 1,
132
+ scope,
133
+ capturedAt: opts.capturedAt ?? Date.now(),
134
+ sessions,
135
+ ...(opts.remoteDeviceCount !== undefined ? { remoteDeviceCount: opts.remoteDeviceCount } : {}),
136
+ };
137
+ try {
138
+ const dir = path.dirname(snapshotPath());
139
+ if (!fs.existsSync(dir))
140
+ fs.mkdirSync(dir, { recursive: true });
141
+ let entries = {};
142
+ try {
143
+ const prev = JSON.parse(fs.readFileSync(snapshotPath(), 'utf-8'));
144
+ if (prev?.entries && typeof prev.entries === 'object')
145
+ entries = { ...prev.entries };
146
+ }
147
+ catch {
148
+ // empty
149
+ }
150
+ entries[scope] = snap;
151
+ const body = { version: 1, entries };
152
+ // Atomic replace so a concurrent reader never sees a partial write.
153
+ const tmp = `${snapshotPath()}.tmp`;
154
+ fs.writeFileSync(tmp, JSON.stringify(body));
155
+ fs.renameSync(tmp, snapshotPath());
156
+ }
157
+ catch {
158
+ // best-effort
159
+ }
160
+ return snap;
161
+ }
162
+ /**
163
+ * True when a snapshot is still within the freshness window. Pure — the
164
+ * staleness invariant for live status lives here.
165
+ */
166
+ export function isActiveSnapshotFresh(capturedAt, nowMs, maxAgeMs = DEFAULT_ACTIVE_CACHE_MAX_AGE_MS) {
167
+ if (!Number.isFinite(capturedAt) || !Number.isFinite(maxAgeMs))
168
+ return false;
169
+ if (maxAgeMs < 0)
170
+ return false;
171
+ return nowMs - capturedAt <= maxAgeMs;
172
+ }
173
+ // ── immutable memo ─────────────────────────────────────────────────────────
174
+ /** Pull only the transcript-stable fields from a live row. */
175
+ export function pickImmutableFields(s) {
176
+ const out = {};
177
+ for (const k of IMMUTABLE_FIELD_KEYS) {
178
+ const v = s[k];
179
+ if (v !== undefined)
180
+ out[k] = v;
181
+ }
182
+ return out;
183
+ }
184
+ /**
185
+ * Transcript mtime used as the memo key. Prefer {@link ActiveSession.lastActivityMs}
186
+ * (the transcript's last write); fall back to `startedAtMs` when no activity
187
+ * stamp exists. Returns null when neither is known — caller must not memoize.
188
+ */
189
+ export function transcriptMtimeMs(s) {
190
+ if (typeof s.lastActivityMs === 'number' && Number.isFinite(s.lastActivityMs))
191
+ return s.lastActivityMs;
192
+ if (typeof s.startedAtMs === 'number' && Number.isFinite(s.startedAtMs))
193
+ return s.startedAtMs;
194
+ return null;
195
+ }
196
+ function readImmutableFile() {
197
+ try {
198
+ const parsed = JSON.parse(fs.readFileSync(immutablePath(), 'utf-8'));
199
+ if (parsed && parsed.version === 1 && parsed.entries && typeof parsed.entries === 'object') {
200
+ return parsed;
201
+ }
202
+ }
203
+ catch {
204
+ // missing/corrupt
205
+ }
206
+ return { version: 1, entries: {} };
207
+ }
208
+ /**
209
+ * Read memoized immutable fields for `(sessionId, mtimeMs)`.
210
+ * Returns null when missing OR when the stored mtime does not match — a
211
+ * transcript rewrite must force re-derivation of topic/label/etc.
212
+ */
213
+ export function readImmutableMemo(sessionId, mtimeMs) {
214
+ if (!sessionId || !Number.isFinite(mtimeMs))
215
+ return null;
216
+ const file = readImmutableFile();
217
+ const entry = file.entries[sessionId];
218
+ if (!entry || entry.mtimeMs !== mtimeMs)
219
+ return null;
220
+ // Defence in depth: strip any live-status key that snuck into a bad write.
221
+ return stripLiveStatusKeys({ ...entry.fields });
222
+ }
223
+ /** Persist immutable fields for `(sessionId, mtimeMs)`. Live keys are stripped. */
224
+ export function writeImmutableMemo(sessionId, mtimeMs, fields, nowMs = Date.now()) {
225
+ if (!sessionId || !Number.isFinite(mtimeMs))
226
+ return;
227
+ try {
228
+ const dir = path.dirname(immutablePath());
229
+ if (!fs.existsSync(dir))
230
+ fs.mkdirSync(dir, { recursive: true });
231
+ const file = readImmutableFile();
232
+ file.entries[sessionId] = {
233
+ mtimeMs,
234
+ fields: stripLiveStatusKeys({ ...fields }),
235
+ writtenAt: nowMs,
236
+ };
237
+ const tmp = `${immutablePath()}.tmp`;
238
+ fs.writeFileSync(tmp, JSON.stringify(file));
239
+ fs.renameSync(tmp, immutablePath());
240
+ }
241
+ catch {
242
+ // best-effort
243
+ }
244
+ }
245
+ /** Drop every live-status key from a field bag (defence in depth). */
246
+ export function stripLiveStatusKeys(fields) {
247
+ const out = { ...fields };
248
+ for (const k of LIVE_STATUS_KEYS) {
249
+ if (k in out)
250
+ delete out[k];
251
+ }
252
+ return out;
253
+ }
254
+ /**
255
+ * True when `fields` contains no live-status key. The invariant the tests pin:
256
+ * the immutable memo never carries status/activity/preview/etc.
257
+ */
258
+ export function assertNoLiveStatusFields(fields) {
259
+ for (const k of LIVE_STATUS_KEYS) {
260
+ if (k in fields && fields[k] !== undefined)
261
+ return false;
262
+ }
263
+ return true;
264
+ }
265
+ /**
266
+ * Write immutable memos for every session that has an id + transcript mtime.
267
+ * Called after a live gather so the next gather (when mtime is unchanged) can
268
+ * refill identity fields without re-deriving them.
269
+ */
270
+ export function updateImmutableMemos(sessions, nowMs = Date.now()) {
271
+ for (const s of sessions) {
272
+ if (!s.sessionId)
273
+ continue;
274
+ const mtime = transcriptMtimeMs(s);
275
+ if (mtime === null)
276
+ continue;
277
+ writeImmutableMemo(s.sessionId, mtime, pickImmutableFields(s), nowMs);
278
+ }
279
+ }
280
+ /**
281
+ * Fill missing immutable fields on a live row from the memo when the transcript
282
+ * mtime matches. Never overwrites a field the live gather already set, and
283
+ * never copies live-status keys.
284
+ */
285
+ export function applyImmutableMemo(s) {
286
+ if (!s.sessionId)
287
+ return s;
288
+ const mtime = transcriptMtimeMs(s);
289
+ if (mtime === null)
290
+ return s;
291
+ const memo = readImmutableMemo(s.sessionId, mtime);
292
+ if (!memo)
293
+ return s;
294
+ for (const k of IMMUTABLE_FIELD_KEYS) {
295
+ if (s[k] === undefined && memo[k] !== undefined) {
296
+ s[k] = memo[k];
297
+ }
298
+ }
299
+ return s;
300
+ }
301
+ /**
302
+ * Cache-first load of THIS host's active sessions. Default path serves the
303
+ * daemon-warmed snapshot when fresh; `forceRefresh` or an expired snapshot
304
+ * re-gathers and rewrites the cache.
305
+ */
306
+ export async function loadLocalActiveSessions(opts = {}) {
307
+ const now = opts.nowMs ?? Date.now();
308
+ const maxAge = opts.maxAgeMs ?? DEFAULT_ACTIVE_CACHE_MAX_AGE_MS;
309
+ const readCache = opts.readCache ?? readActiveSessionsCache;
310
+ const writeCache = opts.writeCache ?? writeActiveSessionsCache;
311
+ if (!opts.forceRefresh) {
312
+ const cached = readCache('local');
313
+ if (cached && isActiveSnapshotFresh(cached.capturedAt, now, maxAge)) {
314
+ return {
315
+ sessions: cached.sessions,
316
+ servedFromCache: true,
317
+ capturedAt: cached.capturedAt,
318
+ };
319
+ }
320
+ }
321
+ const gather = opts.gather ??
322
+ (async () => {
323
+ const { getActiveSessions } = await import('./active.js');
324
+ return getActiveSessions({ localOnly: true });
325
+ });
326
+ const sessions = await gather();
327
+ for (const s of sessions)
328
+ applyImmutableMemo(s);
329
+ updateImmutableMemos(sessions, now);
330
+ const snap = writeCache('local', sessions, { capturedAt: now });
331
+ return { sessions, servedFromCache: false, capturedAt: snap.capturedAt };
332
+ }
333
+ /**
334
+ * Cache-first load of the fleet-wide active set. A prior gather (or any surface
335
+ * that just paid the SSH cost) leaves a fleet snapshot; subsequent menubar /
336
+ * Factory / CLI / watchdog calls within the freshness window share it.
337
+ */
338
+ export async function loadFleetActiveSessions(opts) {
339
+ const now = opts.nowMs ?? Date.now();
340
+ const maxAge = opts.maxAgeMs ?? DEFAULT_ACTIVE_CACHE_MAX_AGE_MS;
341
+ const readCache = opts.readCache ?? readActiveSessionsCache;
342
+ const writeCache = opts.writeCache ?? writeActiveSessionsCache;
343
+ if (!opts.forceRefresh) {
344
+ const cached = readCache('fleet');
345
+ if (cached && isActiveSnapshotFresh(cached.capturedAt, now, maxAge)) {
346
+ return {
347
+ sessions: cached.sessions,
348
+ remoteDeviceCount: cached.remoteDeviceCount ?? 0,
349
+ servedFromCache: true,
350
+ capturedAt: cached.capturedAt,
351
+ };
352
+ }
353
+ }
354
+ const live = await opts.gather();
355
+ for (const s of live.sessions)
356
+ applyImmutableMemo(s);
357
+ updateImmutableMemos(live.sessions, now);
358
+ const snap = writeCache('fleet', live.sessions, {
359
+ capturedAt: now,
360
+ remoteDeviceCount: live.remoteDeviceCount,
361
+ });
362
+ // Also refresh the local slice so a `--local` reader benefits from this gather.
363
+ // Identity is machineId() when available; fall back to "rows with no machine
364
+ // stamp" so a pure-local gather that never set machine still warms the cache.
365
+ let self;
366
+ try {
367
+ const { machineId } = await import('../machine-id.js');
368
+ self = machineId();
369
+ }
370
+ catch {
371
+ self = undefined;
372
+ }
373
+ // Always rewrite the local slice when we know self — including the empty
374
+ // case. If the last local session just died, leaving a still-fresh snapshot
375
+ // with ghost rows would make watchdog / `--local` report sessions that are
376
+ // gone (review on RUSH-2062 / PR #2116).
377
+ if (self !== undefined) {
378
+ const localOnly = live.sessions.filter((s) => !s.machine || s.machine === self);
379
+ writeCache('local', localOnly, { capturedAt: now });
380
+ }
381
+ return {
382
+ sessions: live.sessions,
383
+ remoteDeviceCount: live.remoteDeviceCount,
384
+ servedFromCache: false,
385
+ capturedAt: snap.capturedAt,
386
+ };
387
+ }
388
+ /**
389
+ * Daemon warm entry point: live-gather THIS host and write the local snapshot.
390
+ * Never SSHes. Returns the published row count for the daemon log line.
391
+ */
392
+ export async function publishLocalActiveSessions(opts = {}) {
393
+ const result = await loadLocalActiveSessions({
394
+ forceRefresh: true,
395
+ gather: opts.gather,
396
+ nowMs: opts.nowMs,
397
+ });
398
+ return { sessions: result.sessions, capturedAt: result.capturedAt };
399
+ }
@@ -7,7 +7,7 @@
7
7
  * speaks these types.
8
8
  */
9
9
  /** Agents that store session data on disk and can be discovered by `agents sessions`. */
10
- export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'antigravity' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi' | 'droid' | 'cursor';
10
+ export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'antigravity' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi' | 'droid' | 'cursor' | 'muse';
11
11
  /** Effective permissions mode used to launch a managed agent session. */
12
12
  export type SessionRunMode = 'plan' | 'edit' | 'auto' | 'skip';
13
13
  /** All agents with session discovery support, in display order. */
@@ -7,7 +7,7 @@
7
7
  * speaks these types.
8
8
  */
9
9
  /** All agents with session discovery support, in display order. */
10
- export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'antigravity', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid', 'cursor'];
10
+ export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'antigravity', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid', 'cursor', 'muse'];
11
11
  /**
12
12
  * True when `agent` stores session data `agents sessions` can discover (a member
13
13
  * of {@link SESSION_AGENTS}). The single predicate every session-index writer
@@ -8,7 +8,7 @@
8
8
  * cell measures and truncates through this module so alignment is computed once,
9
9
  * correctly, from the same source of truth.
10
10
  */
11
- /** Strip SGR colour escapes so width is measured on visible characters only. */
11
+ /** Strip SGR colour + OSC-8 hyperlink escapes so width is measured on visible characters only. */
12
12
  export declare function stripAnsi(s: string): string;
13
13
  /** Visible display width of a string, ANSI-aware and wide-char-aware. */
14
14
  export declare function stringWidth(s: string): number;
@@ -10,9 +10,19 @@
10
10
  */
11
11
  /** SGR colour sequences emitted by chalk (e.g. `\x1b[32m`). */
12
12
  const SGR_REGEX = /\x1b\[[0-9;]*m/g;
13
- /** Strip SGR colour escapes so width is measured on visible characters only. */
13
+ /**
14
+ * OSC-8 hyperlink sequences (`\x1b]8;;<url>\x1b\\` … `\x1b]8;;\x1b\\`, or with a
15
+ * BEL `\x07` terminator) emitted by `osc8()` in render.ts for clickable
16
+ * ticket/PR/path badges. These carry the URL, which is NOT visible — leaving it
17
+ * in over-counts width and, worse, lets a truncation land *inside* the escape and
18
+ * corrupt it. Stripping both the opener and closer leaves only the visible label
19
+ * between them, so width is measured on what the user sees and `truncateToWidth`
20
+ * can never cut a hyperlink in half.
21
+ */
22
+ const OSC8_REGEX = /\x1b\]8;[^;]*;.*?(?:\x1b\\|\x07)/g;
23
+ /** Strip SGR colour + OSC-8 hyperlink escapes so width is measured on visible characters only. */
14
24
  export function stripAnsi(s) {
15
- return s.replace(SGR_REGEX, '');
25
+ return s.replace(OSC8_REGEX, '').replace(SGR_REGEX, '');
16
26
  }
17
27
  /**
18
28
  * Display cells for one code point: 0 for zero-width combining/ZWJ/variation
@@ -77,7 +77,7 @@ export interface ConflictInfo {
77
77
  * top-level entry add/remove — deep edits to plugin contents won't
78
78
  * trigger auto-resync, run `agents sync` for that.
79
79
  */
80
- export declare const SHIM_SCHEMA_VERSION = 27;
80
+ export declare const SHIM_SCHEMA_VERSION = 29;
81
81
  /**
82
82
  * Generate the full bash shim script for the given agent. The returned string
83
83
  * is written to ~/.agents/shims/{cliCommand} and made executable.
@@ -160,7 +160,7 @@ export declare function removeShim(agent: AgentId): boolean;
160
160
  * the versioned home (installer run with GROK_HOME set, or grok
161
161
  * self-update under the shim).
162
162
  */
163
- export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 13;
163
+ export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 15;
164
164
  /**
165
165
  * Agents whose config directory can be relocated by an environment variable
166
166
  * (the per-agent `managedEnv` block in `generateVersionedAliasScript` below).
package/dist/lib/shims.js CHANGED
@@ -18,6 +18,7 @@ import { getShimsDir, getVersionsDir, getBackupsDir, getHistoryDir, ensureAgents
18
18
  export { getShimsDir };
19
19
  import { AGENTS, agentConfigDirName, readAuthAccountIdentity } from './agents.js';
20
20
  import { codexHomeShimBash } from './codex-home.js';
21
+ import { codexPolicyArgs } from './codex-policy.js';
21
22
  /**
22
23
  * Files and directories to always skip during conflict detection and migration.
23
24
  * These are never user config that should be migrated.
@@ -222,12 +223,19 @@ async function promptConflictStrategy(conflictInfos) {
222
223
  // The old dispatcher checked only the global dir, so a pinned grok that
223
224
  // installed into the versioned home fell through to the "not installed"
224
225
  // error.
225
- export const SHIM_SCHEMA_VERSION = 27;
226
+ export const SHIM_SCHEMA_VERSION = 29;
226
227
  /** Internal marker string used to embed the schema version in shim scripts. */
227
228
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
228
229
  function shellQuote(value) {
229
230
  return `'${value.replace(/'/g, `'\\''`)}'`;
230
231
  }
232
+ function codexShimLaunchArgs() {
233
+ return [
234
+ '-c',
235
+ 'check_for_update_on_startup=false',
236
+ ...codexPolicyArgs('edit'),
237
+ ].map(shellQuote).join(' ');
238
+ }
231
239
  function getAgentsBinForGeneratedShim() {
232
240
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'index.js');
233
241
  }
@@ -291,8 +299,19 @@ export OPENCODE_CONFIG_DIR="$VERSION_DIR/home/.config/opencode"
291
299
  # mcp.json, sessions, skills, hooks). Point it at the versioned home.
292
300
  export KIMI_CODE_HOME="$VERSION_DIR/home/${configDirName}"
293
301
  `
294
- : '';
295
- const launchArgs = agent === 'codex' ? ' -c check_for_update_on_startup=false' : '';
302
+ : agent === 'muse'
303
+ ? `
304
+ # Muse Code has no MUSE_CONFIG_DIR. It resolves config via XDG:
305
+ # $XDG_CONFIG_HOME/muse (settings, skills, hooks, auth)
306
+ # $XDG_DATA_HOME/muse (sessions, plugins)
307
+ # Pin XDG into the version home so managed runs never walk the adopt-time
308
+ # ~/.config/muse -> version-home symlink — Muse refuses agent-definition
309
+ # sources that are SymlinkOrReparse (exit 1). Same idea as CLAUDE_CONFIG_DIR.
310
+ export XDG_CONFIG_HOME="$VERSION_DIR/home/.config"
311
+ export XDG_DATA_HOME="$VERSION_DIR/home/.local/share"
312
+ `
313
+ : '';
314
+ const launchArgs = agent === 'codex' ? ` ${codexShimLaunchArgs()}` : '';
296
315
  return `#!/bin/bash
297
316
  # Auto-generated by agents-cli - do not edit
298
317
  # Shim for ${agentConfig.name}
@@ -549,6 +568,22 @@ elif [ "$AGENT" = "droid" ]; then
549
568
  esac
550
569
  fi
551
570
  fi
571
+ elif [ "$AGENT" = "muse" ]; then
572
+ # Muse Code installs a self-updating launcher at ~/.local/bin/muse (curl
573
+ # installer from dev.meta.ai). No npm package. Same shims-dir re-exec guard
574
+ # as droid.
575
+ BINARY=$(adopted_original_bin || echo "")
576
+ if [ -z "$BINARY" ]; then
577
+ MUSE_BINARY="$HOME/.local/bin/muse"
578
+ if [ -x "$MUSE_BINARY" ] && [ "$(readlink -f "$MUSE_BINARY" 2>/dev/null)" != "$(readlink -f "$AGENTS_USER_DIR/.cache/shims/$CLI_COMMAND" 2>/dev/null)" ]; then
579
+ BINARY="$MUSE_BINARY"
580
+ else
581
+ BINARY=$(command -v muse 2>/dev/null || echo "")
582
+ case "$(readlink -f "$BINARY" 2>/dev/null)" in
583
+ "$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
584
+ esac
585
+ fi
586
+ fi
552
587
  else
553
588
  BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
554
589
  fi
@@ -847,7 +882,7 @@ export function removeShim(agent) {
847
882
  * the versioned home (installer run with GROK_HOME set, or grok
848
883
  * self-update under the shim).
849
884
  */
850
- export const VERSIONED_ALIAS_SCHEMA_VERSION = 13;
885
+ export const VERSIONED_ALIAS_SCHEMA_VERSION = 15;
851
886
  /** Internal marker string used to embed the schema version in versioned alias scripts. */
852
887
  const VERSIONED_ALIAS_VERSION_MARKER = 'agents-versioned-alias-version:';
853
888
  // The version string is interpolated into a generated bash script and into
@@ -874,7 +909,7 @@ function assertSafeVersion(version) {
874
909
  * KEEP IN SYNC with the `managedEnv` switch in `generateVersionedAliasScript`.
875
910
  * The colocated test `shims.isolation-capability.test.ts` enforces this.
876
911
  */
877
- export const CONFIG_ENV_ISOLATED_AGENTS = ['claude', 'codex', 'copilot', 'grok', 'kimi', 'opencode'];
912
+ export const CONFIG_ENV_ISOLATED_AGENTS = ['claude', 'codex', 'copilot', 'grok', 'kimi', 'opencode', 'muse'];
878
913
  /**
879
914
  * Whether an agent supports a clean `--isolated` install — i.e. its config
880
915
  * location can be redirected by an env var so the isolated copy stays fully
@@ -932,8 +967,16 @@ export OPENCODE_CONFIG_DIR="$HOME/.agents/.history/versions/${agent}/${version}/
932
967
  # mcp.json, sessions, skills, hooks). Point direct aliases at the versioned home.
933
968
  export KIMI_CODE_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/${configDirName}"
934
969
  `
935
- : '';
936
- const launchArgs = agent === 'codex' ? ' -c check_for_update_on_startup=false' : '';
970
+ : agent === 'muse'
971
+ ? `
972
+ # Muse Code: no dedicated config env var. Pin XDG so config/sessions live under
973
+ # the version home as real directories (not via the adopt symlink at
974
+ # ~/.config/muse, which Muse rejects with SymlinkOrReparse).
975
+ export XDG_CONFIG_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/.config"
976
+ export XDG_DATA_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/.local/share"
977
+ `
978
+ : '';
979
+ const launchArgs = agent === 'codex' ? ` ${codexShimLaunchArgs()}` : '';
937
980
  // Resolve the binary the same way the main shim does (see generateShimScript).
938
981
  // Grok and Droid do NOT ship into node_modules/.bin — Grok downloads a native
939
982
  // binary to ~/.grok/downloads and Droid (Factory AI) installs a standalone
@@ -987,7 +1030,18 @@ else
987
1030
  "$HOME/.agents/.cache/shims/"*) BINARY="" ;;
988
1031
  esac
989
1032
  fi`
990
- : `BINARY="${versionDir}/node_modules/.bin/${agentConfig.cliCommand}"`;
1033
+ : agent === 'muse'
1034
+ ? `# Muse Code installs a self-updating launcher at ~/.local/bin/muse.
1035
+ MUSE_BINARY="$HOME/.local/bin/muse"
1036
+ if [ -x "$MUSE_BINARY" ]; then
1037
+ BINARY="$MUSE_BINARY"
1038
+ else
1039
+ BINARY=$(command -v muse 2>/dev/null || echo "")
1040
+ case "$BINARY" in
1041
+ "$HOME/.agents/.cache/shims/"*) BINARY="" ;;
1042
+ esac
1043
+ fi`
1044
+ : `BINARY="${versionDir}/node_modules/.bin/${agentConfig.cliCommand}"`;
991
1045
  return `#!/bin/bash
992
1046
  # Auto-generated by agents-cli - do not edit
993
1047
  # ${VERSIONED_ALIAS_VERSION_MARKER} ${VERSIONED_ALIAS_SCHEMA_VERSION}
@@ -90,7 +90,7 @@ function buildHooksWriter(agent) {
90
90
  // registerHooksForGrok — file copy alone only sees top-level available.hooks
91
91
  // names (RUSH-1353). Copilot/Kiro/Goose load managed *.json under their
92
92
  // hooks dirs the same way.
93
- if (agent === 'claude' || agent === 'codex' || agent === 'antigravity' || agent === 'kimi' || agent === 'droid' || agent === 'copilot' || agent === 'kiro' || agent === 'goose' || agent === 'cursor' || agent === 'grok' || agent === 'hermes') {
93
+ if (agent === 'claude' || agent === 'codex' || agent === 'antigravity' || agent === 'kimi' || agent === 'droid' || agent === 'copilot' || agent === 'kiro' || agent === 'goose' || agent === 'cursor' || agent === 'grok' || agent === 'hermes' || agent === 'muse') {
94
94
  registerHooksToSettings(agent, versionHome);
95
95
  }
96
96
  return { synced };
@@ -51,6 +51,7 @@ export declare const loadFork: ModuleLoader;
51
51
  export declare const loadDefaults: ModuleLoader;
52
52
  export declare const loadSet: ModuleLoader;
53
53
  export declare const loadModels: ModuleLoader;
54
+ export declare const loadModes: ModuleLoader;
54
55
  export declare const loadPrune: ModuleLoader;
55
56
  export declare const loadTrash: ModuleLoader;
56
57
  export declare const loadRestore: ModuleLoader;
@@ -29,6 +29,7 @@ export const loadFork = async () => (await import('../../commands/fork.js')).reg
29
29
  export const loadDefaults = async () => (await import('../../commands/defaults.js')).registerDefaultsCommands;
30
30
  export const loadSet = async () => (await import('../../commands/set.js')).registerSetCommand;
31
31
  export const loadModels = async () => (await import('../../commands/models.js')).registerModelsCommand;
32
+ export const loadModes = async () => (await import('../../commands/modes.js')).registerModesCommand;
32
33
  export const loadPrune = async () => (await import('../../commands/prune.js')).registerPruneCommand;
33
34
  export const loadTrash = async () => (await import('../../commands/trash.js')).registerTrashCommands;
34
35
  export const loadRestore = async () => (await import('../../commands/trash.js')).registerRestoreCommand;
@@ -154,6 +155,7 @@ export const COMMAND_LOADERS = {
154
155
  defaults: [loadDefaults],
155
156
  set: [loadSet],
156
157
  models: [loadModels],
158
+ modes: [loadModes],
157
159
  trash: [loadTrash],
158
160
  restore: [loadRestore],
159
161
  doctor: [loadDoctor],
@@ -68,6 +68,11 @@ export interface RunUmbrellaArgs {
68
68
  yes: boolean;
69
69
  /** Secrets passphrase, if available (env var or prompt). Undefined => skip secrets. */
70
70
  passphrase?: string;
71
+ /**
72
+ * Suppress human progress from the reconcile stage (`refresh`). Required so
73
+ * `agents sync --json` / fleet fan-out leave stdout as a single JSON object.
74
+ */
75
+ quiet?: boolean;
71
76
  }
72
77
  /**
73
78
  * Execute the planned stages in order: repos -> secrets -> reconcile.