@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
@@ -47,6 +47,8 @@ const HOME = os.homedir();
47
47
  const VERSIONS_ROOTS = [getHistoryDir(), getAgentsDir()];
48
48
  const RUSH_SESSIONS_DIR = path.join(HOME, '.rush', 'sessions');
49
49
  const HERMES_SESSIONS_DIR = path.join(HOME, '.hermes', 'sessions');
50
+ /** Muse Code sessions: ~/.local/share/muse/sessions/YYYY/MM/DD/<uuid>/session.jsonl */
51
+ const MUSE_SESSIONS_DIR = path.join(HOME, '.local', 'share', 'muse', 'sessions');
50
52
  /** How long OpenClaw channel/cron snapshots stay valid before we re-shell-out. */
51
53
  const OPENCLAW_TTL_MS = 60_000;
52
54
  const ACTIVE_APPEND_RESCAN_DEBOUNCE_MS = 5_000;
@@ -303,6 +305,7 @@ function dispatchAgentScan(agent, onProgress) {
303
305
  case 'droid': return scanDroidIncremental(onProgress);
304
306
  case 'grok': return scanGrokIncremental(onProgress);
305
307
  case 'cursor': return scanCursorIncremental(onProgress);
308
+ case 'muse': return scanMuseIncremental(onProgress);
306
309
  default: return Promise.resolve();
307
310
  }
308
311
  }
@@ -2274,6 +2277,171 @@ function readHermesMeta(filePath) {
2274
2277
  };
2275
2278
  return { meta, content: userTexts.join('\n') };
2276
2279
  }
