@phnx-labs/agents-cli 1.22.27 → 1.22.29

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 (89) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +62 -26
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +54 -10
  5. package/dist/commands/hosts.js +4 -3
  6. package/dist/commands/perf.d.ts +10 -0
  7. package/dist/commands/perf.js +14 -6
  8. package/dist/commands/projects.js +24 -10
  9. package/dist/commands/routines.js +31 -4
  10. package/dist/commands/run-account-picker.d.ts +37 -0
  11. package/dist/commands/run-account-picker.js +101 -17
  12. package/dist/commands/secrets.js +86 -2
  13. package/dist/commands/sessions.d.ts +26 -6
  14. package/dist/commands/sessions.js +46 -12
  15. package/dist/commands/teams.js +19 -8
  16. package/dist/lib/activity.js +45 -99
  17. package/dist/lib/claude-account-token.js +38 -11
  18. package/dist/lib/cloud/host.js +1 -0
  19. package/dist/lib/devices/resolve-target.d.ts +1 -0
  20. package/dist/lib/devices/resolve-target.js +9 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/events.js +2 -2
  23. package/dist/lib/git.d.ts +9 -3
  24. package/dist/lib/git.js +39 -22
  25. package/dist/lib/hooks/profile.d.ts +12 -1
  26. package/dist/lib/hooks/profile.js +6 -1
  27. package/dist/lib/hooks.js +4 -1
  28. package/dist/lib/hosts/dispatch.d.ts +10 -0
  29. package/dist/lib/hosts/dispatch.js +78 -22
  30. package/dist/lib/hosts/logs.js +11 -1
  31. package/dist/lib/hosts/passthrough.d.ts +5 -0
  32. package/dist/lib/hosts/passthrough.js +5 -2
  33. package/dist/lib/hosts/progress.d.ts +10 -0
  34. package/dist/lib/hosts/progress.js +37 -5
  35. package/dist/lib/hosts/ready.d.ts +41 -2
  36. package/dist/lib/hosts/ready.js +100 -12
  37. package/dist/lib/hosts/reconcile.d.ts +2 -1
  38. package/dist/lib/hosts/reconcile.js +13 -5
  39. package/dist/lib/hosts/reconnect.js +5 -3
  40. package/dist/lib/hosts/registry.js +1 -0
  41. package/dist/lib/hosts/tasks.d.ts +1 -0
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  44. package/dist/lib/menubar/snapshot.d.ts +16 -0
  45. package/dist/lib/menubar/snapshot.js +37 -3
  46. package/dist/lib/models.d.ts +18 -11
  47. package/dist/lib/models.js +19 -15
  48. package/dist/lib/perf/db.js +16 -3
  49. package/dist/lib/perf/types.d.ts +12 -1
  50. package/dist/lib/project-probe.d.ts +22 -3
  51. package/dist/lib/project-probe.js +105 -17
  52. package/dist/lib/project-status.d.ts +9 -0
  53. package/dist/lib/project-status.js +15 -0
  54. package/dist/lib/refresh.js +22 -11
  55. package/dist/lib/rotate.d.ts +22 -0
  56. package/dist/lib/rotate.js +26 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  59. package/dist/lib/secrets/agent.d.ts +19 -3
  60. package/dist/lib/secrets/agent.js +58 -20
  61. package/dist/lib/secrets/audit.d.ts +1 -1
  62. package/dist/lib/secrets/audit.js +2 -0
  63. package/dist/lib/secrets/bundles.d.ts +3 -3
  64. package/dist/lib/secrets/bundles.js +15 -5
  65. package/dist/lib/secrets/filestore.d.ts +2 -0
  66. package/dist/lib/secrets/filestore.js +4 -0
  67. package/dist/lib/secrets/session-store.d.ts +7 -0
  68. package/dist/lib/secrets/session-store.js +21 -0
  69. package/dist/lib/session/active.d.ts +49 -0
  70. package/dist/lib/session/active.js +175 -11
  71. package/dist/lib/session/bash-command.d.ts +16 -0
  72. package/dist/lib/session/bash-command.js +65 -0
  73. package/dist/lib/session/db.d.ts +4 -0
  74. package/dist/lib/session/db.js +32 -15
  75. package/dist/lib/session/remote-bundle.js +1 -1
  76. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  77. package/dist/lib/staleness/detectors/skills.js +17 -2
  78. package/dist/lib/staleness/index.d.ts +10 -1
  79. package/dist/lib/staleness/index.js +28 -3
  80. package/dist/lib/teams/agents.d.ts +1 -0
  81. package/dist/lib/teams/agents.js +38 -11
  82. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  83. package/dist/lib/teams/remoteWorktree.js +29 -27
  84. package/dist/lib/usage.js +18 -10
  85. package/dist/lib/versions.d.ts +1 -0
  86. package/dist/lib/versions.js +40 -22
  87. package/package.json +3 -1
  88. package/dist/lib/exec.bench.d.ts +0 -1
  89. package/dist/lib/exec.bench.js +0 -186
@@ -73,7 +73,7 @@
73
73
  * is failing to put the user back into the agent.
74
74
  */
75
75
  import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
