@phnx-labs/agents-cli 1.20.74 → 1.20.77
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 +1055 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +18 -0
- package/dist/commands/exec.js +83 -13
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +38 -8
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +286 -15
- package/dist/commands/secrets.js +102 -37
- package/dist/commands/sessions-browser.js +12 -2
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +184 -16
- package/dist/commands/sessions.d.ts +56 -14
- package/dist/commands/sessions.js +322 -62
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +110 -37
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +15 -6
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +24 -4
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +59 -7
- package/dist/lib/activity.d.ts +17 -6
- package/dist/lib/activity.js +466 -40
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +21 -0
- package/dist/lib/agents.js +70 -6
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.js +4 -0
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +114 -11
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +37 -1
- package/dist/lib/devices/fleet.js +36 -2
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +82 -0
- package/dist/lib/exec.js +218 -6
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +16 -0
- package/dist/lib/hosts/dispatch.js +26 -4
- package/dist/lib/hosts/passthrough.js +9 -2
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +32 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +17 -5
- package/dist/lib/hosts/run-target.js +30 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +40 -5
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +2 -0
- package/dist/lib/import.js +35 -2
- package/dist/lib/menubar/install-menubar.d.ts +29 -0
- package/dist/lib/menubar/install-menubar.js +59 -1
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +6 -2
- package/dist/lib/overdue.js +10 -36
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +94 -0
- package/dist/lib/routines.js +93 -9
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +242 -20
- package/dist/lib/sandbox.js +10 -0
- package/dist/lib/secrets/account-token.d.ts +20 -0
- package/dist/lib/secrets/account-token.js +64 -0
- package/dist/lib/secrets/agent.d.ts +74 -4
- package/dist/lib/secrets/agent.js +181 -107
- package/dist/lib/secrets/bundles.d.ts +54 -6
- package/dist/lib/secrets/bundles.js +230 -34
- package/dist/lib/secrets/index.d.ts +26 -3
- package/dist/lib/secrets/index.js +42 -12
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +124 -7
- package/dist/lib/session/active.js +390 -56
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +54 -0
- package/dist/lib/session/db.js +320 -19
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +478 -3
- package/dist/lib/session/discover.js +1740 -472
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +141 -32
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.d.ts +7 -2
- package/dist/lib/session/render.js +40 -26
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +99 -13
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +20 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +8 -4
- package/dist/lib/state.js +21 -37
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +47 -2
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +9 -2
- package/dist/lib/versions.js +37 -8
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- package/package.json +1 -1
package/dist/lib/daemon.js
CHANGED
|
@@ -13,10 +13,13 @@ import * as os from 'os';
|
|
|
13
13
|
import { getDaemonDir as getDaemonDirRoot } from './state.js';
|
|
14
14
|
import { isAlive, killTree, backgroundSpawnOptions } from './platform/index.js';
|
|
15
15
|
import { listJobs as listAllJobs } from './routines.js';
|
|
16
|
+
import { syncAllProjectRoutines } from './routines-project.js';
|
|
16
17
|
import { JobScheduler } from './scheduler.js';
|
|
17
18
|
import { MonitorEngine } from './monitors/engine.js';
|
|
18
19
|
import { executeJobDetached, monitorRunningJobs } from './runner.js';
|
|
19
20
|
import { detectOverdueJobs, notifyOverdue } from './overdue.js';
|
|
21
|
+
import { notifyDesktop } from './menubar/notify-desktop.js';
|
|
22
|
+
import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from './routine-notify.js';
|
|
20
23
|
import { BrowserService } from './browser/service.js';
|
|
21
24
|
import { BrowserIPCServer } from './browser/ipc.js';
|
|
22
25
|
import { readAndResolveBundleEnv } from './secrets/bundles.js';
|
|
@@ -315,10 +318,36 @@ export async function runDaemon() {
|
|
|
315
318
|
// secrets-agent and is otherwise absent (leaving the daemon on its existing
|
|
316
319
|
// interactive OAuth session), matching the detached-start path. Never blocks.
|
|
317
320
|
if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
|
318
|
-
const
|
|
321
|
+
const bundleEnv = readDaemonClaudeBundleEnv();
|
|
322
|
+
const oauthToken = (bundleEnv[DAEMON_OAUTH_KEY] ?? '').trim();
|
|
319
323
|
if (oauthToken) {
|
|
320
324
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
|
|
321
|
-
|
|
325
|
+
// Also inject each per-account CLAUDE_CODE_OAUTH_TOKEN_<slug> present in the
|
|
326
|
+
// bundle, so a routine authenticates its rotation-pinned account via that
|
|
327
|
+
// account's own long-lived, non-rotating setup-token (runner.ts
|
|
328
|
+
// buildRoutineSpawnEnv) instead of the interactive session that rotates and
|
|
329
|
+
// logs the fleet out (Claude Code #25609 / #56339).
|
|
330
|
+
let perAccount = 0;
|
|
331
|
+
for (const [k, v] of Object.entries(bundleEnv)) {
|
|
332
|
+
if (k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_') && (v ?? '').trim() && !process.env[k]) {
|
|
333
|
+
process.env[k] = v.trim();
|
|
334
|
+
perAccount++;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
log('INFO', `Loaded Claude OAuth token from secrets bundle for routine runs${perAccount ? ` (+${perAccount} per-account)` : ''}`);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
// No token available (e.g. a headless macOS daemon whose keychain was
|
|
341
|
+
// locked at start resolves broker-only and gets nothing). Historically
|
|
342
|
+
// this was silent, so every Claude routine the daemon spawned failed auth
|
|
343
|
+
// with no signal. Make it loud: WARN in the log and fire a desktop alert.
|
|
344
|
+
log('WARN', 'No Claude OAuth token available — Claude routine runs will fail auth on this host. ' +
|
|
345
|
+
'Restart the daemon with the keychain unlocked, or unlock the `claude` secrets bundle.');
|
|
346
|
+
notifyDesktop({
|
|
347
|
+
title: 'agents daemon: no Claude credential',
|
|
348
|
+
body: 'Claude routines will fail auth on this host. Restart the daemon with the keychain unlocked.',
|
|
349
|
+
action: 'routines:list',
|
|
350
|
+
});
|
|
322
351
|
}
|
|
323
352
|
}
|
|
324
353
|
// Reap any stray duplicate daemon of this install that slipped past the start
|
|
@@ -363,14 +392,50 @@ export async function runDaemon() {
|
|
|
363
392
|
? `workflow: ${config.workflow}`
|
|
364
393
|
: `agent: ${config.agent}`;
|
|
365
394
|
log('INFO', `Triggering job '${config.name}' (${jobLabel})`);
|
|
395
|
+
// RUSH-2030: branded desktop notification on start (agent/workflow routines;
|
|
396
|
+
// suppressed for command housekeeping). Finish/output is fired from the
|
|
397
|
+
// onFinish hook below — executeJobDetached finalizes the run in-process, so
|
|
398
|
+
// the monitor tick never sees the live transition. Never let a notification
|
|
399
|
+
// failure break the trigger.
|
|
366
400
|
try {
|
|
367
|
-
|
|
401
|
+
notifyRoutineStart(config);
|
|
402
|
+
}
|
|
403
|
+
catch { /* best-effort */ }
|
|
404
|
+
try {
|
|
405
|
+
const meta = await executeJobDetached(config, {
|
|
406
|
+
onFinish: (final) => {
|
|
407
|
+
try {
|
|
408
|
+
notifyRoutineFinish(final);
|
|
409
|
+
}
|
|
410
|
+
catch { /* best-effort */ }
|
|
411
|
+
},
|
|
412
|
+
});
|
|
368
413
|
log('INFO', `Job '${config.name}' spawned (run: ${meta.runId}, PID: ${meta.pid})`);
|
|
369
414
|
}
|
|
370
415
|
catch (err) {
|
|
371
|
-
|
|
416
|
+
const message = err.message;
|
|
417
|
+
log('ERROR', `Job '${config.name}' failed to spawn: ${message}`);
|
|
418
|
+
// RUSH-2030: the START ping already fired unconditionally above. A pre-spawn
|
|
419
|
+
// failure produces no run record and thus no onFinish, so send a synthetic
|
|
420
|
+
// "failed to start" finish here — otherwise the user is left with an orphaned
|
|
421
|
+
// "Routine started" and never told it failed.
|
|
422
|
+
try {
|
|
423
|
+
notifyRoutineStartFailed(config, message);
|
|
424
|
+
}
|
|
425
|
+
catch { /* best-effort */ }
|
|
372
426
|
}
|
|
373
427
|
});
|
|
428
|
+
// Materialise opted-in project routines into the user layer on every start
|
|
429
|
+
// so a fresh daemon picks up project YAML without a separate sync step.
|
|
430
|
+
try {
|
|
431
|
+
const result = syncAllProjectRoutines();
|
|
432
|
+
const n = result.projects.reduce((acc, p) => acc + p.synced.length, 0);
|
|
433
|
+
if (n > 0)
|
|
434
|
+
log('INFO', `Project routines sync: ${n} job(s) from ${result.projects.length} project(s)`);
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
log('WARN', `Project routines sync failed: ${err.message}`);
|
|
438
|
+
}
|
|
374
439
|
scheduler.loadAll();
|
|
375
440
|
const scheduled = scheduler.listScheduled();
|
|
376
441
|
log('INFO', `Loaded ${scheduled.length} jobs`);
|
|
@@ -623,9 +688,16 @@ export async function runDaemon() {
|
|
|
623
688
|
checkingLaunchHealth = true;
|
|
624
689
|
try {
|
|
625
690
|
const { healBrokenDefaultLaunches } = await import('./versions.js');
|
|
626
|
-
|
|
691
|
+
// Unattended pass: repair the current default in place, but NEVER repoint
|
|
692
|
+
// the global default (allowDefaultSwitch: false). A background default
|
|
693
|
+
// switch installs a fresh version home → a fresh empty Claude credential
|
|
694
|
+
// scope, i.e. a silent logout uncorrelated with anything the user did.
|
|
695
|
+
const { repaired, unhealed } = await healBrokenDefaultLaunches((m) => log('INFO', `launch-health: ${m}`), { allowDefaultSwitch: false });
|
|
627
696
|
if (repaired.length)
|
|
628
697
|
log('INFO', `launch-health: repaired ${repaired.join(', ')}`);
|
|
698
|
+
if (unhealed.length) {
|
|
699
|
+
log('WARN', `launch-health: ${unhealed.join(', ')} won't launch and will not be auto-switched — choose a version with \`agents use <agent> <version>\` or \`agents add <agent>@latest\``);
|
|
700
|
+
}
|
|
629
701
|
}
|
|
630
702
|
catch (err) {
|
|
631
703
|
log('ERROR', `launch-health check failed: ${err.message}`);
|
|
@@ -705,6 +777,19 @@ export async function runDaemon() {
|
|
|
705
777
|
const brokerSelfHealInterval = setInterval(() => { void runBrokerSelfHeal(); }, 60_000);
|
|
706
778
|
const handleReload = () => {
|
|
707
779
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
780
|
+
// Refresh user-layer copies of opted-in project routines BEFORE the
|
|
781
|
+
// scheduler reloads, so YAML edits under `<project>/.agents/routines/`
|
|
782
|
+
// take effect on the next fire without a manual `routines sync`.
|
|
783
|
+
try {
|
|
784
|
+
const result = syncAllProjectRoutines();
|
|
785
|
+
const n = result.projects.reduce((acc, p) => acc + p.synced.length, 0);
|
|
786
|
+
if (n > 0 || result.missing.length > 0) {
|
|
787
|
+
log('INFO', `Project routines sync: ${n} updated, ${result.missing.length} missing roots`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
catch (err) {
|
|
791
|
+
log('WARN', `Project routines sync failed: ${err.message}`);
|
|
792
|
+
}
|
|
708
793
|
scheduler.reloadAll();
|
|
709
794
|
const reloaded = scheduler.listScheduled();
|
|
710
795
|
log('INFO', `Reloaded ${reloaded.length} jobs`);
|
|
@@ -758,17 +843,26 @@ export async function runDaemon() {
|
|
|
758
843
|
* token leaves the daemon on its existing interactive OAuth session.
|
|
759
844
|
*/
|
|
760
845
|
export function readDaemonClaudeOAuthToken(opts = {}) {
|
|
846
|
+
const token = (readDaemonClaudeBundleEnv(opts)[DAEMON_OAUTH_KEY] ?? '').trim();
|
|
847
|
+
return token.length > 0 ? token : null;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Read the FULL `claude` bundle env — the main `CLAUDE_CODE_OAUTH_TOKEN` plus any
|
|
851
|
+
* per-account `CLAUDE_CODE_OAUTH_TOKEN_<slug>` setup-tokens. Same resolution and
|
|
852
|
+
* never-throws contract as {@link readDaemonClaudeOAuthToken}; returns `{}` when
|
|
853
|
+
* the bundle can't be read (broker-only headless miss, absent bundle, etc.).
|
|
854
|
+
*/
|
|
855
|
+
export function readDaemonClaudeBundleEnv(opts = {}) {
|
|
761
856
|
try {
|
|
762
857
|
const allowPrompt = opts.allowPrompt ?? Boolean(process.stdin.isTTY);
|
|
763
858
|
const { env } = readAndResolveBundleEnv(DAEMON_OAUTH_BUNDLE, {
|
|
764
859
|
caller: 'daemon',
|
|
765
860
|
agentOnly: !allowPrompt,
|
|
766
861
|
});
|
|
767
|
-
|
|
768
|
-
return token.length > 0 ? token : null;
|
|
862
|
+
return env;
|
|
769
863
|
}
|
|
770
864
|
catch {
|
|
771
|
-
return
|
|
865
|
+
return {};
|
|
772
866
|
}
|
|
773
867
|
}
|
|
774
868
|
/** Escape a string for safe inclusion in an XML <string> node. */
|
|
@@ -996,11 +1090,20 @@ function startDaemonLocked(agentsBin) {
|
|
|
996
1090
|
* the daemon then passes it to every routine run it spawns. An already-set
|
|
997
1091
|
* value (e.g. inherited from launchd) is left untouched.
|
|
998
1092
|
*/
|
|
999
|
-
export function buildDetachedDaemonEnv(baseEnv = process.env,
|
|
1093
|
+
export function buildDetachedDaemonEnv(baseEnv = process.env, bundleEnv = readDaemonClaudeBundleEnv()) {
|
|
1000
1094
|
const env = { ...baseEnv };
|
|
1001
1095
|
if (!env.CLAUDE_CODE_OAUTH_TOKEN) {
|
|
1002
|
-
|
|
1003
|
-
|
|
1096
|
+
const main = (bundleEnv[DAEMON_OAUTH_KEY] ?? '').trim();
|
|
1097
|
+
if (main)
|
|
1098
|
+
env.CLAUDE_CODE_OAUTH_TOKEN = main;
|
|
1099
|
+
}
|
|
1100
|
+
// Per-account setup-tokens — same rationale as the runDaemon startup injection:
|
|
1101
|
+
// a routine authenticates its rotation-pinned account via that account's own
|
|
1102
|
+
// long-lived, non-rotating token. An already-set value wins.
|
|
1103
|
+
for (const [k, v] of Object.entries(bundleEnv)) {
|
|
1104
|
+
if (k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_') && (v ?? '').trim() && !env[k]) {
|
|
1105
|
+
env[k] = v.trim();
|
|
1106
|
+
}
|
|
1004
1107
|
}
|
|
1005
1108
|
return env;
|
|
1006
1109
|
}
|
|
@@ -3,6 +3,14 @@ import { type DeviceProfile } from './registry.js';
|
|
|
3
3
|
export declare const ASKPASS_BUNDLE_ENV = "AGENTS_SSH_BUNDLE";
|
|
4
4
|
/** Env var the askpass shim reads to know which key in the bundle is the password. */
|
|
5
5
|
export declare const ASKPASS_KEY_ENV = "AGENTS_SSH_KEY";
|
|
6
|
+
/**
|
|
7
|
+
* Env var that forces the askpass bundle resolve to be broker-only (`agentOnly`)
|
|
8
|
+
* regardless of TTY. A read-only stats probe (`agents devices` load/mem columns)
|
|
9
|
+
* sets this so an uncached password-auth device resolves from the already-unlocked
|
|
10
|
+
* secrets broker or degrades to an unreachable row — never a foreground Touch ID
|
|
11
|
+
* sheet. See {@link buildSshInvocation}'s `agentOnly` option and `runAskpass`. (RUSH-1970)
|
|
12
|
+
*/
|
|
13
|
+
export declare const ASKPASS_AGENT_ONLY_ENV = "AGENTS_SSH_AGENT_ONLY";
|
|
6
14
|
/**
|
|
7
15
|
* Build the `user@host` (or bare `host`) ssh target for a device and validate
|
|
8
16
|
* it against the shared injection guard. Throws if the device has no address.
|
|
@@ -51,8 +59,14 @@ export interface SshHostKeyOptions {
|
|
|
51
59
|
* Host-key checking runs against the CLI-managed known_hosts store (never the
|
|
52
60
|
* user's `~/.ssh/known_hosts`): strict once `hostKey.pinned` is set, else
|
|
53
61
|
* `accept-new` to learn+pin the key on first connect (RUSH-1767).
|
|
62
|
+
*
|
|
63
|
+
* `opts.agentOnly` marks the connection as a read-only probe: for password auth
|
|
64
|
+
* it sets {@link ASKPASS_AGENT_ONLY_ENV} in the overlay so the askpass resolve
|
|
65
|
+
* stays broker-only and never pops a foreground biometric (RUSH-1970).
|
|
54
66
|
*/
|
|
55
|
-
export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions
|
|
67
|
+
export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions, opts?: {
|
|
68
|
+
agentOnly?: boolean;
|
|
69
|
+
}): {
|
|
56
70
|
args: string[];
|
|
57
71
|
env: Record<string, string>;
|
|
58
72
|
};
|
|
@@ -25,6 +25,14 @@ import { hostNameFor } from './ssh-config.js';
|
|
|
25
25
|
export const ASKPASS_BUNDLE_ENV = 'AGENTS_SSH_BUNDLE';
|
|
26
26
|
/** Env var the askpass shim reads to know which key in the bundle is the password. */
|
|
27
27
|
export const ASKPASS_KEY_ENV = 'AGENTS_SSH_KEY';
|
|
28
|
+
/**
|
|
29
|
+
* Env var that forces the askpass bundle resolve to be broker-only (`agentOnly`)
|
|
30
|
+
* regardless of TTY. A read-only stats probe (`agents devices` load/mem columns)
|
|
31
|
+
* sets this so an uncached password-auth device resolves from the already-unlocked
|
|
32
|
+
* secrets broker or degrades to an unreachable row — never a foreground Touch ID
|
|
33
|
+
* sheet. See {@link buildSshInvocation}'s `agentOnly` option and `runAskpass`. (RUSH-1970)
|
|
34
|
+
*/
|
|
35
|
+
export const ASKPASS_AGENT_ONLY_ENV = 'AGENTS_SSH_AGENT_ONLY';
|
|
28
36
|
/**
|
|
29
37
|
* Build the `user@host` (or bare `host`) ssh target for a device and validate
|
|
30
38
|
* it against the shared injection guard. Throws if the device has no address.
|
|
@@ -83,8 +91,12 @@ export function wrapRemoteCommand(device, cmd) {
|
|
|
83
91
|
* Host-key checking runs against the CLI-managed known_hosts store (never the
|
|
84
92
|
* user's `~/.ssh/known_hosts`): strict once `hostKey.pinned` is set, else
|
|
85
93
|
* `accept-new` to learn+pin the key on first connect (RUSH-1767).
|
|
94
|
+
*
|
|
95
|
+
* `opts.agentOnly` marks the connection as a read-only probe: for password auth
|
|
96
|
+
* it sets {@link ASKPASS_AGENT_ONLY_ENV} in the overlay so the askpass resolve
|
|
97
|
+
* stays broker-only and never pops a foreground biometric (RUSH-1970).
|
|
86
98
|
*/
|
|
87
|
-
export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}) {
|
|
99
|
+
export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, opts = {}) {
|
|
88
100
|
const target = sshTargetFor(device);
|
|
89
101
|
const remote = wrapRemoteCommand(device, cmd);
|
|
90
102
|
const env = {};
|
|
@@ -100,6 +112,8 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}) {
|
|
|
100
112
|
env.SSH_ASKPASS_REQUIRE = 'force';
|
|
101
113
|
env[ASKPASS_BUNDLE_ENV] = device.auth.bundle;
|
|
102
114
|
env[ASKPASS_KEY_ENV] = device.auth.bundleKey ?? 'password';
|
|
115
|
+
if (opts.agentOnly)
|
|
116
|
+
env[ASKPASS_AGENT_ONLY_ENV] = '1';
|
|
103
117
|
args.push('-o', 'PreferredAuthentications=password', '-o', 'PubkeyAuthentication=no', '-o', 'NumberOfPasswordPrompts=1');
|
|
104
118
|
}
|
|
105
119
|
else {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* the remaining devices.
|
|
9
9
|
*/
|
|
10
10
|
import { type DeviceProfile, type DeviceRegistry } from './registry.js';
|
|
11
|
+
import type { DeviceStats } from './health.js';
|
|
11
12
|
export type FleetSkipReason = 'offline' | 'no-address' | 'control';
|
|
12
13
|
/** npm dist-tags / semver pins only — rejects shell metacharacters. */
|
|
13
14
|
export declare const FLEET_VERSION_RE: RegExp;
|
|
@@ -55,6 +56,26 @@ export declare function planFleetTargets(reg: DeviceRegistry): FleetTarget[];
|
|
|
55
56
|
* surface — so their `skip` reason still flows through as an `unreachable` row.
|
|
56
57
|
*/
|
|
57
58
|
export declare function remoteFleetTargets(planned: FleetTarget[], self: string): FleetTarget[];
|
|
59
|
+
/**
|
|
60
|
+
* Decide whether a fleet-health target should skip the expensive version+doctor
|
|
61
|
+
* dials (`agents fleet status`). The cheap stats probe (~2.5s, same registry
|
|
62
|
+
* address) has already tried this box one step earlier. If it came back
|
|
63
|
+
* unreachable, dialing `agents --version` (15s) + `agents doctor --json` (30s)
|
|
64
|
+
* would almost certainly fail the same way — just 45s slower — so one
|
|
65
|
+
* genuinely-offline box would stall the whole matrix (the ~60s hang, RUSH-1964).
|
|
66
|
+
*
|
|
67
|
+
* We trust the stats verdict on the DEFAULT path, not only under
|
|
68
|
+
* `--refresh`/`--live`: `probeDeviceStats` and the version/doctor dials share
|
|
69
|
+
* one ssh path (`fleetDialTarget`), so reachability is not per-probe. The
|
|
70
|
+
* verdict is either freshly probed this run or daemon-warmed (~3min) with the
|
|
71
|
+
* live write-back from RUSH-1965, so a box that came online in the last few
|
|
72
|
+
* minutes is the only false-negative window — it renders `unreachable` until the
|
|
73
|
+
* next stats warm, which beats letting it hang the status glance for 45s.
|
|
74
|
+
*
|
|
75
|
+
* An existing skip (control/offline/no-address from {@link planFleetTargets})
|
|
76
|
+
* always wins — those are classified before any probe.
|
|
77
|
+
*/
|
|
78
|
+
export declare function fleetHealthSkip(currentSkip: FleetSkipReason | string | undefined, stats: DeviceStats | undefined): FleetSkipReason | string | undefined;
|
|
58
79
|
/** Human label for a skip reason. */
|
|
59
80
|
export declare function skipLabel(reason: FleetSkipReason): string;
|
|
60
81
|
/**
|
|
@@ -115,5 +136,20 @@ export interface RunFleetOptions {
|
|
|
115
136
|
* recorded as `failed` so one bad device never aborts the rest.
|
|
116
137
|
*/
|
|
117
138
|
export declare function runFleet(targets: FleetTarget[], cmd: string[], opts?: RunFleetOptions): FleetRunResult[];
|
|
139
|
+
export interface FanOutDeviceOptions {
|
|
140
|
+
/**
|
|
141
|
+
* Per-device deadline in milliseconds. When set, any probe that does not
|
|
142
|
+
* settle within this window is abandoned via `Promise.race` against a
|
|
143
|
+
* rejection timer and recorded as a `failed` result with the message
|
|
144
|
+
* `'timed out'`. There is no AbortController — the underlying probe
|
|
145
|
+
* continues running in the background; cancellation of the in-flight work
|
|
146
|
+
* is the caller's responsibility. In practice `probeRemoteAuth` relies on
|
|
147
|
+
* `sshExecAsync`'s own 15 s timer to kill the ssh child independently.
|
|
148
|
+
* The per-device ssh timeout passed directly to {@link sshExecAsync} is
|
|
149
|
+
* the first line of defence; this acts as a hard backstop so one slow
|
|
150
|
+
* device can never stall the entire fan-out past its budget.
|
|
151
|
+
*/
|
|
152
|
+
perDeviceTimeoutMs?: number;
|
|
153
|
+
}
|
|
118
154
|
/** Run one async probe per device in parallel, preserving input order. */
|
|
119
|
-
export declare function fanOutDevices<T, Target extends FanOutDeviceTarget = FanOutDeviceTarget>(targets: Target[], probe: (target: Target) => Promise<T
|
|
155
|
+
export declare function fanOutDevices<T, Target extends FanOutDeviceTarget = FanOutDeviceTarget>(targets: Target[], probe: (target: Target) => Promise<T>, opts?: FanOutDeviceOptions): Promise<FanOutDeviceResult<T>[]>;
|
|
@@ -54,6 +54,32 @@ export function planFleetTargets(reg) {
|
|
|
54
54
|
export function remoteFleetTargets(planned, self) {
|
|
55
55
|
return planned.filter((t) => t.device.name !== self && t.skip !== 'control');
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Decide whether a fleet-health target should skip the expensive version+doctor
|
|
59
|
+
* dials (`agents fleet status`). The cheap stats probe (~2.5s, same registry
|
|
60
|
+
* address) has already tried this box one step earlier. If it came back
|
|
61
|
+
* unreachable, dialing `agents --version` (15s) + `agents doctor --json` (30s)
|
|
62
|
+
* would almost certainly fail the same way — just 45s slower — so one
|
|
63
|
+
* genuinely-offline box would stall the whole matrix (the ~60s hang, RUSH-1964).
|
|
64
|
+
*
|
|
65
|
+
* We trust the stats verdict on the DEFAULT path, not only under
|
|
66
|
+
* `--refresh`/`--live`: `probeDeviceStats` and the version/doctor dials share
|
|
67
|
+
* one ssh path (`fleetDialTarget`), so reachability is not per-probe. The
|
|
68
|
+
* verdict is either freshly probed this run or daemon-warmed (~3min) with the
|
|
69
|
+
* live write-back from RUSH-1965, so a box that came online in the last few
|
|
70
|
+
* minutes is the only false-negative window — it renders `unreachable` until the
|
|
71
|
+
* next stats warm, which beats letting it hang the status glance for 45s.
|
|
72
|
+
*
|
|
73
|
+
* An existing skip (control/offline/no-address from {@link planFleetTargets})
|
|
74
|
+
* always wins — those are classified before any probe.
|
|
75
|
+
*/
|
|
76
|
+
export function fleetHealthSkip(currentSkip, stats) {
|
|
77
|
+
if (currentSkip)
|
|
78
|
+
return currentSkip;
|
|
79
|
+
if (stats?.reachable === false)
|
|
80
|
+
return 'unreachable';
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
57
83
|
/** Human label for a skip reason. */
|
|
58
84
|
export function skipLabel(reason) {
|
|
59
85
|
switch (reason) {
|
|
@@ -186,7 +212,7 @@ export function runFleet(targets, cmd, opts = {}) {
|
|
|
186
212
|
return results;
|
|
187
213
|
}
|
|
188
214
|
/** Run one async probe per device in parallel, preserving input order. */
|
|
189
|
-
export async function fanOutDevices(targets, probe) {
|
|
215
|
+
export async function fanOutDevices(targets, probe, opts = {}) {
|
|
190
216
|
return Promise.all(targets.map(async (target) => {
|
|
191
217
|
if (target.skip) {
|
|
192
218
|
return {
|
|
@@ -196,10 +222,18 @@ export async function fanOutDevices(targets, probe) {
|
|
|
196
222
|
};
|
|
197
223
|
}
|
|
198
224
|
try {
|
|
225
|
+
let probePromise = probe(target);
|
|
226
|
+
if (opts.perDeviceTimeoutMs) {
|
|
227
|
+
const timeoutMs = opts.perDeviceTimeoutMs;
|
|
228
|
+
probePromise = Promise.race([
|
|
229
|
+
probePromise,
|
|
230
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), timeoutMs)),
|
|
231
|
+
]);
|
|
232
|
+
}
|
|
199
233
|
return {
|
|
200
234
|
name: target.name,
|
|
201
235
|
status: 'ok',
|
|
202
|
-
value: await
|
|
236
|
+
value: await probePromise,
|
|
203
237
|
};
|
|
204
238
|
}
|
|
205
239
|
catch (err) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type DeviceStats } from './health.js';
|
|
2
2
|
import { type HostAuthSummary } from '../auth-health.js';
|
|
3
|
+
import type { OnlineState } from './reachability.js';
|
|
3
4
|
export interface FleetCliStatus {
|
|
4
5
|
installed: boolean;
|
|
5
6
|
path: string | null;
|
|
@@ -32,6 +33,13 @@ export interface FleetHealthRow {
|
|
|
32
33
|
/** Cached auth-health rollup for this host (the Auth column). Undefined when
|
|
33
34
|
* the host has never been probed (`agents fleet ping`) or the cache is cold. */
|
|
34
35
|
auth?: HostAuthSummary;
|
|
36
|
+
/** Resolved online/offline verdict (from {@link deviceOnlineState}). Populated
|
|
37
|
+
* by `runFleetStatus` for the summary view; undefined in the raw grid. */
|
|
38
|
+
online?: OnlineState;
|
|
39
|
+
/** When this box was last seen reachable (ISO), for the "last seen …" note on
|
|
40
|
+
* an offline row. Sourced from the registry's tailscale snapshot / reachability
|
|
41
|
+
* verdict. Undefined when never recorded. */
|
|
42
|
+
lastSeen?: string;
|
|
35
43
|
}
|
|
36
44
|
export interface FleetWarning {
|
|
37
45
|
kind: 'unreachable' | 'drift' | 'cli' | 'version-skew';
|
|
@@ -55,3 +63,33 @@ export declare function renderFleetMatrix(report: FleetHealthReport): string[];
|
|
|
55
63
|
* table carries no timestamped data at all (nothing to date).
|
|
56
64
|
*/
|
|
57
65
|
export declare function freshnessFooter(rows: FleetHealthRow[], now?: number): string | null;
|
|
66
|
+
/** Collapse a long dev build (`0.0.0-dev.<sha>[-dirty]`) to `dev`/`dev-dirty`;
|
|
67
|
+
* released semver is shown verbatim. Keeps the version column narrow and stops
|
|
68
|
+
* a single dev box from widening every row. */
|
|
69
|
+
export declare function shortVersion(version: string | null | undefined): string;
|
|
70
|
+
/** OS bucket label for grouping. Anything unrecognized falls under "Other". */
|
|
71
|
+
export declare function platformGroupLabel(platform: string | undefined): 'macOS' | 'Linux' | 'Windows' | 'Other';
|
|
72
|
+
export interface FleetAttentionItem {
|
|
73
|
+
/** Leading mark: `○` offline, `⚠` config/CLI/version issue. */
|
|
74
|
+
glyph: 'offline' | 'warn';
|
|
75
|
+
subject: string;
|
|
76
|
+
detail: string;
|
|
77
|
+
/** The exact command (or instruction) that fixes it. */
|
|
78
|
+
fix: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The actionable problems only — each with the command that fixes it. Order:
|
|
82
|
+
* offline boxes, CLI gaps, active-version config drift, then version skew.
|
|
83
|
+
* A healthy fleet returns `[]` (the caller prints an all-clear line).
|
|
84
|
+
*/
|
|
85
|
+
export declare function buildFleetAttentionItems(report: FleetHealthReport, now?: number): FleetAttentionItem[];
|
|
86
|
+
/**
|
|
87
|
+
* The default `agents fleet status` view (RUSH-1966): a one-line rollup, a
|
|
88
|
+
* NEEDS ATTENTION list of only the actionable problems (each with its fix
|
|
89
|
+
* command), quiet per-device rows grouped by OS, and an honest footer. The full
|
|
90
|
+
* auth/CLI/sync grid moves behind `--verbose` ({@link renderFleetMatrix}).
|
|
91
|
+
*/
|
|
92
|
+
export declare function renderFleetSummary(report: FleetHealthReport, opts?: {
|
|
93
|
+
self?: string;
|
|
94
|
+
now?: number;
|
|
95
|
+
}): string[];
|
|
@@ -226,3 +226,217 @@ export function freshnessFooter(rows, now = Date.now()) {
|
|
|
226
226
|
return null;
|
|
227
227
|
return ` updated ${parts.join(' · ')} — pass --refresh (--live) for a live probe`;
|
|
228
228
|
}
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Summary view (default): rollup + NEEDS ATTENTION + OS groups + footer.
|
|
231
|
+
// The full grid above is kept for `--verbose`. (RUSH-1966)
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
/** Collapse a long dev build (`0.0.0-dev.<sha>[-dirty]`) to `dev`/`dev-dirty`;
|
|
234
|
+
* released semver is shown verbatim. Keeps the version column narrow and stops
|
|
235
|
+
* a single dev box from widening every row. */
|
|
236
|
+
export function shortVersion(version) {
|
|
237
|
+
if (!version)
|
|
238
|
+
return '—';
|
|
239
|
+
const m = version.match(/-dev\b/);
|
|
240
|
+
if (m)
|
|
241
|
+
return /dirty/.test(version) ? 'dev-dirty' : 'dev';
|
|
242
|
+
return version;
|
|
243
|
+
}
|
|
244
|
+
/** OS bucket label for grouping. Anything unrecognized falls under "Other". */
|
|
245
|
+
export function platformGroupLabel(platform) {
|
|
246
|
+
const p = (platform ?? '').toLowerCase();
|
|
247
|
+
if (p === 'macos' || p === 'darwin')
|
|
248
|
+
return 'macOS';
|
|
249
|
+
if (p === 'linux')
|
|
250
|
+
return 'Linux';
|
|
251
|
+
if (p.startsWith('win'))
|
|
252
|
+
return 'Windows';
|
|
253
|
+
return 'Other';
|
|
254
|
+
}
|
|
255
|
+
const GROUP_ORDER = ['macOS', 'Linux', 'Windows', 'Other'];
|
|
256
|
+
/** Non-fresh sync rows for the ACTIVE (default) version only — the drift that a
|
|
257
|
+
* running install actually feels, not stale/cold counts across old orphans. */
|
|
258
|
+
function activeDriftRows(row) {
|
|
259
|
+
return row.sync.filter((s) => s.isDefault && s.status !== 'fresh');
|
|
260
|
+
}
|
|
261
|
+
function isOffline(row) {
|
|
262
|
+
// Prefer the resolved verdict; fall back to a probe error/skip when the caller
|
|
263
|
+
// didn't populate it (e.g. a raw report). Only a positive 'online' is "up".
|
|
264
|
+
if (row.online)
|
|
265
|
+
return row.online !== 'online';
|
|
266
|
+
return Boolean(row.error || row.skipped);
|
|
267
|
+
}
|
|
268
|
+
/** A box with a real offline verdict — as opposed to `unknown` (registered but
|
|
269
|
+
* never addressed/probed). Only a genuine offline is a "check the box" item;
|
|
270
|
+
* an unknown box is unconfigured, not down. */
|
|
271
|
+
function isGenuinelyOffline(row) {
|
|
272
|
+
return row.online === 'offline' || (!row.online && Boolean(row.error || row.skipped));
|
|
273
|
+
}
|
|
274
|
+
/** A CLI count is only worth flagging when it's stark — the box is missing more
|
|
275
|
+
* than two-thirds of the known agent CLIs (e.g. 1/9), which signals a
|
|
276
|
+
* broken/half-set-up box. A normal partial install (a box that just doesn't run
|
|
277
|
+
* every agent) is not a problem, so this deliberately does NOT fire at 4/9 or
|
|
278
|
+
* 6/9. The full count stays visible under `--verbose`. */
|
|
279
|
+
function starkCliGap(row) {
|
|
280
|
+
const { installed, total } = installedCliCount(row);
|
|
281
|
+
return total > 0 && installed * 3 < total ? { installed, total } : null;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The actionable problems only — each with the command that fixes it. Order:
|
|
285
|
+
* offline boxes, CLI gaps, active-version config drift, then version skew.
|
|
286
|
+
* A healthy fleet returns `[]` (the caller prints an all-clear line).
|
|
287
|
+
*/
|
|
288
|
+
export function buildFleetAttentionItems(report, now = Date.now()) {
|
|
289
|
+
const items = [];
|
|
290
|
+
// 1) Genuinely-offline boxes (not `unknown`/unconfigured), each individually.
|
|
291
|
+
for (const row of report.devices) {
|
|
292
|
+
if (!isGenuinelyOffline(row))
|
|
293
|
+
continue;
|
|
294
|
+
const seen = row.lastSeen ? ` · last seen ${formatCheckedAge(Date.parse(row.lastSeen), now)}` : '';
|
|
295
|
+
items.push({ glyph: 'offline', subject: row.name, detail: `offline${seen}`, fix: 'check the box' });
|
|
296
|
+
}
|
|
297
|
+
// 2) Boxes that need `agents apply` — merge config drift and a stark CLI gap
|
|
298
|
+
// into ONE item per box (both are fixed by the same command, so don't
|
|
299
|
+
// double-list). An offline box's config/CLI is unknowable, so skip it.
|
|
300
|
+
for (const row of report.devices) {
|
|
301
|
+
if (isOffline(row))
|
|
302
|
+
continue;
|
|
303
|
+
const reasons = [];
|
|
304
|
+
if (activeDriftRows(row).length > 0)
|
|
305
|
+
reasons.push('config drift');
|
|
306
|
+
const stark = starkCliGap(row);
|
|
307
|
+
if (stark)
|
|
308
|
+
reasons.push(`only ${stark.installed} of ${stark.total} agent CLIs installed`);
|
|
309
|
+
if (reasons.length > 0) {
|
|
310
|
+
items.push({ glyph: 'warn', subject: row.name, detail: reasons.join(' · '), fix: `agents apply ${row.name}` });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// 3) Version skew across the fleet — one line.
|
|
314
|
+
const skew = report.warnings.find((w) => w.kind === 'version-skew');
|
|
315
|
+
if (skew) {
|
|
316
|
+
const counts = new Map();
|
|
317
|
+
for (const row of report.devices) {
|
|
318
|
+
if (!row.version)
|
|
319
|
+
continue;
|
|
320
|
+
const v = shortVersion(row.version);
|
|
321
|
+
counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
322
|
+
}
|
|
323
|
+
const summary = Array.from(counts.entries())
|
|
324
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
325
|
+
.map(([v, n]) => `${n}× ${v}`)
|
|
326
|
+
.join(' · ');
|
|
327
|
+
items.push({ glyph: 'warn', subject: 'version skew', detail: summary, fix: 'agents upgrade --fleet' });
|
|
328
|
+
}
|
|
329
|
+
return items;
|
|
330
|
+
}
|
|
331
|
+
/** Right-aligned `<content>` on the same line as `left`, clamped so it never
|
|
332
|
+
* overlaps the left text on a narrow terminal. */
|
|
333
|
+
function alignRight(left, right, width) {
|
|
334
|
+
const gap = width - stringWidth(left) - stringWidth(right);
|
|
335
|
+
return gap > 1 ? `${left}${' '.repeat(gap)}${right}` : `${left} ${right}`;
|
|
336
|
+
}
|
|
337
|
+
function headroomWord(row) {
|
|
338
|
+
if (isOffline(row))
|
|
339
|
+
return chalk.gray(row.online === 'unknown' ? 'unknown' : 'offline');
|
|
340
|
+
return headroomLabel(row);
|
|
341
|
+
}
|
|
342
|
+
function loadMemCell(row) {
|
|
343
|
+
if (isOffline(row) || !row.stats?.reachable)
|
|
344
|
+
return chalk.gray('—');
|
|
345
|
+
const load = row.stats.loadPercent === undefined ? '—' : `${Math.round(row.stats.loadPercent)}%`;
|
|
346
|
+
const mem = row.stats.memPercent === undefined ? '—' : `${Math.round(row.stats.memPercent)}%`;
|
|
347
|
+
return `${load} / ${mem}`;
|
|
348
|
+
}
|
|
349
|
+
function versionCell(row) {
|
|
350
|
+
if (isOffline(row))
|
|
351
|
+
return chalk.gray('—');
|
|
352
|
+
return shortVersion(row.version);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* The default `agents fleet status` view (RUSH-1966): a one-line rollup, a
|
|
356
|
+
* NEEDS ATTENTION list of only the actionable problems (each with its fix
|
|
357
|
+
* command), quiet per-device rows grouped by OS, and an honest footer. The full
|
|
358
|
+
* auth/CLI/sync grid moves behind `--verbose` ({@link renderFleetMatrix}).
|
|
359
|
+
*/
|
|
360
|
+
export function renderFleetSummary(report, opts = {}) {
|
|
361
|
+
const now = opts.now ?? Date.now();
|
|
362
|
+
const width = terminalWidth();
|
|
363
|
+
const rows = report.devices;
|
|
364
|
+
if (rows.length === 0)
|
|
365
|
+
return [chalk.gray('No registered devices. Run `agents devices` to register some.')];
|
|
366
|
+
const online = rows.filter((r) => !isOffline(r)).length;
|
|
367
|
+
const offline = rows.length - online;
|
|
368
|
+
const oldestStats = oldestAcross(rows, (r) => r.stats?.fetchedAt);
|
|
369
|
+
const cachedAge = oldestStats != null ? `cached ${formatCheckedAge(oldestStats, now)}` : null;
|
|
370
|
+
const rollupRight = [opts.self, cachedAge].filter(Boolean).join(' · ');
|
|
371
|
+
const rollupLeft = `${chalk.bold('Fleet')} ${chalk.green('●')} ${online} online ${chalk.gray('○')} ${offline} offline`;
|
|
372
|
+
const lines = [rollupRight ? alignRight(rollupLeft, chalk.gray(rollupRight), width) : rollupLeft, ''];
|
|
373
|
+
const items = buildFleetAttentionItems(report, now);
|
|
374
|
+
if (items.length === 0) {
|
|
375
|
+
lines.push(chalk.green('Everything looks healthy.'), '');
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
lines.push(chalk.bold(`NEEDS ATTENTION (${items.length})`));
|
|
379
|
+
const subjW = Math.max(...items.map((i) => i.subject.length));
|
|
380
|
+
const detailW = Math.max(...items.map((i) => stringWidth(i.detail)));
|
|
381
|
+
for (const it of items) {
|
|
382
|
+
const glyph = it.glyph === 'offline' ? chalk.gray('○') : chalk.yellow('⚠');
|
|
383
|
+
const subject = it.glyph === 'offline' ? chalk.gray(it.subject) : it.subject;
|
|
384
|
+
lines.push(` ${glyph} ${padToWidth(subject, subjW)} ${padToWidth(it.detail, detailW)} ${chalk.cyan(`→ ${it.fix}`)}`);
|
|
385
|
+
}
|
|
386
|
+
lines.push('');
|
|
387
|
+
}
|
|
388
|
+
// Per-device rows, grouped by OS. Within a group, this machine floats to the
|
|
389
|
+
// top; the rest stay alphabetical.
|
|
390
|
+
const nameW = Math.min(18, Math.max(6, ...rows.map((r) => r.name.length)));
|
|
391
|
+
const headW = Math.max(...rows.map((r) => stringWidth(headroomWord(r))));
|
|
392
|
+
const loadW = Math.max(...rows.map((r) => stringWidth(loadMemCell(r))));
|
|
393
|
+
const verW = Math.max(7, ...rows.map((r) => stringWidth(versionCell(r))));
|
|
394
|
+
const grouped = new Map();
|
|
395
|
+
for (const r of rows) {
|
|
396
|
+
const g = platformGroupLabel(r.platform);
|
|
397
|
+
(grouped.get(g) ?? grouped.set(g, []).get(g)).push(r);
|
|
398
|
+
}
|
|
399
|
+
for (const group of GROUP_ORDER) {
|
|
400
|
+
const members = grouped.get(group);
|
|
401
|
+
if (!members || members.length === 0)
|
|
402
|
+
continue;
|
|
403
|
+
members.sort((a, b) => (a.name === opts.self ? -1 : b.name === opts.self ? 1 : 0) || a.name.localeCompare(b.name));
|
|
404
|
+
lines.push(chalk.bold(group));
|
|
405
|
+
for (const row of members) {
|
|
406
|
+
const isSelf = row.name === opts.self;
|
|
407
|
+
const prefix = isSelf ? chalk.cyan('▸') : ' ';
|
|
408
|
+
const marks = [];
|
|
409
|
+
if (!isOffline(row)) {
|
|
410
|
+
const stark = starkCliGap(row);
|
|
411
|
+
if (stark)
|
|
412
|
+
marks.push(chalk.yellow(`⚠ CLIs ${stark.installed}/${stark.total}`));
|
|
413
|
+
}
|
|
414
|
+
else if (isGenuinelyOffline(row) && row.lastSeen) {
|
|
415
|
+
marks.push(chalk.gray(`last seen ${formatCheckedAge(Date.parse(row.lastSeen), now)}`));
|
|
416
|
+
}
|
|
417
|
+
if (isSelf)
|
|
418
|
+
marks.push(chalk.cyan('← this machine'));
|
|
419
|
+
const note = marks.length ? ` ${marks.join(' ')}` : '';
|
|
420
|
+
lines.push(` ${prefix} ${padToWidth(truncateToWidth(row.name, nameW), nameW)} ` +
|
|
421
|
+
`${padToWidth(headroomWord(row), headW)} ` +
|
|
422
|
+
`${padToWidth(loadMemCell(row), loadW)} ` +
|
|
423
|
+
`${padToWidth(versionCell(row), verW)}` +
|
|
424
|
+
note);
|
|
425
|
+
}
|
|
426
|
+
lines.push('');
|
|
427
|
+
}
|
|
428
|
+
// Footer: orphan-version nudge (a `prune` concern, not drift), then an honest
|
|
429
|
+
// freshness line naming the cache age and what --live/--verbose add.
|
|
430
|
+
const orphaned = rows.filter((r) => r.orphans.length > 0).length;
|
|
431
|
+
if (orphaned > 0) {
|
|
432
|
+
const subject = orphaned === 1 ? '1 device carries' : `${orphaned} devices carry`;
|
|
433
|
+
lines.push(chalk.gray(`${subject} orphaned versions · run ${chalk.cyan('agents prune')} to reclaim disk`));
|
|
434
|
+
}
|
|
435
|
+
const freshBits = [
|
|
436
|
+
cachedAge,
|
|
437
|
+
'pass --live to re-probe auth + reachability',
|
|
438
|
+
'pass --verbose for the auth/CLI/sync columns',
|
|
439
|
+
].filter(Boolean);
|
|
440
|
+
lines.push(chalk.gray(freshBits.join(' · ')));
|
|
441
|
+
return lines;
|
|
442
|
+
}
|