@phnx-labs/agents-cli 1.22.22 → 1.22.23

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 (91) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/README.md +8 -2
  3. package/dist/commands/doctor.js +15 -7
  4. package/dist/commands/exec.js +20 -6
  5. package/dist/commands/focus.d.ts +76 -4
  6. package/dist/commands/focus.js +219 -40
  7. package/dist/commands/fork.d.ts +20 -2
  8. package/dist/commands/fork.js +91 -64
  9. package/dist/commands/go.d.ts +25 -0
  10. package/dist/commands/go.js +63 -2
  11. package/dist/commands/harness-wizard.d.ts +206 -0
  12. package/dist/commands/harness-wizard.js +403 -0
  13. package/dist/commands/harness.d.ts +12 -0
  14. package/dist/commands/harness.js +97 -101
  15. package/dist/commands/resume.js +10 -3
  16. package/dist/commands/secrets.js +25 -30
  17. package/dist/commands/sessions-resume.d.ts +21 -3
  18. package/dist/commands/sessions-resume.js +50 -11
  19. package/dist/commands/sessions.d.ts +81 -5
  20. package/dist/commands/sessions.js +325 -66
  21. package/dist/commands/watchdog.js +13 -2
  22. package/dist/lib/agents.d.ts +1 -1
  23. package/dist/lib/agents.js +132 -0
  24. package/dist/lib/codex-policy.d.ts +17 -0
  25. package/dist/lib/codex-policy.js +48 -0
  26. package/dist/lib/crabbox/lease.d.ts +25 -0
  27. package/dist/lib/crabbox/lease.js +62 -0
  28. package/dist/lib/daemon.js +70 -0
  29. package/dist/lib/exec.d.ts +4 -0
  30. package/dist/lib/exec.js +88 -54
  31. package/dist/lib/feed-broadcast.d.ts +1 -20
  32. package/dist/lib/feed-broadcast.js +31 -1
  33. package/dist/lib/hooks.js +12 -2
  34. package/dist/lib/mcp.js +44 -0
  35. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
  36. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
  38. package/dist/lib/models.d.ts +0 -5
  39. package/dist/lib/models.js +48 -0
  40. package/dist/lib/plugin-marketplace.js +9 -0
  41. package/dist/lib/pricing/prices.json +119 -92
  42. package/dist/lib/pricing/table.js +13 -0
  43. package/dist/lib/remote-agents-json.d.ts +29 -1
  44. package/dist/lib/remote-agents-json.js +47 -10
  45. package/dist/lib/resources/mcp.js +2 -0
  46. package/dist/lib/resources/permissions.js +3 -0
  47. package/dist/lib/resources/types.d.ts +2 -1
  48. package/dist/lib/runner.js +28 -19
  49. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  50. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  51. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  52. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  53. package/dist/lib/secrets/index.d.ts +15 -1
  54. package/dist/lib/secrets/index.js +118 -49
  55. package/dist/lib/secrets/reaper.d.ts +87 -0
  56. package/dist/lib/secrets/reaper.js +184 -0
  57. package/dist/lib/secrets/remote.d.ts +29 -0
  58. package/dist/lib/secrets/remote.js +37 -1
  59. package/dist/lib/session/active.d.ts +36 -1
  60. package/dist/lib/session/active.js +60 -19
  61. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  62. package/dist/lib/session/actor-sidecar.js +67 -4
  63. package/dist/lib/session/db.d.ts +1 -1
  64. package/dist/lib/session/db.js +32 -1
  65. package/dist/lib/session/discover.js +168 -0
  66. package/dist/lib/session/parse.d.ts +10 -0
  67. package/dist/lib/session/parse.js +98 -0
  68. package/dist/lib/session/remote-list.d.ts +10 -1
  69. package/dist/lib/session/remote-list.js +2 -8
  70. package/dist/lib/session/remote.d.ts +57 -3
  71. package/dist/lib/session/remote.js +90 -26
  72. package/dist/lib/session/resume-command.d.ts +6 -0
  73. package/dist/lib/session/resume-command.js +8 -0
  74. package/dist/lib/session/session-cache.d.ts +173 -0
  75. package/dist/lib/session/session-cache.js +399 -0
  76. package/dist/lib/session/types.d.ts +1 -1
  77. package/dist/lib/session/types.js +1 -1
  78. package/dist/lib/session/width.d.ts +1 -1
  79. package/dist/lib/session/width.js +12 -2
  80. package/dist/lib/shims.d.ts +2 -2
  81. package/dist/lib/shims.js +40 -5
  82. package/dist/lib/staleness/writers/hooks.js +1 -1
  83. package/dist/lib/types.d.ts +1 -1
  84. package/dist/lib/usage.d.ts +13 -0
  85. package/dist/lib/usage.js +215 -0
  86. package/dist/lib/versions.js +13 -2
  87. package/package.json +1 -1
  88. package/dist/bin/agents +0 -0
  89. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  90. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  91. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