76
- import { sshTargetFor } from './types.js';
76
+ import { hostIdentityArgs, sshTargetFor } from './types.js';
77
77
  /** ssh's connection-layer failure code — the signal that the link dropped rather
78
78
  * than the remote command exiting on its own. Mirrors ssh-exec.ts `sshStream`. */
79
79
  export const SSH_CONN_FAILURE = 255;
@@ -214,15 +214,17 @@ export function reattachRemoteCommand(sessionId) {
214
214
  */
215
215
  export function reattachRemoteSession(host, sessionId) {
216
216
  const target = sshTargetFor(host);
217
+ const extraSshArgs = hostIdentityArgs(host);
217
218
  // Fresh (non-multiplexed) reachability probe: code 0 means the handshake actually
218
219
  // completed, so a hung/failed connect is never mistaken for a live reconnection.
219
- const probe = sshExec(target, 'true', { multiplex: false });
220
+ // RUSH-2265: pass host identity on every hop (probe + stream), not only the first.
221
+ const probe = sshExec(target, 'true', { multiplex: false, extraSshArgs });
220
222
  if (probe.code !== 0)
221
223
  return { code: SSH_CONN_FAILURE, connected: false, heldMs: 0 };
222
224
  // Timed from AFTER the probe returned, so this is the attach's own duration and
223
225
  // carries none of the connect phase the file header rules out as a signal.
224
226
  const startedAt = Date.now();
225
- const code = sshStream(target, reattachRemoteCommand(sessionId), { tty: true });
227
+ const code = sshStream(target, reattachRemoteCommand(sessionId), { tty: true, extraSshArgs });
226
228
  return { code, connected: true, heldMs: Date.now() - startedAt };
227
229
  }
228
230
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -94,6 +94,7 @@ function deviceHost(device, user, overlay) {
94
94
  source: 'inline',
95
95
  ...(address ? { address } : {}),
96
96
  user: user ?? device.user,
97
+ identityFile: device.auth.identityFile,
97
98
  os: device.platform !== 'unknown' ? device.platform : overlay?.os,
98
99
  ...(overlay?.caps?.length ? { caps: overlay.caps } : {}),
99
100
  enrolled: true,
@@ -14,6 +14,7 @@ export interface HostTask {
14
14
  target: string;
15
15
  /** OpenSSH private-key path retained for follow/reconcile/stop calls. */
16
16
  identityFile?: string;
17
+ remoteShell?: 'posix' | 'powershell';
17
18
  agent: string;
18
19
  prompt: string;
19
20
  pid?: number;
@@ -1,10 +1,26 @@
1
1
  import type { WatchdogTickResult } from '../watchdog/runner.js';
2
+ /**
3
+ * One registered fleet device, for the menu-bar's collapsible DEVICES section.
4
+ * Sourced from the local registry read (`loadDevices`) — no network probe — so
5
+ * it carries only what the registry knows for sure (name, platform, whether it
6
+ * is the interactive host, whether it is this machine). Live load% is merged in
7
+ * on the Swift side from the daemon-warmed `.fleet-stats.json`; online/offline
8
+ * is deliberately NOT claimed here (the registry's cached tailscale flag is
9
+ * documented as stale in both directions — registry.ts isLikelyOnline).
10
+ */
11
+ export interface MenubarDevice {
12
+ name: string;
13
+ platform: string;
14
+ interactive: boolean;
15
+ isLocal: boolean;
16
+ }
2
17
  export interface MenubarSnapshot {
3
18
  version: 1;
4
19
  capturedAt: string;
5
20
  routines: Record<string, unknown>[];
6
21
  recentSessions: Record<string, unknown>[];
7
22
  activeSessions: Record<string, unknown>[];
23
+ devices: MenubarDevice[];
8
24
  watchdog: {
9
25
  enabled: boolean;
10
26
  lastTick: Pick<WatchdogTickResult, 'didNudge' | 'counts'> | null;
@@ -1,11 +1,31 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { buildRoutineListJson } from '../../commands/routines.js';
4
- import { backfillActiveRowsFromIndex, serializeActiveSessionsForJson, serializeSessionsJson } from '../../commands/sessions.js';
4
+ import { backfillActiveRowsFromIndex, isRunningLiveSession, serializeActiveSessionsForJson, serializeSessionsJson } from '../../commands/sessions.js';
5
5
  import { getConfigValue } from '../device-config.js';
6
+ import { loadDevices } from '../devices/registry.js';
7
+ import { machineId } from '../machine-id.js';
6
8
  import { querySessions } from '../session/db.js';
7
9
  import { readActiveSessionsCache } from '../session/session-cache.js';
8
10
  import { getRuntimeStateDir } from '../state.js';
11
+ /**
12
+ * The full registered-device roster for the menu bar, from the local registry
13
+ * file only (no ssh, no stats probe) — as cheap as `buildRoutineListJson()`, so
14
+ * it rides the same 3-minute snapshot poll instead of a second timer.
15
+ */
16
+ async function buildMenubarDevices() {
17
+ const reg = await loadDevices();
18
+ const interactiveHost = getConfigValue('interactive.host').value;
19
+ const self = machineId();
20
+ return Object.keys(reg)
21
+ .sort()
22
+ .map((name) => ({
23
+ name,
24
+ platform: reg[name].platform,
25
+ interactive: name === interactiveHost,
26
+ isLocal: name === self,
27
+ }));
28
+ }
9
29
  export function readLastWatchdogTick(stateDir = path.join(getRuntimeStateDir(), 'watchdog')) {
10
30
  try {
11
31
  return JSON.parse(fs.readFileSync(path.join(stateDir, 'last-tick.json'), 'utf-8'));
@@ -16,12 +36,25 @@ export function readLastWatchdogTick(stateDir = path.join(getRuntimeStateDir(),
16
36
  }
17
37
  /** One-process read model for AGI Menu's repeating three-minute refresh. */
18
38
  export async function computeMenubarSnapshot() {
19
- const [routines, recent] = await Promise.all([
39
+ const [routines, recent, devices] = await Promise.all([
20
40
  Promise.resolve(buildRoutineListJson()),
21
41
  Promise.resolve(querySessions({ limit: 40, skipExistenceCheck: true })),
42
+ buildMenubarDevices(),
22
43
  ]);
23
44
  const active = readActiveSessionsCache('local');
24
- const activeSessions = active?.sessions ?? [];
45
+ const rawSessions = active?.sessions ?? [];
46
+ // The raw cache is never filtered at write time (RUSH-2336) — it retains
47
+ // queued/closed/crashed rows so `--queued`/`--closed`/`--crashed` can
48
+ // recover them, and the daemon's warm-tick gather (unlike the CLI's own
49
+ // local gather) never stamps `machine` on a row. Stamp self here — this IS
50
+ // the 'local' scope by construction — then apply the ONE canonical
51
+ // bare-active selector so the menubar never shows a retained dead/queued
52
+ // row nor a process row of unverified liveness.
53
+ const self = machineId();
54
+ for (const s of rawSessions)
55
+ if (!s.machine)
56
+ s.machine = self;
57
+ const activeSessions = rawSessions.filter(isRunningLiveSession);
25
58
  backfillActiveRowsFromIndex(activeSessions);
26
59
  return {
27
60
  version: 1,
@@ -29,6 +62,7 @@ export async function computeMenubarSnapshot() {
29
62
  routines,
30
63
  recentSessions: JSON.parse(serializeSessionsJson(recent)),
31
64
  activeSessions: serializeActiveSessionsForJson(activeSessions),
65
+ devices,
32
66
  watchdog: {
33
67
  enabled: getConfigValue('watchdog.enabled').value === true,
34
68
  lastTick: (() => {
@@ -69,17 +69,24 @@ export declare function locateModelSource(agent: AgentId, version: string): Mode
69
69
  * - constants: {OPUS_ID:"...",OPUS_NAME:"...",SONNET_ID:"...",...}
70
70
  */
71
71
  /**
72
- * Drop a bare `claude-<family>-<major>` (e.g. `claude-opus-4`) when a more specific
73
- * sibling (`claude-opus-4-8`) is present. The bare form is only ever an internal
74
- * `.includes("claude-opus-4")` prefix-check string in the binary, not a submittable
75
- * id (issue #1892); a bare id with no sibling (e.g. `claude-sonnet-5`) is a real
76
- * current model and is kept.
72
+ * Drop a catalog id that is a proper dash-boundary prefix of a more-specific
73
+ * sibling also present in the list (e.g. `claude-opus-4` when `claude-opus-4-8`
74
+ * is present, or `claude-opus-4-1` when `claude-opus-4-1-20250805` is present).
75
+ *
76
+ * Two sources produce these prefix forms in the native binary:
77
+ * - standalone `.includes("claude-opus-4")` prefix-check strings (#1892)
78
+ * - per-cloud metadata field values such as `foundry:"claude-opus-4-1"` next
79
+ * to a real firstParty id `claude-opus-4-1-20250805` (#2233)
80
+ *
81
+ * A genuine bare current id with no longer sibling (e.g. `claude-sonnet-5`) is
82
+ * kept. The dash boundary (`startsWith(id + '-')`) avoids collapsing
83
+ * `claude-opus-4-1` into `claude-opus-4-10`.
77
84
  */
78
85
  export declare function dropBareLegacyIds(ids: string[]): string[];
79
86
  /**
80
87
  * Scan raw binary/bundle text for canonical Claude model ids, then drop bare
81
- * legacy prefixes (issue #1892). Two independent guards keep non-model strings
82
- * out of the catalog:
88
+ * legacy / cloud-metadata prefixes (#1892, #2233). Two independent guards keep
89
+ * non-model strings out of the catalog:
83
90
  *
84
91
  * - **Word-boundary anchors on the id regex.** The id must not be glued to a
85
92
  * surrounding identifier character, and must not be the truncated prefix of a
@@ -98,10 +105,10 @@ export declare function dropBareLegacyIds(ids: string[]): string[];
98
105
  * id this scan exists to suppress (two packed strings can end up glued with no
99
106
  * separator in the extracted binary text). The atomic match fails outright
100
107
  * instead of degrading to the bare form.
101
- * - **`dropBareLegacyIds`.** A standalone `.includes("claude-opus-4")` prefix
102
- * check is a fully delimited string the anchors cannot tell apart from a real
103
- * bare id, so a bare `claude-<family>-<major>` is dropped only when a
104
- * more-specific sibling (`claude-opus-4-8`) is also present; a genuinely bare
108
+ * - **`dropBareLegacyIds`.** A fully delimited string the anchors cannot tell
109
+ * apart from a real id whether a bare major (`.includes("claude-opus-4")`)
110
+ * or a bare-minor per-cloud field value (`foundry:"claude-opus-4-1"`) is
111
+ * dropped when a more-specific sibling is also present; a genuinely bare
105
112
  * current id with no sibling (`claude-sonnet-5`) is kept.
106
113
  */
107
114
  export declare function scanClaudeCatalogIds(text: string): string[];
@@ -290,22 +290,26 @@ function extractStrings(filePath, minLen = 6) {
290
290
  * - constants: {OPUS_ID:"...",OPUS_NAME:"...",SONNET_ID:"...",...}
291
291
  */
292
292
  /**
293
- * Drop a bare `claude-<family>-<major>` (e.g. `claude-opus-4`) when a more specific
294
- * sibling (`claude-opus-4-8`) is present. The bare form is only ever an internal
295
- * `.includes("claude-opus-4")` prefix-check string in the binary, not a submittable
296
- * id (issue #1892); a bare id with no sibling (e.g. `claude-sonnet-5`) is a real
297
- * current model and is kept.
293
+ * Drop a catalog id that is a proper dash-boundary prefix of a more-specific
294
+ * sibling also present in the list (e.g. `claude-opus-4` when `claude-opus-4-8`
295
+ * is present, or `claude-opus-4-1` when `claude-opus-4-1-20250805` is present).
296
+ *
297
+ * Two sources produce these prefix forms in the native binary:
298
+ * - standalone `.includes("claude-opus-4")` prefix-check strings (#1892)
299
+ * - per-cloud metadata field values such as `foundry:"claude-opus-4-1"` next
300
+ * to a real firstParty id `claude-opus-4-1-20250805` (#2233)
301
+ *
302
+ * A genuine bare current id with no longer sibling (e.g. `claude-sonnet-5`) is
303
+ * kept. The dash boundary (`startsWith(id + '-')`) avoids collapsing
304
+ * `claude-opus-4-1` into `claude-opus-4-10`.
298
305
  */
299
306
  export function dropBareLegacyIds(ids) {
300
- return ids.filter((id) => {
301
- const bareMajor = /^claude-[a-z]+-\d+$/.test(id);
302
- return !(bareMajor && ids.some((o) => o !== id && o.startsWith(`${id}-`)));
303
- });
307
+ return ids.filter((id) => !ids.some((other) => other !== id && other.startsWith(`${id}-`)));
304
308
  }
305
309
  /**
306
310
  * Scan raw binary/bundle text for canonical Claude model ids, then drop bare
307
- * legacy prefixes (issue #1892). Two independent guards keep non-model strings
308
- * out of the catalog:
311
+ * legacy / cloud-metadata prefixes (#1892, #2233). Two independent guards keep
312
+ * non-model strings out of the catalog:
309
313
  *
310
314
  * - **Word-boundary anchors on the id regex.** The id must not be glued to a
311
315
  * surrounding identifier character, and must not be the truncated prefix of a
@@ -324,10 +328,10 @@ export function dropBareLegacyIds(ids) {
324
328
  * id this scan exists to suppress (two packed strings can end up glued with no
325
329
  * separator in the extracted binary text). The atomic match fails outright
326
330
  * instead of degrading to the bare form.
327
- * - **`dropBareLegacyIds`.** A standalone `.includes("claude-opus-4")` prefix
328
- * check is a fully delimited string the anchors cannot tell apart from a real
329
- * bare id, so a bare `claude-<family>-<major>` is dropped only when a
330
- * more-specific sibling (`claude-opus-4-8`) is also present; a genuinely bare
331
+ * - **`dropBareLegacyIds`.** A fully delimited string the anchors cannot tell
332
+ * apart from a real id whether a bare major (`.includes("claude-opus-4")`)
333
+ * or a bare-minor per-cloud field value (`foundry:"claude-opus-4-1"`) is
334
+ * dropped when a more-specific sibling is also present; a genuinely bare
331
335
  * current id with no sibling (`claude-sonnet-5`) is kept.
332
336
  */
333
337
  export function scanClaudeCatalogIds(text) {
@@ -244,7 +244,7 @@ export function aggregateSamples(opts = {}) {
244
244
  const key = `${r.kind}\0${r.label}`;
245
245
  let b = map.get(key);
246
246
  if (!b) {
247
- b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0, timeouts: 0 };
247
+ b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0, blocks: 0, timeouts: 0 };
248
248
  map.set(key, b);
249
249
  }
250
250
  b.durations.push(Number(r.duration_ms));
@@ -254,10 +254,19 @@ export function aggregateSamples(opts = {}) {
254
254
  b.stale++;
255
255
  else if (r.cache === 'miss' || r.cache === 'none')
256
256
  b.misses++;
257
+ // Exit classes (Claude/Codex PreToolUse convention):
258
+ // 0 → allowed
259
+ // 2 → intentional deny/block (not a crash)
260
+ // 1 / other nonzero → real error
261
+ // Timeouts are recorded via status, not exit_code, so they don't double-count.
257
262
  if (r.status === 'timeout')
258
263
  b.timeouts++;
259
- else if (typeof r.exit_code === 'number' && r.exit_code !== 0)
260
- b.errors++;
264
+ else if (typeof r.exit_code === 'number') {
265
+ if (r.exit_code === 2)
266
+ b.blocks++;
267
+ else if (r.exit_code !== 0)
268
+ b.errors++;
269
+ }
261
270
  }
262
271
  const out = [];
263
272
  for (const b of map.values()) {
@@ -286,6 +295,10 @@ export function aggregateSamples(opts = {}) {
286
295
  row.errorCount = b.errors;
287
296
  row.errorRate = Math.round((b.errors / n) * 1000) / 1000;
288
297
  }
298
+ if (b.blocks > 0) {
299
+ row.blockCount = b.blocks;
300
+ row.blockRate = Math.round((b.blocks / n) * 1000) / 1000;
301
+ }
289
302
  if (b.timeouts > 0)
290
303
  row.timeoutRate = Math.round((b.timeouts / n) * 1000) / 1000;
291
304
  if (opts.project)
@@ -35,8 +35,19 @@ export interface PerfAggregateRow {
35
35
  cacheStalePct?: number;
36
36
  cacheMissPct?: number;
37
37
  errorCount?: number;
38
- /** Fraction (0-1) of samples with a nonzero exit code. */
38
+ /**
39
+ * Fraction (0-1) of samples with a real crash exit (exit 1 / other nonzero
40
+ * except the intentional PreToolUse deny code 2). Exit 2 is blockRate.
41
+ */
39
42
  errorRate?: number;
43
+ /**
44
+ * Count of intentional deny/block exits (Claude/Codex PreToolUse exit 2).
45
+ * Not an error — deny-by-design guards (ask-user-question-guard, git-guard,
46
+ * plan-html-reminder) exit 2 when they block.
47
+ */
48
+ blockCount?: number;
49
+ /** Fraction (0-1) of samples with exit code 2 (intentional deny/block). */
50
+ blockRate?: number;
40
51
  /** Fraction (0-1) of samples with status:'timeout'. */
41
52
  timeoutRate?: number;
42
53
  /** Project key (see project-key.ts) the row is scoped to — set only when
@@ -73,6 +73,16 @@ export declare function formatWorkspaceLine(s: RepoWorkspaceStatus): string;
73
73
  * `fleet` row label.
74
74
  */
75
75
  export declare function formatFleetWorkspaces(statuses: HostWorkspaceStatus[]): string[];
76
+ /**
77
+ * One-line fleet health summary — `6/13 clean · 4 behind · 4 dirty · 1 missing`.
78
+ * Sits ABOVE the per-host {@link formatFleetWorkspaces} table so the card is
79
+ * scannable without reading every host cell; the table keeps the per-host branch
80
+ * and drift detail. Zero buckets are omitted. `behind` colours red when any host
81
+ * is ≥10 behind (matching the footer's critical threshold), else yellow; a host
82
+ * that is both behind and dirty counts in both. Each host×path row is one unit,
83
+ * the same unit the table renders. Pure — chalk styling only.
84
+ */
85
+ export declare function formatFleetSummary(statuses: HostWorkspaceStatus[]): string;
76
86
  /** Severity for a workspace/repo warning on the project card. */
77
87
  export type WorkspaceWarningSeverity = 'critical' | 'continue';
78
88
  export interface WorkspaceWarning {
@@ -81,13 +91,22 @@ export interface WorkspaceWarning {
81
91
  remediation?: string;
82
92
  }
83
93
  /**
84
- * Turn probed workspace rows into card-footer warnings.
94
+ * Turn probed workspace rows into card-footer warnings, GROUPED by root cause so
95
+ * a fleet where eight hosts drift is a few lines, not sixteen (each with its own
96
+ * repeated remediation).
85
97
  *
86
98
  * - missing / unreadable git → critical (agents there cannot share a tree)
87
- * - behind upstream → critical when ≥10 commits, continue otherwise
99
+ * - behind upstream → critical when ANY host is ≥10 commits behind, else continue
88
100
  * - dirty tree → continue (local work is fine; just note it)
89
101
  * - ahead-only is not a warning (that is progress waiting to push)
90
102
  *
91
- * Pure. Caller decides whether the rows came from `--fleet` or a local probe.
103
+ * Within one probed path, all behind hosts collapse to one warning listing each
104
+ * host with its count (`4 hosts behind origin/main — mac-mini ↓172, …`) plus one
105
+ * shared remediation; a lone host keeps its full sentence. Missing and dirty
106
+ * collapse the same way. `error` stays per-host (each message is distinct).
107
+ * Grouping is per path so two different repos never merge into one count. Unlike
108
+ * doctor's `emitGroup`, the list names EVERY host, not the first two — each
109
+ * host's drift count differs and is individually actionable. Pure — chalk only.
110
+ * Caller decides whether the rows came from `--fleet` or a local probe.
92
111
  */
93
112
  export declare function workspaceWarnings(statuses: HostWorkspaceStatus[]): WorkspaceWarning[];
@@ -159,46 +159,134 @@ export function formatFleetWorkspaces(statuses) {
159
159
  });
160
160
  }
161
161
  /**
162
- * Turn probed workspace rows into card-footer warnings.
162
+ * One-line fleet health summary `6/13 clean · 4 behind · 4 dirty · 1 missing`.
163
+ * Sits ABOVE the per-host {@link formatFleetWorkspaces} table so the card is
164
+ * scannable without reading every host cell; the table keeps the per-host branch
165
+ * and drift detail. Zero buckets are omitted. `behind` colours red when any host
166
+ * is ≥10 behind (matching the footer's critical threshold), else yellow; a host
167
+ * that is both behind and dirty counts in both. Each host×path row is one unit,
168
+ * the same unit the table renders. Pure — chalk styling only.
169
+ */
170
+ export function formatFleetSummary(statuses) {
171
+ const total = statuses.length;
172
+ let clean = 0;
173
+ let behind = 0;
174
+ let dirty = 0;
175
+ let missing = 0;
176
+ let hardBehind = false;
177
+ for (const s of statuses) {
178
+ if (!s.present) {
179
+ missing++;
180
+ continue;
181
+ }
182
+ if (s.error)
183
+ continue; // unreadable — neither clean nor a drift bucket (surfaced in the footer)
184
+ const isBehind = s.behind !== undefined && s.behind > 0;
185
+ const isDirty = s.dirty !== undefined && s.dirty > 0;
186
+ if (isBehind) {
187
+ behind++;
188
+ if (s.behind >= 10)
189
+ hardBehind = true;
190
+ }
191
+ if (isDirty)
192
+ dirty++;
193
+ if (!isBehind && !isDirty)
194
+ clean++;
195
+ }
196
+ const parts = [chalk.green(`${clean}/${total} clean`)];
197
+ if (behind)
198
+ parts.push((hardBehind ? chalk.red : chalk.yellow)(`${behind} behind`));
199
+ if (dirty)
200
+ parts.push(chalk.yellow(`${dirty} dirty`));
201
+ if (missing)
202
+ parts.push(chalk.red(`${missing} missing`));
203
+ return parts.join(chalk.dim(' · '));
204
+ }
205
+ /**
206
+ * Turn probed workspace rows into card-footer warnings, GROUPED by root cause so
207
+ * a fleet where eight hosts drift is a few lines, not sixteen (each with its own
208
+ * repeated remediation).
163
209
  *
164
210
  * - missing / unreadable git → critical (agents there cannot share a tree)
165
- * - behind upstream → critical when ≥10 commits, continue otherwise
211
+ * - behind upstream → critical when ANY host is ≥10 commits behind, else continue
166
212
  * - dirty tree → continue (local work is fine; just note it)
167
213
  * - ahead-only is not a warning (that is progress waiting to push)
168
214
  *
169
- * Pure. Caller decides whether the rows came from `--fleet` or a local probe.
215
+ * Within one probed path, all behind hosts collapse to one warning listing each
216
+ * host with its count (`4 hosts behind origin/main — mac-mini ↓172, …`) plus one
217
+ * shared remediation; a lone host keeps its full sentence. Missing and dirty
218
+ * collapse the same way. `error` stays per-host (each message is distinct).
219
+ * Grouping is per path so two different repos never merge into one count. Unlike
220
+ * doctor's `emitGroup`, the list names EVERY host, not the first two — each
221
+ * host's drift count differs and is individually actionable. Pure — chalk only.
222
+ * Caller decides whether the rows came from `--fleet` or a local probe.
170
223
  */
171
224
  export function workspaceWarnings(statuses) {
172
225
  const out = [];
173
- for (const s of [...statuses].sort((a, b) => a.host.localeCompare(b.host) || a.path.localeCompare(b.path))) {
174
- const where = s.host ? `${s.host}` : 'local';
175
- const pathBit = s.path ? ` (${s.path})` : '';
176
- if (!s.present) {
226
+ const where = (s) => (s.host ? s.host : 'local');
227
+ const paths = [...new Set(statuses.map((s) => s.path))].sort((a, b) => a.localeCompare(b));
228
+ for (const path of paths) {
229
+ const pathBit = path ? ` (${path})` : '';
230
+ const rows = statuses.filter((s) => s.path === path);
231
+ const present = rows.filter((s) => s.present && !s.error);
232
+ // Missing checkout — grouped critical, a lone host keeps its full sentence.
233
+ const missing = rows.filter((s) => !s.present).sort((a, b) => where(a).localeCompare(where(b)));
234
+ if (missing.length === 1) {
177
235
  out.push({
178
236
  severity: 'critical',
179
- text: `${where}: checkout missing${pathBit}`,
237
+ text: `${where(missing[0])}: checkout missing${pathBit}`,
180
238
  remediation: 'clone or sync the project root on that host before landing agents there',
181
239
  });
182
- continue;
183
240
  }
184
- if (s.error) {
241
+ else if (missing.length > 1) {
185
242
  out.push({
186
243
  severity: 'critical',
187
- text: `${where}: ${s.error}${pathBit}`,
244
+ text: `${missing.length} hosts missing checkout${pathBit}${missing.map(where).join(', ')}`,
245
+ remediation: 'clone or sync the project root on those hosts before landing agents there',
188
246
  });
189
- continue;
190
247
  }
191
- if (s.behind !== undefined && s.behind > 0) {
248
+ // Unreadable git one per host, since each error message is distinct.
249
+ for (const s of rows.filter((s) => s.present && s.error).sort((a, b) => where(a).localeCompare(where(b)))) {
250
+ out.push({ severity: 'critical', text: `${where(s)}: ${s.error}${pathBit}` });
251
+ }
252
+ // Behind upstream — grouped, worst count first, one shared remediation.
253
+ const behind = present
254
+ .filter((s) => s.behind !== undefined && s.behind > 0)
255
+ .sort((a, b) => (b.behind ?? 0) - (a.behind ?? 0) || where(a).localeCompare(where(b)));
256
+ if (behind.length === 1) {
257
+ const s = behind[0];
192
258
  out.push({
193
- severity: s.behind >= 10 ? 'critical' : 'continue',
194
- text: `${where} is ${s.behind} commit${s.behind === 1 ? '' : 's'} behind ${s.upstream ?? 'upstream'}${pathBit}`,
259
+ severity: (s.behind ?? 0) >= 10 ? 'critical' : 'continue',
260
+ text: `${where(s)} is ${s.behind} commit${s.behind === 1 ? '' : 's'} behind ${s.upstream ?? 'upstream'}${pathBit}`,
195
261
  remediation: 'pull (or rebase) before agents on this host open PRs against a stale base',
196
262
  });
197
263
  }
198
- if (s.dirty !== undefined && s.dirty > 0) {
264
+ else if (behind.length > 1) {
265
+ const upstreams = new Set(behind.map((s) => s.upstream ?? 'upstream'));
266
+ const upstream = upstreams.size === 1 ? [...upstreams][0] : 'upstream';
267
+ const list = behind.map((s) => `${where(s)} ↓${s.behind}`).join(', ');
268
+ out.push({
269
+ severity: behind.some((s) => (s.behind ?? 0) >= 10) ? 'critical' : 'continue',
270
+ text: `${behind.length} hosts behind ${upstream}${pathBit} — ${list}`,
271
+ remediation: 'pull (or rebase) before agents on these hosts open PRs against a stale base',
272
+ });
273
+ }
274
+ // Dirty tree — grouped, most changes first, no remediation (local work is fine).
275
+ const dirty = present
276
+ .filter((s) => s.dirty !== undefined && s.dirty > 0)
277
+ .sort((a, b) => (b.dirty ?? 0) - (a.dirty ?? 0) || where(a).localeCompare(where(b)));
278
+ if (dirty.length === 1) {
279
+ const s = dirty[0];
280
+ out.push({
281
+ severity: 'continue',
282
+ text: `${where(s)} has ${s.dirty} uncommitted change${s.dirty === 1 ? '' : 's'}${pathBit}`,
283
+ });
284
+ }
285
+ else if (dirty.length > 1) {
286
+ const list = dirty.map((s) => `${where(s)} ${s.dirty}`).join(', ');
199
287
  out.push({
200
288
  severity: 'continue',
201
- text: `${where} has ${s.dirty} uncommitted change${s.dirty === 1 ? '' : 's'}${pathBit}`,
289
+ text: `${dirty.length} hosts with uncommitted changes${pathBit} ${list}`,
202
290
  });
203
291
  }
204
292
  }
@@ -87,6 +87,15 @@ export interface LiveDeadSplit {
87
87
  * crashed sessions is itself a thing to go fix.
88
88
  */
89
89
  export declare function liveDeadSplit(byStatus: Partial<Record<ActiveStatus, number>>): LiveDeadSplit;
90
+ /**
91
+ * The `dead` row body: `41 crashed` when every dead session shares one status,
92
+ * else `12 finished or lost (8 crashed, 4 closed)`. The generic "finished or
93
+ * lost" only earns its keep when the statuses actually differ — with a single
94
+ * status it just hides which one behind a parenthetical that repeats the count.
95
+ * Pure — chalk styling only; the caller adds the `dead` label. Assumes
96
+ * `split.dead > 0` (the caller gates on it).
97
+ */
98
+ export declare function formatDeadSummary(split: LiveDeadSplit): string;
90
99
  /** Sort members for the card: running first, then idle, then the rest; agent name asc within a state. */
91
100
  export declare function sortProjectMembers(members: ProjectMember[]): ProjectMember[];
92
101
  /** Cap for the members line before it collapses to `+N more`. */
@@ -135,6 +135,21 @@ export function liveDeadSplit(byStatus) {
135
135
  deadByStatus.sort((a, b) => b.n - a.n || a.status.localeCompare(b.status));
136
136
  return { live, dead, deadByStatus };
137
137
  }
138
+ /**
139
+ * The `dead` row body: `41 crashed` when every dead session shares one status,
140
+ * else `12 finished or lost (8 crashed, 4 closed)`. The generic "finished or
141
+ * lost" only earns its keep when the statuses actually differ — with a single
142
+ * status it just hides which one behind a parenthetical that repeats the count.
143
+ * Pure — chalk styling only; the caller adds the `dead` label. Assumes
144
+ * `split.dead > 0` (the caller gates on it).
145
+ */
146
+ export function formatDeadSummary(split) {
147
+ if (split.deadByStatus.length === 1) {
148
+ return chalk.yellow(`${split.dead} ${split.deadByStatus[0].status}`);
149
+ }
150
+ const detail = split.deadByStatus.map((d) => `${d.n} ${d.status}`).join(', ');
151
+ return `${chalk.yellow(`${split.dead} finished or lost`)} ${chalk.dim(`(${detail})`)}`;
152
+ }
138
153
  /**
139
154
  * Display order for the members line: the states a human scans for first
140
155
  * (running, then idle, then need-input, then queued), everything else after,
@@ -135,15 +135,24 @@ export async function refresh(options = {}) {
135
135
  const versionsToSync = skipPrompts
136
136
  ? listInstalledVersions(agentId)
137
137
  : [defaultVer];
138
- const actuallySynced = getActuallySyncedResources(agentId, defaultVer);
139
- const newResources = getNewResources(available, actuallySynced, getProjectOnlyResources());
140
- const hasAnySynced = actuallySynced.commands.length > 0 ||
141
- actuallySynced.skills.length > 0 ||
142
- actuallySynced.hooks.length > 0 ||
143
- actuallySynced.memory.length > 0 ||
144
- actuallySynced.mcp.length > 0 ||
145
- actuallySynced.permissions.length > 0 ||
146
- actuallySynced.plugins.length > 0;
138
+ // Interactive-only: getActuallySyncedResources walks every skill tree with
139
+ // content compares (~1s/agent on a full install). The unattended path
140
+ // (`skipPrompts` / `agents sync --yes`) never reads these — it always
141
+ // force-full-syncs so skip the scan entirely (RUSH-2320 #1).
142
+ let actuallySynced;
143
+ let newResources;
144
+ let hasAnySynced = false;
145
+ if (!skipPrompts) {
146
+ actuallySynced = getActuallySyncedResources(agentId, defaultVer);
147
+ newResources = getNewResources(available, actuallySynced, getProjectOnlyResources());
148
+ hasAnySynced = actuallySynced.commands.length > 0 ||
149
+ actuallySynced.skills.length > 0 ||
150
+ actuallySynced.hooks.length > 0 ||
151
+ actuallySynced.memory.length > 0 ||
152
+ actuallySynced.mcp.length > 0 ||
153
+ actuallySynced.permissions.length > 0 ||
154
+ actuallySynced.plugins.length > 0;
155
+ }
147
156
  try {
148
157
  let selection;
149
158
  let forceFullSync = false;
@@ -156,7 +165,7 @@ export async function refresh(options = {}) {
156
165
  if (userSelection)
157
166
  selection = userSelection;
158
167
  }
159
- else if (hasNewResources(newResources, agentId, defaultVer)) {
168
+ else if (newResources && hasNewResources(newResources, agentId, defaultVer)) {
160
169
  log(chalk.cyan(`\n${agentLabel(agentId)}@${defaultVer}:`));
161
170
  const userSelection = await promptNewResourceSelection(agentId, newResources, defaultVer);
162
171
  if (userSelection)
@@ -168,7 +177,9 @@ export async function refresh(options = {}) {
168
177
  if (forceFullSync || (selection && Object.keys(selection).length > 0)) {
169
178
  const kinds = new Set();
170
179
  for (const ver of versionsToSync) {
171
- const syncResult = syncResourcesToVersion(agentId, ver, selection, forceFullSync ? { force: true } : undefined);
180
+ // Pass the already-built `available` so each version does not re-scan
181
+ // resource trees (RUSH-2320 #5).
182
+ const syncResult = syncResourcesToVersion(agentId, ver, selection, { available, ...(forceFullSync ? { force: true } : {}) });
172
183
  if (syncResult.commands)
173
184
  kinds.add('commands');
174
185
  if (syncResult.skills)
@@ -145,6 +145,28 @@ export type AccountReadiness = {
145
145
  * reported while the account is actually serving requests.
146
146
  */
147
147
  export declare function readinessFromCandidate(candidate: RotateCandidate): AccountReadiness;
148
+ /**
149
+ * Whether a human sitting at a terminal can clear this exclusion by launching
150
+ * the agent and signing in. The two unhealthy classes are opposites, and the
151
+ * zero-healthy callers MUST NOT treat them alike:
152
+ *
153
+ * - `signed_out` / `revoked` — recoverable. There is no credential (or the
154
+ * server rejected it), and the harness's own TUI is the login surface, so
155
+ * launching it is exactly the fix. Refusing to launch strands the user with
156
+ * no way to authenticate through agents-cli at all (RUSH-2334).
157
+ * - `rate_limited` / `out_of_credits` — NOT recoverable. The account is signed
158
+ * in and throttled; launching it just hammers an exhausted account, which is
159
+ * precisely the loop RUSH-2132's fail-loud guard exists to stop. Only a
160
+ * window reset clears these.
161
+ */
162
+ export declare function isSignInRecoverable(readiness: AccountReadiness): boolean;
163
+ /**
164
+ * The subset of an `exhausted` set whose exclusion a sign-in would clear, so an
165
+ * interactive caller can offer the login instead of dead-ending. Empty means
166
+ * every account is throttled — nothing a human can fix right now, so the caller
167
+ * keeps failing loud.
168
+ */
169
+ export declare function signInRecoverableCandidates(candidates: RotateCandidate[]): RotateCandidate[];
148
170
  /**
149
171
  * Readiness for a specific installed (agent, version). Returns `{ ready: true }`
150
172
  * when the version isn't among the collected candidates — absence is the