2280
+ /**
2281
+ * Muse Code stores one session per directory under
2282
+ * ~/.local/share/muse/sessions/YYYY/MM/DD/<uuid>/session.jsonl (event-sourced
2283
+ * JSONL). Walk the tree for session.jsonl files and index each one.
2284
+ *
2285
+ * Also scan version homes (`versions/muse/<v>/home/.local/share/muse/sessions`)
2286
+ * so managed/isolated runs that rewrite HOME still show up in `agents sessions`.
2287
+ */
2288
+ function scanMuseIncremental(onProgress) {
2289
+ const roots = [];
2290
+ if (fs.existsSync(MUSE_SESSIONS_DIR))
2291
+ roots.push(MUSE_SESSIONS_DIR);
2292
+ for (const root of VERSIONS_ROOTS) {
2293
+ const versionsBase = path.join(root, 'versions', 'muse');
2294
+ if (!fs.existsSync(versionsBase))
2295
+ continue;
2296
+ try {
2297
+ for (const version of fs.readdirSync(versionsBase)) {
2298
+ const dir = path.join(versionsBase, version, 'home', '.local', 'share', 'muse', 'sessions');
2299
+ if (fs.existsSync(dir))
2300
+ roots.push(dir);
2301
+ }
2302
+ }
2303
+ catch {
2304
+ /* unreadable */
2305
+ }
2306
+ }
2307
+ if (roots.length === 0)
2308
+ return Promise.resolve();
2309
+ const filePaths = [];
2310
+ const walk = (dir) => {
2311
+ let entries;
2312
+ try {
2313
+ entries = fs.readdirSync(dir, { withFileTypes: true });
2314
+ }
2315
+ catch {
2316
+ return;
2317
+ }
2318
+ for (const ent of entries) {
2319
+ const full = path.join(dir, ent.name);
2320
+ if (ent.isDirectory()) {
2321
+ // Skip subagent child sessions — nested under subagent/<id>/
2322
+ if (ent.name === 'subagent')
2323
+ continue;
2324
+ walk(full);
2325
+ }
2326
+ else if (ent.name === 'session.jsonl') {
2327
+ filePaths.push(full);
2328
+ }
2329
+ }
2330
+ };
2331
+ for (const root of roots)
2332
+ walk(root);
2333
+ const changed = filterChangedFiles(filePaths);
2334
+ if (changed.length === 0)
2335
+ return Promise.resolve();
2336
+ onProgress?.({ agent: 'muse', parsed: 0, total: changed.length });
2337
+ const scanEntries = [];
2338
+ const touched = [];
2339
+ const seen = new Set();
2340
+ let parsed = 0;
2341
+ for (const { filePath, scan } of changed) {
2342
+ try {
2343
+ const result = readMuseMeta(filePath);
2344
+ if (result && !seen.has(result.meta.id)) {
2345
+ seen.add(result.meta.id);
2346
+ scanEntries.push({ meta: result.meta, content: result.content, scan });
2347
+ }
2348
+ else {
2349
+ touched.push({ filePath, scan });
2350
+ }
2351
+ }
2352
+ catch {
2353
+ touched.push({ filePath, scan });
2354
+ }
2355
+ parsed++;
2356
+ onProgress?.({ agent: 'muse', parsed, total: changed.length });
2357
+ }
2358
+ upsertSessionsBatch(scanEntries);
2359
+ recordScans(touched);
2360
+ return Promise.resolve();
2361
+ }
2362
+ /** Parse a Muse session.jsonl for session metadata + first user prompt text. */
2363
+ function readMuseMeta(filePath) {
2364
+ // Path shape: .../sessions/YYYY/MM/DD/<uuid>/session.jsonl
2365
+ const sessionId = path.basename(path.dirname(filePath));
2366
+ if (!/^[0-9a-f-]{36}$/i.test(sessionId))
2367
+ return null;
2368
+ let content;
2369
+ try {
2370
+ content = fs.readFileSync(filePath, 'utf-8');
2371
+ }
2372
+ catch {
2373
+ return null;
2374
+ }
2375
+ const userTexts = [];
2376
+ let topic;
2377
+ let messageCount = 0;
2378
+ let model;
2379
+ let project;
2380
+ let firstTs;
2381
+ let lastTs;
2382
+ for (const line of content.split('\n')) {
2383
+ if (!line.trim())
2384
+ continue;
2385
+ let raw;
2386
+ try {
2387
+ raw = JSON.parse(line);
2388
+ }
2389
+ catch {
2390
+ continue;
2391
+ }
2392
+ if (typeof raw.recorded_at === 'number') {
2393
+ const v = raw.recorded_at;
2394
+ // Muse logs use microseconds; values < 1e14 are treated as milliseconds.
2395
+ const ms = v > 1e14 ? Math.floor(v / 1000) : v;
2396
+ const ts = new Date(ms).toISOString();
2397
+ if (!firstTs)
2398
+ firstTs = ts;
2399
+ lastTs = ts;
2400
+ }
2401
+ const payloadType = raw.payload_type;
2402
+ const payload = raw.payload;
2403
+ if (payloadType === 'runtime.session.metadata') {
2404
+ const record = payload?.record;
2405
+ if (typeof record?.workspace_root === 'string')
2406
+ project = record.workspace_root;
2407
+ }
2408
+ if (payloadType === 'runtime.command_intake.received') {
2409
+ const cmd = payload?.record?.command;
2410
+ if (cmd?.kind === 'turn_submit' && typeof cmd.prompt === 'string' && cmd.prompt.trim()) {
2411
+ messageCount++;
2412
+ userTexts.push(cmd.prompt.trim());
2413
+ if (!topic)
2414
+ topic = extractSessionTopic(cmd.prompt.trim());
2415
+ }
2416
+ }
2417
+ const event = payload?.event ?? payload;
2418
+ if (event?.kind === 'assistant_message_committed' && typeof event.text === 'string') {
2419
+ messageCount++;
2420
+ }
2421
+ if (event?.kind === 'model_request_configured' && typeof event.model === 'string') {
2422
+ model = event.model;
2423
+ }
2424
+ if (typeof event?.model === 'string' && !model)
2425
+ model = event.model;
2426
+ }
2427
+ const stat = safeStatSync(filePath);
2428
+ const timestamp = lastTs
2429
+ || firstTs
2430
+ || (stat ? stat.mtime.toISOString() : new Date().toISOString());
2431
+ const shortId = deriveShortId(sessionId);
2432
+ const meta = {
2433
+ id: sessionId,
2434
+ shortId,
2435
+ agent: 'muse',
2436
+ timestamp,
2437
+ project,
2438
+ filePath,
2439
+ model,
2440
+ topic,
2441
+ messageCount: messageCount || undefined,
2442
+ };
2443
+ return { meta, content: userTexts.join('\n') };
2444
+ }
2277
2445
  /** Extract plain text from a Hermes message content field (string or list of parts). */
