@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.
- package/CHANGELOG.md +55 -0
- package/README.md +62 -26
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +54 -10
- package/dist/commands/hosts.js +4 -3
- package/dist/commands/perf.d.ts +10 -0
- package/dist/commands/perf.js +14 -6
- package/dist/commands/projects.js +24 -10
- package/dist/commands/routines.js +31 -4
- package/dist/commands/run-account-picker.d.ts +37 -0
- package/dist/commands/run-account-picker.js +101 -17
- package/dist/commands/secrets.js +86 -2
- package/dist/commands/sessions.d.ts +26 -6
- package/dist/commands/sessions.js +46 -12
- package/dist/commands/teams.js +19 -8
- package/dist/lib/activity.js +45 -99
- package/dist/lib/claude-account-token.js +38 -11
- package/dist/lib/cloud/host.js +1 -0
- package/dist/lib/devices/resolve-target.d.ts +1 -0
- package/dist/lib/devices/resolve-target.js +9 -2
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/events.js +2 -2
- package/dist/lib/git.d.ts +9 -3
- package/dist/lib/git.js +39 -22
- package/dist/lib/hooks/profile.d.ts +12 -1
- package/dist/lib/hooks/profile.js +6 -1
- package/dist/lib/hooks.js +4 -1
- package/dist/lib/hosts/dispatch.d.ts +10 -0
- package/dist/lib/hosts/dispatch.js +78 -22
- package/dist/lib/hosts/logs.js +11 -1
- package/dist/lib/hosts/passthrough.d.ts +5 -0
- package/dist/lib/hosts/passthrough.js +5 -2
- package/dist/lib/hosts/progress.d.ts +10 -0
- package/dist/lib/hosts/progress.js +37 -5
- package/dist/lib/hosts/ready.d.ts +41 -2
- package/dist/lib/hosts/ready.js +100 -12
- package/dist/lib/hosts/reconcile.d.ts +2 -1
- package/dist/lib/hosts/reconcile.js +13 -5
- package/dist/lib/hosts/reconnect.js +5 -3
- package/dist/lib/hosts/registry.js +1 -0
- package/dist/lib/hosts/tasks.d.ts +1 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/snapshot.d.ts +16 -0
- package/dist/lib/menubar/snapshot.js +37 -3
- package/dist/lib/models.d.ts +18 -11
- package/dist/lib/models.js +19 -15
- package/dist/lib/perf/db.js +16 -3
- package/dist/lib/perf/types.d.ts +12 -1
- package/dist/lib/project-probe.d.ts +22 -3
- package/dist/lib/project-probe.js +105 -17
- package/dist/lib/project-status.d.ts +9 -0
- package/dist/lib/project-status.js +15 -0
- package/dist/lib/refresh.js +22 -11
- package/dist/lib/rotate.d.ts +22 -0
- package/dist/lib/rotate.js +26 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +19 -3
- package/dist/lib/secrets/agent.js +58 -20
- package/dist/lib/secrets/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +2 -0
- package/dist/lib/secrets/bundles.d.ts +3 -3
- package/dist/lib/secrets/bundles.js +15 -5
- package/dist/lib/secrets/filestore.d.ts +2 -0
- package/dist/lib/secrets/filestore.js +4 -0
- package/dist/lib/secrets/session-store.d.ts +7 -0
- package/dist/lib/secrets/session-store.js +21 -0
- package/dist/lib/session/active.d.ts +49 -0
- package/dist/lib/session/active.js +175 -11
- package/dist/lib/session/bash-command.d.ts +16 -0
- package/dist/lib/session/bash-command.js +65 -0
- package/dist/lib/session/db.d.ts +4 -0
- package/dist/lib/session/db.js +32 -15
- package/dist/lib/session/remote-bundle.js +1 -1
- package/dist/lib/staleness/detectors/skills.d.ts +2 -0
- package/dist/lib/staleness/detectors/skills.js +17 -2
- package/dist/lib/staleness/index.d.ts +10 -1
- package/dist/lib/staleness/index.js +28 -3
- package/dist/lib/teams/agents.d.ts +1 -0
- package/dist/lib/teams/agents.js +38 -11
- package/dist/lib/teams/remoteWorktree.d.ts +11 -7
- package/dist/lib/teams/remoteWorktree.js +29 -27
- package/dist/lib/usage.js +18 -10
- package/dist/lib/versions.d.ts +1 -0
- package/dist/lib/versions.js +40 -22
- package/package.json +3 -1
- package/dist/lib/exec.bench.d.ts +0 -1
- 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
|
-
|
|
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,
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
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: (() => {
|
package/dist/lib/models.d.ts
CHANGED
|
@@ -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
|
|
73
|
-
* sibling (`claude-opus-4-8`
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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 (
|
|
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
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* more-specific sibling
|
|
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[];
|
package/dist/lib/models.js
CHANGED
|
@@ -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
|
|
294
|
-
* sibling (`claude-opus-4-8`
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
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 (
|
|
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
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
* more-specific sibling
|
|
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) {
|
package/dist/lib/perf/db.js
CHANGED
|
@@ -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'
|
|
260
|
-
|
|
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)
|
package/dist/lib/perf/types.d.ts
CHANGED
|
@@ -35,8 +35,19 @@ export interface PerfAggregateRow {
|
|
|
35
35
|
cacheStalePct?: number;
|
|
36
36
|
cacheMissPct?: number;
|
|
37
37
|
errorCount?: number;
|
|
38
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 (
|
|
241
|
+
else if (missing.length > 1) {
|
|
185
242
|
out.push({
|
|
186
243
|
severity: 'critical',
|
|
187
|
-
text: `${
|
|
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
|
-
|
|
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 (
|
|
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: `${
|
|
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,
|
package/dist/lib/refresh.js
CHANGED
|
@@ -135,15 +135,24 @@ export async function refresh(options = {}) {
|
|
|
135
135
|
const versionsToSync = skipPrompts
|
|
136
136
|
? listInstalledVersions(agentId)
|
|
137
137
|
: [defaultVer];
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
actuallySynced
|
|
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
|
-
|
|
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)
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -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
|