@@ -160,6 +160,9 @@ export function parseSession(filePath, agent, opts = {}) {
160
160
  case 'cursor':
161
161
  events = parseCursor(filePath);
162
162
  break;
163
+ case 'muse':
164
+ events = parseMuse(filePath);
165
+ break;
163
166
  }
164
167
  // Chokepoint: every string field that originated in an untrusted session
165
168
  // file gets stripped of terminal escapes here, so renderers downstream can
@@ -206,6 +209,13 @@ export function detectAgent(filePath) {
206
209
  return 'droid';
207
210
  if (filePath.includes('/.cursor/') || filePath.includes('\\.cursor\\'))
208
211
  return 'cursor';
212
+ // Muse sessions: ~/.local/share/muse/sessions/YYYY/MM/DD/<uuid>/session.jsonl
213
+ if (filePath.includes('/muse/sessions/') ||
214
+ filePath.includes('\\muse\\sessions\\') ||
215
+ filePath.includes('/.local/share/muse/') ||
216
+ filePath.includes('\\.local\\share\\muse\\')) {
217
+ return 'muse';
218
+ }
209
219
  // Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
210
220
  const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
211
221
  if (cloudMatch)
@@ -1504,6 +1514,94 @@ export function parseRush(filePath) {
1504
1514
  // system_prompt, message_count, messages: [{role, content}, ...] }
1505
1515
  // Content may be a string or an array of text parts.
1506
1516
  // ---------------------------------------------------------------------------
1517
+ /**
1518
+ * Muse Code session.jsonl → normalized events.
1519
+ *
1520
+ * Muse records an append-only event log. We map:
1521
+ * - runtime.command_intake.received / turn_submit → user message
1522
+ * - assistant_message_committed → assistant message
1523
+ * - model_completed.usage → usage
1524
+ * - tool-related events when present
1525
+ */
1526
+ export function parseMuse(filePath) {
1527
+ const content = safeReadSessionFile(filePath);
1528
+ const lines = content.split('\n').filter((l) => l.trim());
1529
+ const events = [];
1530
+ const museTs = (raw) => {
1531
+ if (typeof raw?.recorded_at === 'number') {
1532
+ const v = raw.recorded_at;
1533
+ const ms = v > 1e14 ? Math.floor(v / 1000) : v;
1534
+ return new Date(ms).toISOString();
1535
+ }
1536
+ return new Date().toISOString();
1537
+ };
1538
+ for (const line of lines) {
1539
+ let raw;
1540
+ try {
1541
+ raw = JSON.parse(line);
1542
+ }
1543
+ catch {
1544
+ continue;
1545
+ }
1546
+ const timestamp = museTs(raw);
1547
+ const payloadType = raw.payload_type;
1548
+ const payload = raw.payload;
1549
+ const event = payload?.event ?? payload;
1550
+ if (payloadType === 'runtime.command_intake.received') {
1551
+ const cmd = payload?.record?.command;
1552
+ if (cmd?.kind === 'turn_submit' && typeof cmd.prompt === 'string' && cmd.prompt.trim()) {
1553
+ events.push({
1554
+ type: 'message',
1555
+ agent: 'muse',
1556
+ timestamp,
1557
+ role: 'user',
1558
+ content: cmd.prompt.trim(),
1559
+ });
1560
+ }
1561
+ continue;
1562
+ }
1563
+ if (event?.kind === 'assistant_message_committed' && typeof event.text === 'string') {
1564
+ events.push({
1565
+ type: 'message',
1566
+ agent: 'muse',
1567
+ timestamp,
1568
+ role: 'assistant',
1569
+ content: event.text,
1570
+ });
1571
+ continue;
1572
+ }
1573
+ if (event?.kind === 'model_completed' && event.usage && typeof event.usage === 'object') {
1574
+ const u = event.usage;
1575
+ events.push({
1576
+ type: 'usage',
1577
+ agent: 'muse',
1578
+ timestamp,
1579
+ content: JSON.stringify({
1580
+ input_tokens: u.input_tokens ?? 0,
1581
+ output_tokens: u.output_tokens ?? 0,
1582
+ cached_tokens: u.cached_tokens ?? 0,
1583
+ reasoning_tokens: u.reasoning_tokens ?? 0,
1584
+ }),
1585
+ });
1586
+ continue;
1587
+ }
1588
+ // Tool call / result shapes when Muse records them under task events
1589
+ if (event?.kind === 'tool_call' || event?.kind === 'tool.called') {
1590
+ const tool = event.tool || event.name || event.tool_name;
1591
+ if (typeof tool === 'string') {
1592
+ events.push({
1593
+ type: 'tool_use',
1594
+ agent: 'muse',
1595
+ timestamp,
1596
+ tool,
1597
+ args: typeof event.args === 'object' ? event.args : undefined,
1598
+ callId: typeof event.call_id === 'string' ? event.call_id : undefined,
1599
+ });
1600
+ }
1601
+ }
1602
+ }
1603
+ return events;
1604
+ }
1507
1605
  /** Parse a Hermes session JSON file into normalized events. */
1508
1606
  export function parseHermes(filePath) {
1509
1607
  let session;
@@ -73,7 +73,16 @@ export declare function isAutomaticSessionPeer(d: DeviceProfile, self: string):
73
73
  * address. `forwardedArgs` are the caller's own sessions args (query + filters,
74
74
  * already `--json`) so every peer returns the same slice this machine asked for.
75
75
  */
76
- export declare function gatherRemoteList(forwardedArgs: string[], hosts?: string[]): Promise<RemoteListResult>;
76
+ export interface GatherRemoteListOptions {
77
+ /**
78
+ * Opt-in early-exit for a globally-unique id lookup (a full UUID): the first
79
+ * peer to return the matching row resolves the fan-out and cancels the rest.
80
+ * Omitted for browse/label/prefix sweeps, which must wait for every peer to
81
+ * know whether the match is unique or conflicting.
82
+ */
83
+ isDefinitive?: (session: SessionMeta, machine: string) => boolean;
84
+ }
85
+ export declare function gatherRemoteList(forwardedArgs: string[], hosts?: string[], opts?: GatherRemoteListOptions): Promise<RemoteListResult>;
77
86
  export interface RemoteToolSearchResult {
78
87
  envelopes: Array<{
79
88
  machine: string;
@@ -191,19 +191,13 @@ export function isAutomaticSessionPeer(d, self) {
191
191
  return false;
192
192
  return d.platform === 'windows' || d.platform === 'linux' || d.platform === 'macos';
193
193
  }
194
- /**
195
- * Gather listing sessions from other machines. With an explicit `hosts` list
196
- * (from `--host`), fan out to exactly those. Otherwise sweep the registered,
197
- * online devices from `ag devices`, excluding this machine and any without an
198
- * address. `forwardedArgs` are the caller's own sessions args (query + filters,
199
- * already `--json`) so every peer returns the same slice this machine asked for.
200
- */
201
- export async function gatherRemoteList(forwardedArgs, hosts) {
194
+ export async function gatherRemoteList(forwardedArgs, hosts, opts) {
202
195
  const safeResolver = forwardedArgs.includes('--resolve-safe-v1');
203
196
  const result = await gatherRemoteAgentsJson({
204
197
  args: forwardedArgs,
205
198
  noFanoutEnv: NO_FANOUT_ENV,
206
199
  hosts,
200
+ earlyExit: opts?.isDefinitive ? { isDefinitive: opts.isDefinitive } : undefined,
207
201
  parse: (stdout, machine) => parseRemoteListPayload(stdout, machine, safeResolver),
208
202
  });
209
203
  return {
@@ -54,20 +54,74 @@ export declare function classifySshFailure(res: {
54
54
  error?: Error | null;
55
55
  status: number | null;
56
56
  }): SshOutcome;
57
+ /**
58
+ * How long a successful remote fetch may be served without re-SSHing.
59
+ * Short on purpose: session listings must stay near-live (RUSH-2062). Match the
60
+ * active-session snapshot window so surfaces share one freshness model.
61
+ */
62
+ export declare const REMOTE_CACHE_MAX_AGE_MS = 15000;
57
63
  /**
58
64
  * Deterministic cache path for a (host, forwarded-args) pair. The forwarded args
59
65
  * are hashed so distinct queries cache independently; the host stays readable in
60
66
  * the filename (sanitised so `user@host` and aliases are filesystem-safe).
61
67
  */
62
68
  export declare function remoteCachePath(host: string, forwardedArgs: string[]): string;
69
+ /**
70
+ * Pure freshness check for a remote-sessions cache entry. A reachable host
71
+ * skips SSH only while this returns true; unreachable fallback ignores age.
72
+ */
73
+ export declare function isRemoteCacheFresh(mtimeMs: number, nowMs: number, maxAgeMs?: number): boolean;
74
+ export interface RemoteCacheHit {
75
+ output: string;
76
+ mtimeMs: number;
77
+ }
78
+ /**
79
+ * Read a cached remote fetch. When `maxAgeMs` is set, returns null if the
80
+ * entry is older than the window (cache-first path for reachable hosts).
81
+ * Omit `maxAgeMs` to accept any age (unreachable fallback).
82
+ */
83
+ export declare function readRemoteCache(host: string, forwardedArgs: string[], opts?: {
84
+ maxAgeMs?: number;
85
+ nowMs?: number;
86
+ }): RemoteCacheHit | null;
63
87
  /** Banner shown above replayed cache rows when the peer is offline. */
64
88
  export declare function formatStaleBanner(host: string, mtimeMs: number): string;
65
89
  /** Message shown when a host is unreachable and there is no cache to fall back to. */
66
90
  export declare function formatUnreachable(host: string): string;
91
+ /** Persist a successful fetch for later cache-first / offline replay.
92
+ * Best-effort: a cache write must never break the live query. Exported for tests. */
93
+ export declare function writeRemoteCache(host: string, forwardedArgs: string[], output: string): void;
94
+ /**
95
+ * Serve a *fresh* cache entry for a reachable-host skip (no banner — the data
96
+ * is still within the freshness window). Returns false when missing/stale so
97
+ * the caller SSHes. RUSH-2062: without this, a reachable host never skipped SSH
98
+ * even when the cache was just written.
99
+ */
100
+ export declare function serveWarmRemoteCache(host: string, forwardedArgs: string[], opts?: {
101
+ maxAgeMs?: number;
102
+ nowMs?: number;
103
+ }): boolean;
104
+ /** Replay a cached fetch for an unreachable host (any age). Banner goes to
105
+ * stderr (so a piped stdout stays exactly the cached rows); returns false when
106
+ * nothing is cached for this exact (host, query). */
107
+ export declare function replayRemoteCache(host: string, forwardedArgs: string[]): boolean;
108
+ export interface RunRemoteSessionsOptions {
109
+ /** Skip warm cache and SSH every host (force-refresh). */
110
+ forceRefresh?: boolean;
111
+ /** Override freshness window for the warm path. */
112
+ maxAgeMs?: number;
113
+ /** Clock (tests). */
114
+ nowMs?: number;
115
+ }
67
116
  /**
68
117
  * Run the current `agents sessions` invocation on one or more remote machines over
69
- * SSH, writing each remote's output to the terminal. A successful fetch is cached;
70
- * an unreachable host falls back to that cache (with a stale banner) when present.
118
+ * SSH, writing each remote's output to the terminal.
119
+ *
120
+ * Cache policy (RUSH-2062):
121
+ * - **Default:** serve a fresh cache hit without SSH; SSH only on miss/stale.
122
+ * - **`forceRefresh`:** always SSH, then rewrite the cache.
123
+ * - **Unreachable:** fall back to any cached output (with a stale banner).
124
+ *
71
125
  * Sets `process.exitCode = 1` if any host could not be answered (live or cached).
72
126
  * Reads the invocation from `process.argv` (override via `argv` for testing).
73
127
  *
@@ -75,4 +129,4 @@ export declare function formatUnreachable(host: string): string;
75
129
  * Session output is small and the remote returns quickly, so buffering is
76
130
  * imperceptible; `maxBuffer` is generous for the rare large `--markdown <id>` dump.
77
131
  */
78
- export declare function runRemoteSessions(hosts: string[], argv?: string[]): void;
132
+ export declare function runRemoteSessions(hosts: string[], argv?: string[], opts?: RunRemoteSessionsOptions): void;
@@ -9,13 +9,14 @@
9
9
  * upfront copy, always current, but the peer must be reachable. SSH access is the
10
10
  * only auth — if you can `ssh <host>`, you own the box (no identity layer by design).
11
11
  *
12
- * Offline degradation (no sync, still fetch-first): every *successful* fetch is
12
+ * Cache-first (RUSH-2062) + offline degradation: every *successful* fetch is
13
13
  * cached to `~/.agents/.cache/remote-sessions/`, keyed by host + the exact query.
14
- * When a later run finds the host unreachable, the cache is replayed with a clearly
15
- * labelled "showing cached results" banner instead of returning nothing. The cache
16
- * is a byproduct of fetches you already made never a background job, freely
17
- * deletable so the fetch-don't-replicate model holds; this is just graceful
18
- * degradation when the peer is asleep.
14
+ * A later call with a *fresh* cache serves it without SSH (same daemon-warmed
15
+ * shared-cache shape as `stats-cache.ts`) so a reachable host is not re-probed
16
+ * on every menubar/CLI/watchdog tick. When the host is unreachable, any cache
17
+ * (even stale) is replayed with a clearly labelled "showing cached results"
18
+ * banner. The cache is a byproduct of fetches you already made — freely
19
+ * deletable — so the fetch-don't-replicate model holds.
19
20
  *
20
21
  * Mirrors the transport already used by `agents secrets export --host`
21
22
  * (`src/commands/secrets.ts`): `ssh -o BatchMode=yes <host> bash -lc '<cmd>'`,
@@ -145,6 +146,12 @@ export function classifySshFailure(res) {
145
146
  }
146
147
  /** Root of the offline-replay cache (`~/.agents/.cache/remote-sessions/`). */
147
148
  const REMOTE_CACHE_DIR = join(getCacheDir(), 'remote-sessions');
149
+ /**
150
+ * How long a successful remote fetch may be served without re-SSHing.
151
+ * Short on purpose: session listings must stay near-live (RUSH-2062). Match the
152
+ * active-session snapshot window so surfaces share one freshness model.
153
+ */
154
+ export const REMOTE_CACHE_MAX_AGE_MS = 15_000;
148
155
  /**
149
156
  * Deterministic cache path for a (host, forwarded-args) pair. The forwarded args
150
157
  * are hashed so distinct queries cache independently; the host stays readable in
@@ -155,6 +162,37 @@ export function remoteCachePath(host, forwardedArgs) {
155
162
  const safeHost = host.replace(/[^a-zA-Z0-9._@-]/g, '_');
156
163
  return join(REMOTE_CACHE_DIR, `${safeHost}__${hash}.txt`);
157
164
  }
165
+ /**
166
+ * Pure freshness check for a remote-sessions cache entry. A reachable host
167
+ * skips SSH only while this returns true; unreachable fallback ignores age.
168
+ */
169
+ export function isRemoteCacheFresh(mtimeMs, nowMs, maxAgeMs = REMOTE_CACHE_MAX_AGE_MS) {
170
+ if (!Number.isFinite(mtimeMs) || !Number.isFinite(maxAgeMs) || maxAgeMs < 0)
171
+ return false;
172
+ return nowMs - mtimeMs <= maxAgeMs;
173
+ }
174
+ /**
175
+ * Read a cached remote fetch. When `maxAgeMs` is set, returns null if the
176
+ * entry is older than the window (cache-first path for reachable hosts).
177
+ * Omit `maxAgeMs` to accept any age (unreachable fallback).
178
+ */
179
+ export function readRemoteCache(host, forwardedArgs, opts = {}) {
180
+ try {
181
+ const p = remoteCachePath(host, forwardedArgs);
182
+ if (!existsSync(p))
183
+ return null;
184
+ const mtimeMs = statSync(p).mtimeMs;
185
+ if (opts.maxAgeMs !== undefined) {
186
+ const now = opts.nowMs ?? Date.now();
187
+ if (!isRemoteCacheFresh(mtimeMs, now, opts.maxAgeMs))
188
+ return null;
189
+ }
190
+ return { output: readFileSync(p, 'utf8'), mtimeMs };
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ }
158
196
  /** Banner shown above replayed cache rows when the peer is offline. */
159
197
  export function formatStaleBanner(host, mtimeMs) {
160
198
  const ago = formatRelativeTime(new Date(mtimeMs).toISOString());
@@ -164,9 +202,9 @@ export function formatStaleBanner(host, mtimeMs) {
164
202
  export function formatUnreachable(host) {
165
203
  return chalk.red(`${host}: unreachable over SSH (asleep, offline, or host key changed?) — ConnectTimeout 10s`);
166
204
  }
167
- /** Persist a successful fetch for later offline replay. Best-effort: a cache
168
- * write must never break the live query. */
169
- function writeRemoteCache(host, forwardedArgs, output) {
205
+ /** Persist a successful fetch for later cache-first / offline replay.
206
+ * Best-effort: a cache write must never break the live query. Exported for tests. */
207
+ export function writeRemoteCache(host, forwardedArgs, output) {
170
208
  try {
171
209
  mkdirSync(REMOTE_CACHE_DIR, { recursive: true });
172
210
  writeFileSync(remoteCachePath(host, forwardedArgs), output);
@@ -175,26 +213,42 @@ function writeRemoteCache(host, forwardedArgs, output) {
175
213
  // ignore — caching is an optimisation, not a guarantee
176
214
  }
177
215
  }
178
- /** Replay a cached fetch for an unreachable host. Banner goes to stderr (so a
179
- * piped stdout stays exactly the cached rows); returns false when nothing is
180
- * cached for this exact (host, query). */
181
- function replayRemoteCache(host, forwardedArgs) {
182
- try {
183
- const p = remoteCachePath(host, forwardedArgs);
184
- if (!existsSync(p))
185
- return false;
186
- process.stderr.write(formatStaleBanner(host, statSync(p).mtimeMs) + '\n');
187
- process.stdout.write(readFileSync(p, 'utf8'));
188
- return true;
189
- }
190
- catch {
216
+ /**
217
+ * Serve a *fresh* cache entry for a reachable-host skip (no banner — the data
218
+ * is still within the freshness window). Returns false when missing/stale so
219
+ * the caller SSHes. RUSH-2062: without this, a reachable host never skipped SSH
220
+ * even when the cache was just written.
221
+ */
222
+ export function serveWarmRemoteCache(host, forwardedArgs, opts = {}) {
223
+ const hit = readRemoteCache(host, forwardedArgs, {
224
+ maxAgeMs: opts.maxAgeMs ?? REMOTE_CACHE_MAX_AGE_MS,
225
+ nowMs: opts.nowMs,
226
+ });
227
+ if (!hit)
191
228
  return false;
192
- }
229
+ process.stdout.write(hit.output);
230
+ return true;
231
+ }
232
+ /** Replay a cached fetch for an unreachable host (any age). Banner goes to
233
+ * stderr (so a piped stdout stays exactly the cached rows); returns false when
234
+ * nothing is cached for this exact (host, query). */
235
+ export function replayRemoteCache(host, forwardedArgs) {
236
+ const hit = readRemoteCache(host, forwardedArgs); // no maxAge — any age ok
237
+ if (!hit)
238
+ return false;
239
+ process.stderr.write(formatStaleBanner(host, hit.mtimeMs) + '\n');
240
+ process.stdout.write(hit.output);
241
+ return true;
193
242
  }
194
243
  /**
195
244
  * Run the current `agents sessions` invocation on one or more remote machines over
196
- * SSH, writing each remote's output to the terminal. A successful fetch is cached;
197
- * an unreachable host falls back to that cache (with a stale banner) when present.
245
+ * SSH, writing each remote's output to the terminal.
246
+ *
247
+ * Cache policy (RUSH-2062):
248
+ * - **Default:** serve a fresh cache hit without SSH; SSH only on miss/stale.
249
+ * - **`forceRefresh`:** always SSH, then rewrite the cache.
250
+ * - **Unreachable:** fall back to any cached output (with a stale banner).
251
+ *
198
252
  * Sets `process.exitCode = 1` if any host could not be answered (live or cached).
199
253
  * Reads the invocation from `process.argv` (override via `argv` for testing).
200
254
  *
@@ -202,16 +256,26 @@ function replayRemoteCache(host, forwardedArgs) {
202
256
  * Session output is small and the remote returns quickly, so buffering is
203
257
  * imperceptible; `maxBuffer` is generous for the rare large `--markdown <id>` dump.
204
258
  */
205
- export function runRemoteSessions(hosts, argv = process.argv) {
259
+ export function runRemoteSessions(hosts, argv = process.argv, opts = {}) {
206
260
  for (const host of hosts)
207
261
  assertValidSshTarget(host); // fail fast on any bad target
208
262
  const forwarded = ensureWholeIndex(buildForwardedArgs(argv, new Set(hosts)));
209
263
  const cols = terminalWidth();
210
264
  const multi = hosts.length > 1;
211
265
  let failures = 0;
266
+ const forceRefresh = opts.forceRefresh === true
267
+ || process.env.AGENTS_SESSIONS_FORCE_REFRESH === '1';
212
268
  for (const host of hosts) {
213
269
  if (multi)
214
270
  process.stdout.write(chalk.cyan(`\n── ${host} ──\n`));
271
+ // Cache-first: a warm hit skips SSH entirely so reachable hosts share one
272
+ // snapshot across menubar/CLI/watchdog instead of re-fanning every call.
273
+ if (!forceRefresh && serveWarmRemoteCache(host, forwarded, {
274
+ maxAgeMs: opts.maxAgeMs,
275
+ nowMs: opts.nowMs,
276
+ })) {
277
+ continue;
278
+ }
215
279
  // Per-host: a Windows peer needs a PowerShell command, POSIX peers `bash -lc`.
216
280
  const remoteCmd = buildRemoteCommand(forwarded, cols, resolveRemoteOsSync(host));
217
281
  const res = spawnSync('ssh', [...SSH_OPTS, ...controlOpts(), host, remoteCmd], {
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The one public command used by UI and lifecycle consumers to reopen a session.
3
+ * `agents resume` owns identity resolution, source-device routing, version/home
4
+ * selection, and harness-specific continuation; callers must not recreate it.
5
+ */
6
+ export declare function buildCanonicalResumeCommand(sessionId: string): string[];
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The one public command used by UI and lifecycle consumers to reopen a session.
3
+ * `agents resume` owns identity resolution, source-device routing, version/home
4
+ * selection, and harness-specific continuation; callers must not recreate it.
5
+ */
6
+ export function buildCanonicalResumeCommand(sessionId) {
7
+ return ['agents', 'resume', sessionId];
8
+ }
@@ -0,0 +1,173 @@
1
+ import type { ActiveSession } from './active.js';
2
+ /**
3
+ * How long a snapshot may be served before a reader re-gathers.
4
+ * Short on purpose: live status (running/idle/waiting) must not go stale.
5
+ * The daemon warm tick uses the same cadence (see {@link SESSION_CACHE_WARM_INTERVAL_MS}).
6
+ */
7
+ export declare const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS = 15000;
8
+ /** Daemon warm interval — keep in sync with the setInterval in `lib/daemon.ts`. */
9
+ export declare const SESSION_CACHE_WARM_INTERVAL_MS = 15000;
10
+ /** Kick off the first warm ~25s after daemon start (staggered off other ticks). */
11
+ export declare const SESSION_CACHE_WARM_KICKOFF_MS = 25000;
12
+ /** Snapshot scope: this host only, or a fleet-wide merge written by a reader. */
13
+ export type ActiveCacheScope = 'local' | 'fleet';
14
+ export interface ActiveSessionsSnapshot {
15
+ version: 1;
16
+ scope: ActiveCacheScope;
17
+ /** Epoch ms the sessions array was captured. */
18
+ capturedAt: number;
19
+ sessions: ActiveSession[];
20
+ /** Peer count from the last fleet gather (fleet scope only). */
21
+ remoteDeviceCount?: number;
22
+ }
23
+ /**
24
+ * Per-session fields that are stable until the transcript changes. Keyed on
25
+ * transcript mtime so a rewrite invalidates them. Live status is intentionally
26
+ * absent — see {@link LIVE_STATUS_KEYS}.
27
+ */
28
+ export interface ImmutableSessionFields {
29
+ topic?: string;
30
+ label?: string;
31
+ name?: string;
32
+ cwd?: string;
33
+ project?: string | null;
34
+ attachments?: ActiveSession['attachments'];
35
+ startedAtMs?: number;
36
+ version?: string;
37
+ pr?: ActiveSession['pr'];
38
+ worktree?: ActiveSession['worktree'];
39
+ ticket?: ActiveSession['ticket'];
40
+ createdTickets?: string[];
41
+ spawnedTeam?: string;
42
+ sessionFile?: string;
43
+ owner?: string;
44
+ assignedTask?: string;
45
+ kind?: string;
46
+ context?: ActiveSession['context'];
47
+ }
48
+ /** Keys stored in the immutable memo (transcript-stable). */
49
+ export declare const IMMUTABLE_FIELD_KEYS: readonly ["topic", "label", "name", "cwd", "project", "attachments", "startedAtMs", "version", "pr", "worktree", "ticket", "createdTickets", "spawnedTeam", "sessionFile", "owner", "assignedTask", "kind", "context"];
50
+ /**
51
+ * Live / volatile fields that MUST NOT be served from the immutable memo.
52
+ * They either change without a transcript write (pid death, attach state) or
53
+ * are short-window signals (preview, tok/s). The short snapshot TTL is the
54
+ * only cache that may carry them — and only as a whole-row snapshot.
55
+ */
56
+ export declare const LIVE_STATUS_KEYS: readonly ["status", "activity", "preview", "tokPerSec", "awaitingReason", "question", "todos", "tail", "lastActivityMs", "hostLink", "presence", "pidAlive", "tmuxClients", "windowHeartbeatMs", "provenance", "rateLimited", "plan"];
57
+ /** Test seam: redirect the snapshot file. Returns the previous override. */
58
+ export declare function setActiveSessionsSnapshotPathForTest(p: string | null): string | null;
59
+ /** Test seam: redirect the immutable-memo file. Returns the previous override. */
60
+ export declare function setImmutableMemoPathForTest(p: string | null): string | null;
61
+ /** Read one scope from the snapshot file (best-effort; missing/corrupt → null). */
62
+ export declare function readActiveSessionsCache(scope: ActiveCacheScope): ActiveSessionsSnapshot | null;
63
+ /**
64
+ * Persist a snapshot for one scope (best-effort). Other scopes are preserved
65
+ * so a local warm never drops a fleet snapshot a reader just wrote.
66
+ */
67
+ export declare function writeActiveSessionsCache(scope: ActiveCacheScope, sessions: ActiveSession[], opts?: {
68
+ capturedAt?: number;
69
+ remoteDeviceCount?: number;
70
+ }): ActiveSessionsSnapshot;
71
+ /**
72
+ * True when a snapshot is still within the freshness window. Pure — the
73
+ * staleness invariant for live status lives here.
74
+ */
75
+ export declare function isActiveSnapshotFresh(capturedAt: number, nowMs: number, maxAgeMs?: number): boolean;
76
+ /** Pull only the transcript-stable fields from a live row. */
77
+ export declare function pickImmutableFields(s: ActiveSession): ImmutableSessionFields;
78
+ /**
79
+ * Transcript mtime used as the memo key. Prefer {@link ActiveSession.lastActivityMs}
80
+ * (the transcript's last write); fall back to `startedAtMs` when no activity
81
+ * stamp exists. Returns null when neither is known — caller must not memoize.
82
+ */
83
+ export declare function transcriptMtimeMs(s: ActiveSession): number | null;
84
+ /**
85
+ * Read memoized immutable fields for `(sessionId, mtimeMs)`.
86
+ * Returns null when missing OR when the stored mtime does not match — a
87
+ * transcript rewrite must force re-derivation of topic/label/etc.
88
+ */
89
+ export declare function readImmutableMemo(sessionId: string, mtimeMs: number): ImmutableSessionFields | null;
90
+ /** Persist immutable fields for `(sessionId, mtimeMs)`. Live keys are stripped. */
91
+ export declare function writeImmutableMemo(sessionId: string, mtimeMs: number, fields: ImmutableSessionFields, nowMs?: number): void;
92
+ /** Drop every live-status key from a field bag (defence in depth). */
93
+ export declare function stripLiveStatusKeys<T extends Record<string, unknown>>(fields: T): T;
94
+ /**
95
+ * True when `fields` contains no live-status key. The invariant the tests pin:
96
+ * the immutable memo never carries status/activity/preview/etc.
97
+ */
98
+ export declare function assertNoLiveStatusFields(fields: Record<string, unknown>): boolean;
99
+ /**
100
+ * Write immutable memos for every session that has an id + transcript mtime.
101
+ * Called after a live gather so the next gather (when mtime is unchanged) can
102
+ * refill identity fields without re-deriving them.
103
+ */
104
+ export declare function updateImmutableMemos(sessions: ReadonlyArray<ActiveSession>, nowMs?: number): void;
105
+ /**
106
+ * Fill missing immutable fields on a live row from the memo when the transcript
107
+ * mtime matches. Never overwrites a field the live gather already set, and
108
+ * never copies live-status keys.
109
+ */
110
+ export declare function applyImmutableMemo(s: ActiveSession): ActiveSession;
111
+ export interface LoadLocalActiveSessionsOptions {
112
+ /** Skip the cache and re-gather (the force-refresh path). */
113
+ forceRefresh?: boolean;
114
+ /** Freshness window; defaults to {@link DEFAULT_ACTIVE_CACHE_MAX_AGE_MS}. */
115
+ maxAgeMs?: number;
116
+ /** Clock (injectable for tests). */
117
+ nowMs?: number;
118
+ /**
119
+ * Live gather. Defaults to `getActiveSessions({ localOnly: true })` so a
120
+ * warm never dials a remote-host teammate (RUSH-2118).
121
+ */
122
+ gather?: () => Promise<ActiveSession[]>;
123
+ /** Injectable cache IO for tests. */
124
+ readCache?: typeof readActiveSessionsCache;
125
+ writeCache?: typeof writeActiveSessionsCache;
126
+ }
127
+ export interface LoadLocalActiveSessionsResult {
128
+ sessions: ActiveSession[];
129
+ /** True when the row set came from the warm snapshot, not a live gather. */
130
+ servedFromCache: boolean;
131
+ capturedAt: number;
132
+ }
133
+ /**
134
+ * Cache-first load of THIS host's active sessions. Default path serves the
135
+ * daemon-warmed snapshot when fresh; `forceRefresh` or an expired snapshot
136
+ * re-gathers and rewrites the cache.
137
+ */
138
+ export declare function loadLocalActiveSessions(opts?: LoadLocalActiveSessionsOptions): Promise<LoadLocalActiveSessionsResult>;
139
+ export interface LoadFleetActiveSessionsOptions {
140
+ forceRefresh?: boolean;
141
+ maxAgeMs?: number;
142
+ nowMs?: number;
143
+ /** Live fleet gather (local + remote). Required — this module does not own SSH. */
144
+ gather: () => Promise<{
145
+ sessions: ActiveSession[];
146
+ remoteDeviceCount: number;
147
+ }>;
148
+ readCache?: typeof readActiveSessionsCache;
149
+ writeCache?: typeof writeActiveSessionsCache;
150
+ }
151
+ export interface LoadFleetActiveSessionsResult {
152
+ sessions: ActiveSession[];
153
+ remoteDeviceCount: number;
154
+ servedFromCache: boolean;
155
+ capturedAt: number;
156
+ }
157
+ /**
158
+ * Cache-first load of the fleet-wide active set. A prior gather (or any surface
159
+ * that just paid the SSH cost) leaves a fleet snapshot; subsequent menubar /
160
+ * Factory / CLI / watchdog calls within the freshness window share it.
161
+ */
162
+ export declare function loadFleetActiveSessions(opts: LoadFleetActiveSessionsOptions): Promise<LoadFleetActiveSessionsResult>;
163
+ /**
164
+ * Daemon warm entry point: live-gather THIS host and write the local snapshot.
165
+ * Never SSHes. Returns the published row count for the daemon log line.
166
+ */
167
+ export declare function publishLocalActiveSessions(opts?: {
168
+ gather?: () => Promise<ActiveSession[]>;
169
+ nowMs?: number;
170
+ }): Promise<{
171
+ sessions: ActiveSession[];
172
+ capturedAt: number;
173
+ }>;