2278
2446
  function extractHermesMessageText(content) {
2279
2447
  if (typeof content === 'string')
@@ -133,6 +133,16 @@ export declare function parseGrok(filePath: string): SessionEvent[];
133
133
  export declare function parseOpenCode(filePath: string): SessionEvent[];
134
134
  /** Parse a Rush JSONL session file into normalized events. */
135
135
  export declare function parseRush(filePath: string): SessionEvent[];
136
+ /**
137
+ * Muse Code session.jsonl → normalized events.
138
+ *
139
+ * Muse records an append-only event log. We map:
140
+ * - runtime.command_intake.received / turn_submit → user message
141
+ * - assistant_message_committed → assistant message
142
+ * - model_completed.usage → usage
143
+ * - tool-related events when present
144
+ */
145
+ export declare function parseMuse(filePath: string): SessionEvent[];
136
146
  /** Parse a Hermes session JSON file into normalized events. */
137
147
  export declare function parseHermes(filePath: string): SessionEvent[];
138
148
  /** Parse a Kimi session state.json file by reading its agents/main/wire.jsonl. */
@@ -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;
@@ -0,0 +1,37 @@
1
+ import { type RotateCandidate } from '../rotate.js';
2
+ import type { AgentId } from '../types.js';
3
+ import type { SessionMeta } from './types.js';
4
+ export type SessionRecoveryTarget = {
5
+ mode: 'native';
6
+ agent: AgentId;
7
+ version: string;
8
+ reason: string;
9
+ } | {
10
+ mode: 'continue';
11
+ agent: AgentId;
12
+ version: string;
13
+ reason: string;
14
+ };
15
+ export declare class SessionRecoveryError extends Error {
16
+ constructor(message: string);
17
+ }
18
+ /** Canonical origin-device label for every recovery consumer. */
19
+ export declare function sessionOriginDevice(session: Pick<SessionMeta, 'machine'>, self?: string): string;
20
+ /** The peer that must execute recovery, or undefined when this is the origin. */
21
+ export declare function sessionRecoveryPeer(session: Pick<SessionMeta, 'machine'>, selfCheck?: (host: string) => boolean): string | undefined;
22
+ /** Whether an explicit placement names the session's origin device. */
23
+ export declare function sessionRecoveryDestinationMatches(session: Pick<SessionMeta, 'machine'>, requestedHost: string, self?: string): boolean;
24
+ /**
25
+ * Decide how a durable session resumes on the device that owns it.
26
+ *
27
+ * Native resume is legal only in the exact origin version's isolated home and
28
+ * only while that account is healthy. Every other successful path stays on the
29
+ * same harness and uses `/continue`, whose indexed transcript reader can reach
30
+ * retained version trash. No healthy same-harness account is a loud failure.
31
+ */
32
+ export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean): SessionRecoveryTarget;
33
+ export declare function resolveSessionRecovery(session: SessionMeta): Promise<SessionRecoveryTarget>;
34
+ /** Stable self-command used by focus, resume, and attach. The owning device runs
35
+ * the recovery resolver above; callers must not native-resume another version's
36
+ * isolated home themselves. */
37
+ export declare function sessionRecoveryRunArgs(session: Pick<SessionMeta, 'id'>): string[];
@@ -0,0 +1,95 @@
1
+ import { AGENTS } from '../agents.js';
2
+ import { isSelfHost } from '../devices/self-host.js';
3
+ import { nativeResume } from '../exec.js';
4
+ import { machineId, normalizeHost } from '../machine-id.js';
5
+ import { collectRunCandidates, formatNoHealthyAccountError, pickBalancedCandidate, readinessFromCandidate, } from '../rotate.js';
6
+ export class SessionRecoveryError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = 'SessionRecoveryError';
10
+ }
11
+ }
12
+ /** Canonical origin-device label for every recovery consumer. */
13
+ export function sessionOriginDevice(session, self = machineId()) {
14
+ return normalizeHost(session.machine ?? self);
15
+ }
16
+ /** The peer that must execute recovery, or undefined when this is the origin. */
17
+ export function sessionRecoveryPeer(session, selfCheck = isSelfHost) {
18
+ if (!session.machine || selfCheck(session.machine))
19
+ return undefined;
20
+ return normalizeHost(session.machine);
21
+ }
22
+ /** Whether an explicit placement names the session's origin device. */
23
+ export function sessionRecoveryDestinationMatches(session, requestedHost, self = machineId()) {
24
+ const requested = normalizeHost(requestedHost.split('@').pop() || requestedHost);
25
+ return requested === sessionOriginDevice(session, self);
26
+ }
27
+ function runnableSessionAgent(session) {
28
+ if (!(session.agent in AGENTS)) {
29
+ throw new SessionRecoveryError(`Session ${session.shortId} belongs to ${session.agent}, which is indexed but cannot be launched by agents run.`);
30
+ }
31
+ return session.agent;
32
+ }
33
+ function sourceReason(session, candidates) {
34
+ if (!session.version)
35
+ return 'the origin version was not recorded';
36
+ const source = candidates.find((candidate) => candidate.version === session.version);
37
+ if (!source)
38
+ return `origin ${session.agent}@${session.version} is not installed`;
39
+ const readiness = readinessFromCandidate(source);
40
+ return readiness.ready
41
+ ? `origin ${session.agent}@${session.version} has no native resume form`
42
+ : `origin ${session.agent}@${session.version} is ${readiness.reason}`;
43
+ }
44
+ /**
45
+ * Decide how a durable session resumes on the device that owns it.
46
+ *
47
+ * Native resume is legal only in the exact origin version's isolated home and
48
+ * only while that account is healthy. Every other successful path stays on the
49
+ * same harness and uses `/continue`, whose indexed transcript reader can reach
50
+ * retained version trash. No healthy same-harness account is a loud failure.
51
+ */
52
+ export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume) {
53
+ const agent = runnableSessionAgent(session);
54
+ const device = sessionOriginDevice(session);
55
+ const source = session.version
56
+ ? candidates.find((candidate) => candidate.version === session.version)
57
+ : undefined;
58
+ const sourceReady = source ? readinessFromCandidate(source).ready : false;
59
+ // An exact healthy origin is deterministic: preserve its isolated home. If
60
+ // native resume is unavailable for that harness, /continue still launches in
61
+ // that same healthy home. Only an unusable/missing origin enters balanced
62
+ // account selection.
63
+ const selection = sourceReady
64
+ ? { picked: source }
65
+ : pickBalancedCandidate(candidates);
66
+ if (!selection) {
67
+ const detail = formatNoHealthyAccountError(agent, 'balanced', candidates);
68
+ throw new SessionRecoveryError(`Cannot recover session ${session.shortId} on ${device}; origin ${agent}@${session.version ?? 'unknown'}. ${detail}`);
69
+ }
70
+ const version = selection.picked.version;
71
+ if (session.version === version && supportsNative(agent, version)) {
72
+ return {
73
+ mode: 'native',
74
+ agent,
75
+ version,
76
+ reason: `origin ${agent}@${version} is installed and healthy`,
77
+ };
78
+ }
79
+ return {
80
+ mode: 'continue',
81
+ agent,
82
+ version,
83
+ reason: `${sourceReason(session, candidates)}; continuing with healthy ${agent}@${version}`,
84
+ };
85
+ }
86
+ export async function resolveSessionRecovery(session) {
87
+ const agent = runnableSessionAgent(session);
88
+ return resolveSessionRecoveryFromCandidates(session, await collectRunCandidates(agent));
89
+ }
90
+ /** Stable self-command used by focus, resume, and attach. The owning device runs
91
+ * the recovery resolver above; callers must not native-resume another version's
92
+ * isolated home themselves. */
93
+ export function sessionRecoveryRunArgs(session) {
94
+ return ['run', 'auto', '--resume', session.id, '--interactive'];
95
+ }
@@ -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;