@phnx-labs/agents-cli 1.22.57 → 1.22.59
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 +294 -0
- package/README.md +29 -0
- package/dist/bootstrap.js +39 -1
- package/dist/commands/accounts.js +7 -3
- package/dist/commands/apply.js +10 -2
- package/dist/commands/fork.d.ts +23 -10
- package/dist/commands/fork.js +115 -58
- package/dist/commands/monitors.js +198 -23
- package/dist/commands/prune.js +5 -3
- package/dist/commands/routines.d.ts +8 -0
- package/dist/commands/routines.js +57 -3
- package/dist/commands/routines.test-fixture.js +5 -0
- package/dist/commands/send.d.ts +2 -1
- package/dist/commands/send.js +7 -5
- package/dist/commands/sessions-picker.d.ts +11 -0
- package/dist/commands/sessions-picker.js +16 -0
- package/dist/commands/sessions-stats.js +37 -5
- package/dist/commands/sessions.js +40 -5
- package/dist/commands/share.d.ts +14 -0
- package/dist/commands/share.js +43 -2
- package/dist/commands/ssh.js +12 -1
- package/dist/commands/status.js +1 -1
- package/dist/commands/sync.js +83 -7
- package/dist/commands/traces.js +7 -0
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.js +7 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -1
- package/dist/lib/account-registry.d.ts +5 -1
- package/dist/lib/account-registry.js +47 -14
- package/dist/lib/accounting/capacity.d.ts +18 -7
- package/dist/lib/accounting/capacity.js +19 -8
- package/dist/lib/accounting/usage-sync.d.ts +29 -1
- package/dist/lib/accounting/usage-sync.js +76 -2
- package/dist/lib/accounting/usage.js +7 -1
- package/dist/lib/auth-mint.d.ts +11 -1
- package/dist/lib/auth-mint.js +21 -6
- package/dist/lib/auto-pull-worker.js +7 -2
- package/dist/lib/browser/ipc.d.ts +8 -0
- package/dist/lib/browser/ipc.js +87 -0
- package/dist/lib/browser/service.d.ts +19 -0
- package/dist/lib/browser/service.js +96 -11
- package/dist/lib/browser/sessions-list.js +10 -1
- package/dist/lib/cloud/rush.d.ts +7 -0
- package/dist/lib/cloud/rush.js +29 -1
- package/dist/lib/daemon/daemon.d.ts +22 -0
- package/dist/lib/daemon/daemon.js +39 -0
- package/dist/lib/daemon/runner.d.ts +3 -0
- package/dist/lib/daemon/runner.js +86 -45
- package/dist/lib/daemon/session-index-service.js +9 -1
- package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
- package/dist/lib/daemon/usage-sync-service.js +14 -8
- package/dist/lib/daemon-services.js +1 -1
- package/dist/lib/daemon-ticks.d.ts +15 -0
- package/dist/lib/daemon-ticks.js +26 -0
- package/dist/lib/device-config.d.ts +5 -1
- package/dist/lib/device-config.js +2 -2
- package/dist/lib/devices/connect.d.ts +17 -8
- package/dist/lib/devices/connect.js +31 -14
- package/dist/lib/devices/health.js +5 -1
- package/dist/lib/devices/pool.d.ts +25 -2
- package/dist/lib/devices/pool.js +32 -2
- package/dist/lib/devices/stats-cache.d.ts +0 -6
- package/dist/lib/devices/stats-cache.js +2 -9
- package/dist/lib/doctor-diff.d.ts +14 -0
- package/dist/lib/doctor-diff.js +120 -9
- package/dist/lib/fleet/manifest.d.ts +17 -0
- package/dist/lib/fleet/manifest.js +26 -0
- package/dist/lib/git.d.ts +38 -0
- package/dist/lib/git.js +58 -0
- package/dist/lib/hooks/install.d.ts +27 -11
- package/dist/lib/hooks/install.js +42 -17
- package/dist/lib/hosts/ready.d.ts +8 -0
- package/dist/lib/hosts/ready.js +13 -2
- package/dist/lib/hosts/reconnect.d.ts +52 -203
- package/dist/lib/hosts/reconnect.js +64 -284
- package/dist/lib/installations/migrate.d.ts +6 -120
- package/dist/lib/installations/migrate.js +27 -259
- package/dist/lib/installations/shims.d.ts +13 -95
- package/dist/lib/installations/shims.js +22 -139
- package/dist/lib/installations/store.js +1 -1
- package/dist/lib/installations/versions.d.ts +43 -133
- package/dist/lib/installations/versions.js +94 -206
- package/dist/lib/monitors/config.d.ts +71 -3
- package/dist/lib/monitors/config.js +100 -12
- package/dist/lib/monitors/pid-watch.d.ts +35 -0
- package/dist/lib/monitors/pid-watch.js +45 -0
- package/dist/lib/monitors/remote.d.ts +18 -0
- package/dist/lib/monitors/remote.js +11 -0
- package/dist/lib/permissions.js +7 -2
- package/dist/lib/plugins/plugins.d.ts +17 -3
- package/dist/lib/plugins/plugins.js +84 -9
- package/dist/lib/plugins/skills.d.ts +8 -1
- package/dist/lib/plugins/skills.js +18 -2
- package/dist/lib/pty-server.d.ts +14 -0
- package/dist/lib/pty-server.js +49 -5
- package/dist/lib/refresh.d.ts +9 -0
- package/dist/lib/refresh.js +3 -1
- package/dist/lib/routine-readiness.d.ts +15 -1
- package/dist/lib/routine-readiness.js +41 -0
- package/dist/lib/sandbox.d.ts +4 -1
- package/dist/lib/sandbox.js +30 -1
- package/dist/lib/secrets/agent.d.ts +80 -225
- package/dist/lib/secrets/agent.js +139 -401
- package/dist/lib/secrets/bundles.d.ts +73 -222
- package/dist/lib/secrets/bundles.js +168 -467
- package/dist/lib/secrets/drivers/rush.js +5 -0
- package/dist/lib/secrets/reaper.d.ts +28 -70
- package/dist/lib/secrets/reaper.js +30 -85
- package/dist/lib/secrets/remote.d.ts +42 -129
- package/dist/lib/secrets/remote.js +55 -173
- package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
- package/dist/lib/self-heal/checks/install-staging.js +96 -0
- package/dist/lib/self-heal/registry.js +2 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/self-update.d.ts +65 -0
- package/dist/lib/self-update.js +138 -0
- package/dist/lib/session/active.d.ts +13 -1
- package/dist/lib/session/active.js +2 -0
- package/dist/lib/session/cloud.js +5 -0
- package/dist/lib/session/db.d.ts +51 -6
- package/dist/lib/session/db.js +266 -20
- package/dist/lib/session/fork.d.ts +45 -26
- package/dist/lib/session/fork.js +32 -95
- package/dist/lib/session/tool-calls.d.ts +43 -1
- package/dist/lib/session/tool-calls.js +74 -44
- package/dist/lib/session/tool-store.d.ts +33 -2
- package/dist/lib/session/tool-store.js +56 -3
- package/dist/lib/smart-launch.d.ts +6 -0
- package/dist/lib/smart-launch.js +5 -2
- package/dist/lib/staleness/writers/plugins.js +5 -2
- package/dist/lib/staleness/writers/sources.d.ts +5 -0
- package/dist/lib/staleness/writers/sources.js +2 -1
- package/dist/lib/staleness/writers/subagents.js +13 -3
- package/dist/lib/state.d.ts +7 -4
- package/dist/lib/state.js +7 -4
- package/dist/lib/subagents.js +8 -2
- package/dist/lib/sync-status.d.ts +22 -0
- package/dist/lib/sync-status.js +27 -0
- package/dist/lib/sync-umbrella.d.ts +9 -0
- package/dist/lib/sync-umbrella.js +21 -2
- package/dist/lib/teams/scheduler.d.ts +10 -0
- package/dist/lib/teams/scheduler.js +8 -0
- package/dist/lib/traces/insights.d.ts +47 -14
- package/dist/lib/traces/insights.js +92 -21
- package/dist/lib/traces/phenotype.d.ts +23 -3
- package/dist/lib/traces/phenotype.js +72 -24
- package/dist/lib/traces/sync.d.ts +128 -6
- package/dist/lib/traces/sync.js +294 -35
- package/dist/lib/traces/worker-template.js +154 -1
- package/dist/lib/view-types.d.ts +12 -0
- package/package.json +2 -2
package/dist/commands/sync.js
CHANGED
|
@@ -45,6 +45,7 @@ import { runLaunchSync } from '../lib/project-launch.js';
|
|
|
45
45
|
import { formatKeptProjectResources } from '../lib/project-resources.js';
|
|
46
46
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
47
47
|
import { runUmbrellaSync } from '../lib/sync-umbrella.js';
|
|
48
|
+
import { verifyVersionConverged, formatResidualDrift } from '../lib/sync-status.js';
|
|
48
49
|
import { addHostOption } from '../lib/hosts/option.js';
|
|
49
50
|
import { syncRepoGit, adoptUserRepoIfNeeded, recordUserRepoRemote, resolveUserRepoRemoteUrl } from '../lib/git.js';
|
|
50
51
|
import { getSystemAgentsDir, getUserAgentsDir, getEnabledExtraRepos } from '../lib/state.js';
|
|
@@ -53,6 +54,46 @@ import { registerStatusCommand } from './status.js';
|
|
|
53
54
|
function emitJson(payload) {
|
|
54
55
|
console.log(JSON.stringify(payload));
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Post-reconcile verification (PHNX-3186): after a sync writes into a set of
|
|
59
|
+
* version homes, re-read each home and confirm it now matches its resolved
|
|
60
|
+
* sources. The `agents sync` success line MUST NOT read "reconciled" while the
|
|
61
|
+
* drift it was asked to fix stays put. Any residual drifted/missing resource
|
|
62
|
+
* sets a non-zero exit code and — outside `--json` — names the exact unfixed
|
|
63
|
+
* drift so the operator sees what did not converge instead of a false ✓. Orphans
|
|
64
|
+
* are excluded (sync never removes them). Returns the residual for `--json`.
|
|
65
|
+
*/
|
|
66
|
+
function verifyReconciled(pairs, cwd) {
|
|
67
|
+
const residual = [];
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
for (const { agent, version } of pairs) {
|
|
70
|
+
const key = `${agent}@${version}`;
|
|
71
|
+
if (seen.has(key))
|
|
72
|
+
continue;
|
|
73
|
+
seen.add(key);
|
|
74
|
+
const r = verifyVersionConverged(agent, version, cwd);
|
|
75
|
+
if (r)
|
|
76
|
+
residual.push(r);
|
|
77
|
+
}
|
|
78
|
+
// Residual drift is reported loudly via `ok:false` + the printed ⚠ block, but
|
|
79
|
+
// does NOT change the exit code — matching the declined-write precedent
|
|
80
|
+
// (RUSH-2700). The fleet fan-out (`agents sync --device all`) THROWS on a
|
|
81
|
+
// non-zero peer exit (`hosts/passthrough.ts`), discarding that box's JSON, so a
|
|
82
|
+
// non-zero exit here would hide the very `residualDrift` payload it emitted.
|
|
83
|
+
// Callers that must treat an incomplete sync as failure read `ok`/`residualDrift`
|
|
84
|
+
// from `--json`, exactly as they already do for declines.
|
|
85
|
+
return residual;
|
|
86
|
+
}
|
|
87
|
+
/** Print the residual-drift block naming exactly what did not converge. */
|
|
88
|
+
function printResidual(residual, errLog) {
|
|
89
|
+
if (residual.length === 0)
|
|
90
|
+
return;
|
|
91
|
+
const lines = formatResidualDrift(residual);
|
|
92
|
+
errLog(chalk.yellow(`⚠ sync did not fully reconcile — ${lines.length} resource(s) still drift after writing:`));
|
|
93
|
+
for (const line of lines)
|
|
94
|
+
errLog(chalk.yellow(` ${line}`));
|
|
95
|
+
errLog(chalk.gray(' Re-run the sync; a gap that survives a re-run is a real unreconcilable drift — report it.'));
|
|
96
|
+
}
|
|
56
97
|
/**
|
|
57
98
|
* Translate per-kind CLI flags into a `ResourceSelection` for `buildSelection`.
|
|
58
99
|
* Returns `undefined` when no kind flag was given (caller should use full sync).
|
|
@@ -429,6 +470,7 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
|
|
|
429
470
|
await runInteractiveReconcile(opts, outLog, errLog);
|
|
430
471
|
return;
|
|
431
472
|
}
|
|
473
|
+
const cwd = opts.cwd || process.cwd();
|
|
432
474
|
const flags = {
|
|
433
475
|
repos: opts.repos,
|
|
434
476
|
secrets: opts.secrets,
|
|
@@ -459,10 +501,18 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
|
|
|
459
501
|
// --cloud (fetch-only, no local reconcile).
|
|
460
502
|
if (!opts.cloud)
|
|
461
503
|
evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog);
|
|
504
|
+
// Post-reconcile verification (PHNX-3186): re-read every version the reconcile
|
|
505
|
+
// wrote into and confirm it now matches source. Without this the umbrella
|
|
506
|
+
// printed `✓ sync: reconciled` unconditionally, the exact false-success the
|
|
507
|
+
// ticket reports. Residual drift downgrades the line and sets a non-zero exit.
|
|
508
|
+
const residual = result.reconciled
|
|
509
|
+
? verifyReconciled(result.reconciledVersions.map((r) => ({ agent: r.agent, version: r.version })), cwd)
|
|
510
|
+
: [];
|
|
462
511
|
if (json) {
|
|
463
512
|
emitJson({
|
|
464
|
-
// A refused resource is not a clean sync (RUSH-2700
|
|
465
|
-
|
|
513
|
+
// A refused resource OR residual drift is not a clean sync (RUSH-2700 +
|
|
514
|
+
// PHNX-3186).
|
|
515
|
+
ok: result.declined.length === 0 && residual.length === 0,
|
|
466
516
|
mode: 'umbrella',
|
|
467
517
|
plan: result.plan,
|
|
468
518
|
repos: result.repos,
|
|
@@ -470,6 +520,7 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
|
|
|
470
520
|
devices: result.devices,
|
|
471
521
|
reconciled: result.reconciled,
|
|
472
522
|
declined: result.declined,
|
|
523
|
+
residualDrift: residual,
|
|
473
524
|
});
|
|
474
525
|
return;
|
|
475
526
|
}
|
|
@@ -482,12 +533,18 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
|
|
|
482
533
|
if (result.secrets) {
|
|
483
534
|
parts.push(result.secrets.skipped ? 'secrets skipped' : `secrets ${result.secrets.pulled} pulled`);
|
|
484
535
|
}
|
|
536
|
+
// Only claim "reconciled" when the reconcile actually converged. Residual
|
|
537
|
+
// drift is already printed loudly by verifyReconciled above; reflect it in
|
|
538
|
+
// the one-line summary rather than a bare ✓.
|
|
485
539
|
if (result.reconciled)
|
|
486
|
-
parts.push('reconciled');
|
|
487
|
-
|
|
540
|
+
parts.push(residual.length === 0 ? 'reconciled' : 'reconcile INCOMPLETE');
|
|
541
|
+
const symbol = residual.length === 0 ? chalk.green('✓') : chalk.yellow('⚠');
|
|
542
|
+
const line = `${symbol} sync: ${parts.join(' · ') || 'nothing to do'}`;
|
|
543
|
+
outLog(residual.length === 0 ? chalk.green(line) : chalk.yellow(line));
|
|
488
544
|
const errs = [...(result.repos?.errors ?? []), ...(result.secrets?.errors ?? [])];
|
|
489
545
|
for (const e of errs)
|
|
490
546
|
errLog(chalk.yellow(` ! ${e}`));
|
|
547
|
+
printResidual(residual, errLog);
|
|
491
548
|
}
|
|
492
549
|
}
|
|
493
550
|
catch (err) {
|
|
@@ -700,13 +757,23 @@ async function runSync(agentSpec, repoArg, opts) {
|
|
|
700
757
|
if (!quiet && !json)
|
|
701
758
|
printSyncDetail(result, agentId, v, cwd);
|
|
702
759
|
}
|
|
760
|
+
// Verify each version actually converged; a repo-scoped sync only touched
|
|
761
|
+
// that repo's kinds, so skip verification there (the other layers legitimately
|
|
762
|
+
// still differ and are not this run's responsibility).
|
|
763
|
+
const residual = repoScope
|
|
764
|
+
? []
|
|
765
|
+
: verifyReconciled(versions.map(({ version: v }) => ({ agent: agentId, version: v })), cwd);
|
|
766
|
+
if (!quiet && !json)
|
|
767
|
+
printResidual(residual, errLog);
|
|
703
768
|
if (json) {
|
|
704
769
|
emitJson({
|
|
705
|
-
// Any version that refused a write
|
|
706
|
-
|
|
770
|
+
// Any version that refused a write, or any residual drift after the
|
|
771
|
+
// reconcile, makes the whole run not-ok (RUSH-2700 + PHNX-3186).
|
|
772
|
+
ok: versions.every(({ result }) => result.declined.length === 0) && residual.length === 0,
|
|
707
773
|
mode: 'agent-all',
|
|
708
774
|
agent: agentId,
|
|
709
775
|
repo: repoScope,
|
|
776
|
+
residualDrift: residual,
|
|
710
777
|
...healedPointers,
|
|
711
778
|
versions: versions.map(({ version: v, result }) => ({
|
|
712
779
|
version: v,
|
|
@@ -901,6 +968,11 @@ async function runSync(agentSpec, repoArg, opts) {
|
|
|
901
968
|
return;
|
|
902
969
|
}
|
|
903
970
|
const result = syncResourcesToVersion(agentId, version, selection, { projectDir, cwd, force, allowExecSurfaces: !!opts.allowExecSurfaces });
|
|
971
|
+
// Post-reconcile verification (PHNX-3186). Only for a FULL reconcile
|
|
972
|
+
// (`!selection`): an interactive subset-selection deliberately touched only the
|
|
973
|
+
// picked kinds, so the rest legitimately still differs and is not this run's
|
|
974
|
+
// failure. This is the exact path `agents sync <agent>[@version]` takes.
|
|
975
|
+
const residual = selection ? [] : verifyReconciled([{ agent: agentId, version }], cwd);
|
|
904
976
|
// Compile project-scope rules into the workspace itself so each agent's
|
|
905
977
|
// native loader picks up cwd/<INSTRUCTIONS_FILE>. projectDir is the
|
|
906
978
|
// .agents/ directory; the workspace root is its parent.
|
|
@@ -910,8 +982,11 @@ async function runSync(agentSpec, repoArg, opts) {
|
|
|
910
982
|
projectCompile = compileRulesForProject(projectRoot);
|
|
911
983
|
}
|
|
912
984
|
if (json) {
|
|
985
|
+
const base = agentSyncJson(agentId, version, result);
|
|
913
986
|
emitJson({
|
|
914
|
-
...
|
|
987
|
+
...base,
|
|
988
|
+
ok: base.ok === true && residual.length === 0,
|
|
989
|
+
residualDrift: residual,
|
|
915
990
|
...healedPointers,
|
|
916
991
|
projectCompile: projectCompile
|
|
917
992
|
? {
|
|
@@ -928,6 +1003,7 @@ async function runSync(agentSpec, repoArg, opts) {
|
|
|
928
1003
|
return;
|
|
929
1004
|
// ---------- 6. Detailed output ----------
|
|
930
1005
|
printSyncDetail(result, agentId, version, cwd);
|
|
1006
|
+
printResidual(residual, errLog);
|
|
931
1007
|
if (projectCompile?.compiled) {
|
|
932
1008
|
const linkInfo = projectCompile.symlinks.length > 0
|
|
933
1009
|
? ` (+ ${projectCompile.symlinks.join(', ')})`
|
package/dist/commands/traces.js
CHANGED
|
@@ -109,6 +109,13 @@ async function handleSync(opts) {
|
|
|
109
109
|
parts.push(chalk.dim('nothing new'));
|
|
110
110
|
}
|
|
111
111
|
console.log(parts.join(chalk.dim(' · ')));
|
|
112
|
+
// The per-session data can upload cleanly while the aggregated console shard
|
|
113
|
+
// fails to refresh (e.g. the sessions DB is locked by a running app). That used
|
|
114
|
+
// to be silent, so the console sat stale with no signal here (PHNX-3401).
|
|
115
|
+
if (result.indexError) {
|
|
116
|
+
console.log(chalk.yellow(' ⚠ console index not refreshed') +
|
|
117
|
+
chalk.dim(` (${result.indexError}) — dashboard may be stale; it will retry next sync`));
|
|
118
|
+
}
|
|
112
119
|
}
|
|
113
120
|
async function handleStatus() {
|
|
114
121
|
const ledger = readSyncLedger();
|
|
@@ -11,7 +11,7 @@ import { installVersion, removeVersion, listInstalledVersions, isVersionInstalle
|
|
|
11
11
|
import { carryForwardSettings } from '../lib/settings-manifest.js';
|
|
12
12
|
import { createShim, createVersionedAlias, supportsIsolatedInstall, isIsolationProtected, CONFIG_ENV_ISOLATED_AGENTS, removeShim, shimExists, getShimsDir, getShimPath, getPathShadowingExecutable, isShimsInPath, getPathSetupInstructions, addShimsToPath, switchConfigSymlink, switchHomeFileSymlinks, } from '../lib/installations/shims.js';
|
|
13
13
|
import { isInteractiveTerminal, isPromptCancelled, requireInteractiveSelection } from './utils.js';
|
|
14
|
-
import {
|
|
14
|
+
import { tryAutoPullSystemRepo } from '../lib/git.js';
|
|
15
15
|
import { getAgentsDir, getTrashVersionsDir } from '../lib/state.js';
|
|
16
16
|
import { setHelpSections } from '../lib/help.js';
|
|
17
17
|
import { updateSessionFilePaths } from '../lib/session/db.js';
|
|
@@ -630,10 +630,18 @@ export function registerVersionsCommands(program) {
|
|
|
630
630
|
useCmd.action(async (agentArg, versionArg, options) => {
|
|
631
631
|
try {
|
|
632
632
|
const skipPrompts = options.yes || !isInteractiveTerminal();
|
|
633
|
-
// Auto-pull ~/.agents/.system if it's a git repo
|
|
633
|
+
// Auto-pull ~/.agents/.system if it's a git repo tracking the EXPECTED
|
|
634
|
+
// system remote (silent on success). The system repo ships hooks that run
|
|
635
|
+
// as shell on tool events, so pulling from a repointed origin would be
|
|
636
|
+
// remote code execution — tryAutoPullSystemRepo refuses that (PHNX-2957).
|
|
634
637
|
const agentsDir = getAgentsDir();
|
|
635
|
-
const pullResult = await
|
|
636
|
-
if (pullResult.
|
|
638
|
+
const pullResult = await tryAutoPullSystemRepo(agentsDir);
|
|
639
|
+
if (pullResult.refused) {
|
|
640
|
+
console.error(chalk.red(`Refusing to auto-sync ~/.agents/.system: its origin (${pullResult.actualRemote}) is not the expected system repo.`));
|
|
641
|
+
console.error(chalk.gray('The system repo ships hooks that run on tool events; a fast-forward from an unexpected origin is not applied. ' +
|
|
642
|
+
'Re-point it (git -C ~/.agents/.system remote set-url origin <expected>) or set AGENTS_SYSTEM_REPO, then re-run `agents setup --force`.'));
|
|
643
|
+
}
|
|
644
|
+
else if (pullResult.pulled) {
|
|
637
645
|
console.log(chalk.gray('Synced ~/.agents/.system from remote'));
|
|
638
646
|
}
|
|
639
647
|
// Support both "claude 2.0.65" and "claude@2.0.65" formats
|
package/dist/commands/view.js
CHANGED
|
@@ -4,7 +4,7 @@ import { termLink } from '../lib/format.js';
|
|
|
4
4
|
import ora from 'ora';
|
|
5
5
|
import * as fs from 'fs';
|
|
6
6
|
import * as path from 'path';
|
|
7
|
-
import { AGENTS, ALL_AGENT_IDS, accountDisplayLabel, getAllCliStates, getUnmanagedCliState, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
|
|
7
|
+
import { AGENTS, ALL_AGENT_IDS, accountDisplayLabel, getAllCliStates, getUnmanagedCliState, getAccountInfo, credentialPresence, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
|
|
8
8
|
import { ambientClaudeToken, loginHint } from '../lib/signin-badge.js';
|
|
9
9
|
import { machineId } from '../lib/machine-id.js';
|
|
10
10
|
import { authCacheKey, readAuthHealthCache } from '../lib/auth-health.js';
|
|
@@ -24,7 +24,7 @@ import { listNativeAccounts } from '../lib/account-registry.js';
|
|
|
24
24
|
import { isGitRepo, getGitSyncStatus } from '../lib/git.js';
|
|
25
25
|
import { getCentralRulesFileName } from '../lib/rules/rules.js';
|
|
26
26
|
import { composeRulesFromState } from '../lib/rules/compose.js';
|
|
27
|
-
import { getConfiguredRunStrategy } from '../lib/accounting/rotate.js';
|
|
27
|
+
import { getConfiguredRunStrategy, isLaunchableSignedIn } from '../lib/accounting/rotate.js';
|
|
28
28
|
import { resolveRunDefaults } from '../lib/run-defaults.js';
|
|
29
29
|
import { resolveConfiguredModel } from '../lib/models.js';
|
|
30
30
|
import { listProfiles, profileExists, profileSummary, readProfile } from '../lib/profiles.js';
|
|
@@ -1293,6 +1293,11 @@ export async function collectAgentsJson(filterAgentId, resourceSections, opts) {
|
|
|
1293
1293
|
isolated: isVersionIsolated(agentId, version),
|
|
1294
1294
|
isIsolatedDefault: getIsolatedDefault(agentId) === version,
|
|
1295
1295
|
signedIn: info.signedIn,
|
|
1296
|
+
// The strict per-version launch truth (vs the display `signedIn` above,
|
|
1297
|
+
// which inherits the active/global HOME login). The same primitive
|
|
1298
|
+
// `collectRunCandidates` uses locally, so remote `--device auto` placement
|
|
1299
|
+
// is gated on identical launchability (PHNX-3466).
|
|
1300
|
+
launchable: isLaunchableSignedIn(info.signedIn, credentialPresence(agentId, home)),
|
|
1296
1301
|
authVerdict: authCache[authCacheKey(host, agentId, version)]?.verdict ?? null,
|
|
1297
1302
|
email: info.email,
|
|
1298
1303
|
accountId: info.accountId,
|
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* - `__secrets-get` / `__secrets-ping` / `__secrets-lock` (SYNC_* tokens)
|
|
16
16
|
* - `__shim`
|
|
17
17
|
* - `__claude-statusline`
|
|
18
|
-
* - `__usage-ingest`
|
|
18
|
+
* - `__usage-ingest` / `__usage-export`
|
|
19
19
|
* - `__daemon-run`
|
|
20
20
|
*
|
|
21
21
|
* The tokens are imported from the leaf module sync-commands.ts — the SAME
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* - `__secrets-get` / `__secrets-ping` / `__secrets-lock` (SYNC_* tokens)
|
|
16
16
|
* - `__shim`
|
|
17
17
|
* - `__claude-statusline`
|
|
18
|
-
* - `__usage-ingest`
|
|
18
|
+
* - `__usage-ingest` / `__usage-export`
|
|
19
19
|
* - `__daemon-run`
|
|
20
20
|
*
|
|
21
21
|
* The tokens are imported from the leaf module sync-commands.ts — the SAME
|
|
@@ -84,6 +84,11 @@ if (process.argv[2] === '__usage-ingest') {
|
|
|
84
84
|
const { runUsageIngest } = await import('./lib/accounting/usage-ingest.js');
|
|
85
85
|
process.exit(await runUsageIngest());
|
|
86
86
|
}
|
|
87
|
+
if (process.argv[2] === '__usage-export') {
|
|
88
|
+
const { exportClaudeUsageCacheRows } = await import('./lib/accounting/usage.js');
|
|
89
|
+
process.stdout.write(JSON.stringify({ v: 1, rows: exportClaudeUsageCacheRows() }));
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
87
92
|
if (process.argv[2] === '__daemon-run') {
|
|
88
93
|
const { runDaemon, log: daemonLog } = await import('./lib/daemon/daemon.js');
|
|
89
94
|
// RUSH-2418: the daemon is the one always-on process here, and it ran with no
|
|
@@ -63,11 +63,15 @@ export declare function unbindAccount(nameOrId: string, target: string): void;
|
|
|
63
63
|
/** Every target bound to `accountId`: this box's device-doc bindings merged over
|
|
64
64
|
* the fleet-shared central bindings (PHNX-3315). */
|
|
65
65
|
export declare function accountBindings(accountId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): string[];
|
|
66
|
+
export interface AccountSelection {
|
|
67
|
+
id: string;
|
|
68
|
+
source: 'explicit' | 'binding' | 'default';
|
|
69
|
+
}
|
|
66
70
|
/** Explicit selection wins over a configured per-harness default. */
|
|
67
71
|
export declare function resolveAccountSelection(explicit: string | undefined, agent: AgentId, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, opts?: {
|
|
68
72
|
useDefault?: boolean;
|
|
69
73
|
target?: string;
|
|
70
|
-
}):
|
|
74
|
+
}): AccountSelection | undefined;
|
|
71
75
|
export interface AddAccountOptions {
|
|
72
76
|
baseUrl?: string;
|
|
73
77
|
}
|
|
@@ -20,6 +20,7 @@ import * as fs from 'node:fs';
|
|
|
20
20
|
import * as path from 'node:path';
|
|
21
21
|
import * as crypto from 'node:crypto';
|
|
22
22
|
import * as yaml from 'yaml';
|
|
23
|
+
import chalk from 'chalk';
|
|
23
24
|
import { atomicWriteFileSync } from './fs-atomic.js';
|
|
24
25
|
import { getUserAgentsDir, readMeta, updateMeta } from './state.js';
|
|
25
26
|
import { deleteKeychainToken, getKeychainToken, hasKeychainToken } from './secrets/index.js';
|
|
@@ -307,18 +308,19 @@ export function accountBindings(accountId, meta) {
|
|
|
307
308
|
/** Explicit selection wins over a configured per-harness default. */
|
|
308
309
|
export function resolveAccountSelection(explicit, agent, meta, opts = {}) {
|
|
309
310
|
if (explicit)
|
|
310
|
-
return explicit;
|
|
311
|
+
return { id: explicit, source: 'explicit' };
|
|
311
312
|
// This box's device-doc bindings win over the fleet-shared central bindings
|
|
312
313
|
// (PHNX-3315), so a per-box account attachment resolves without touching the
|
|
313
314
|
// shared file. Defaults are genuinely fleet-shared and stay central.
|
|
314
315
|
const bindings = { ...meta.accounts?.bindings, ...meta.deviceAccounts?.bindings };
|
|
315
316
|
const bound = opts.target ? bindings[opts.target] : undefined;
|
|
316
317
|
if (bound)
|
|
317
|
-
return bound;
|
|
318
|
+
return { id: bound, source: 'binding' };
|
|
318
319
|
const deviceScoped = bindings[agent];
|
|
319
320
|
if (deviceScoped)
|
|
320
|
-
return deviceScoped;
|
|
321
|
-
|
|
321
|
+
return { id: deviceScoped, source: 'binding' };
|
|
322
|
+
const defaulted = opts.useDefault === false ? undefined : meta.accounts?.defaults?.[agent];
|
|
323
|
+
return defaulted ? { id: defaulted, source: 'default' } : undefined;
|
|
322
324
|
}
|
|
323
325
|
function profileConsumers(name, base) {
|
|
324
326
|
const dir = path.join(base, 'profiles');
|
|
@@ -371,19 +373,30 @@ export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
|
|
|
371
373
|
const rows = nativeRowsForNameOrId(meta, oldName);
|
|
372
374
|
if (rows.length) {
|
|
373
375
|
assertUniqueUnifiedName(newName, meta, doc, new Set(rows.map(account => account.id)));
|
|
374
|
-
// Sweep every row for the identity (PHNX-3206) in its owning store (PHNX-3315)
|
|
376
|
+
// Sweep every row for the identity (PHNX-3206) in its owning store (PHNX-3315)
|
|
377
|
+
// and any per-harness default that points to the old name or row ids.
|
|
375
378
|
const rowScope = rows[0].scope;
|
|
379
|
+
const idsToRename = new Set([oldName, ...rows.map(row => row.id)]);
|
|
376
380
|
updateMeta(current => {
|
|
381
|
+
const defaults = { ...current.accounts?.defaults };
|
|
382
|
+
for (const [agent, value] of Object.entries(defaults)) {
|
|
383
|
+
if (idsToRename.has(value))
|
|
384
|
+
defaults[agent] = newName;
|
|
385
|
+
}
|
|
386
|
+
const next = { ...current, accounts: { ...current.accounts, defaults } };
|
|
377
387
|
if (rowScope === 'device') {
|
|
378
388
|
const native = { ...current.deviceAccounts?.native };
|
|
379
389
|
for (const row of rows)
|
|
380
390
|
native[row.id] = { ...native[row.id], name: newName };
|
|
381
|
-
|
|
391
|
+
next.deviceAccounts = { ...current.deviceAccounts, native };
|
|
382
392
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
393
|
+
else {
|
|
394
|
+
const native = { ...current.accounts?.native };
|
|
395
|
+
for (const row of rows)
|
|
396
|
+
native[row.id] = { ...native[row.id], name: newName };
|
|
397
|
+
next.accounts = { ...next.accounts, native };
|
|
398
|
+
}
|
|
399
|
+
return next;
|
|
387
400
|
});
|
|
388
401
|
return;
|
|
389
402
|
}
|
|
@@ -391,6 +404,15 @@ export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
|
|
|
391
404
|
if (!account)
|
|
392
405
|
throw new Error(`Unknown account '${oldName}'.`);
|
|
393
406
|
assertUniqueUnifiedName(newName, meta, doc);
|
|
407
|
+
const idsToRename = new Set([account.name, account.id]);
|
|
408
|
+
updateMeta(current => {
|
|
409
|
+
const defaults = { ...current.accounts?.defaults };
|
|
410
|
+
for (const [agent, value] of Object.entries(defaults)) {
|
|
411
|
+
if (idsToRename.has(value))
|
|
412
|
+
defaults[agent] = newName;
|
|
413
|
+
}
|
|
414
|
+
return { ...current, accounts: { ...current.accounts, defaults } };
|
|
415
|
+
});
|
|
394
416
|
renameBundle(account.name, newName); // moves metadata + secret, preserves ACCOUNT_ID
|
|
395
417
|
renameProfileConsumers(account.name, newName, base);
|
|
396
418
|
}
|
|
@@ -422,7 +444,7 @@ export function removeAccount(name, base = getUserAgentsDir()) {
|
|
|
422
444
|
if (!account)
|
|
423
445
|
throw new Error(`Unknown account '${name}'.`);
|
|
424
446
|
const bindings = accountBindings(account.id, meta);
|
|
425
|
-
const defaults = Object.entries(meta.accounts?.defaults ?? {}).filter(([,
|
|
447
|
+
const defaults = Object.entries(meta.accounts?.defaults ?? {}).filter(([, value]) => value === account.id || value === account.name).map(([agent]) => agent);
|
|
426
448
|
if (bindings.length || defaults.length) {
|
|
427
449
|
const refs = [...bindings.map(target => `binding ${target}`), ...defaults.map(agent => `default ${agent}`)];
|
|
428
450
|
throw new Error(`Account '${account.name}' is still referenced by: ${refs.join(', ')}. Detach or clear those references before removing it.`);
|
|
@@ -505,9 +527,20 @@ export function resolveSpawnAccount(explicit, agent, version, meta, opts = {}) {
|
|
|
505
527
|
const selection = resolveAccountSelection(explicit, agent, meta, { useDefault: opts.useDefault, target });
|
|
506
528
|
if (!selection)
|
|
507
529
|
return null;
|
|
508
|
-
const unified = findUnifiedAccount(selection, meta);
|
|
509
|
-
if (!unified)
|
|
510
|
-
|
|
530
|
+
const unified = findUnifiedAccount(selection.id, meta);
|
|
531
|
+
if (!unified) {
|
|
532
|
+
// A stale per-harness default is a preference, not a hard requirement: the
|
|
533
|
+
// machine stays runnable by falling back to balanced rotation. Bindings and
|
|
534
|
+
// explicit --account are intentional, so they still fail loud.
|
|
535
|
+
if (selection.source === 'default') {
|
|
536
|
+
process.stderr.write(chalk.yellow(`[agents] default account '${selection.id}' for ${agent} no longer exists on this machine; falling back to balanced selection. Clear the stale default with: agents accounts clear-default ${agent}\n`));
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
const remedy = selection.source === 'binding'
|
|
540
|
+
? `Detach the stale binding with: agents accounts detach ${selection.id} ${target}`
|
|
541
|
+
: `Add an account named '${selection.id}' or remove the --account override.`;
|
|
542
|
+
throw new Error(`Unknown account '${selection.id}' for ${agent} harness. ${remedy}`);
|
|
543
|
+
}
|
|
511
544
|
if (unified.kind === 'native') {
|
|
512
545
|
if (unified.agent !== agent) {
|
|
513
546
|
throw new Error(`Account '${unified.name}' is a ${unified.agent} login and cannot authenticate the ${agent} harness.`);
|
|
@@ -10,15 +10,26 @@
|
|
|
10
10
|
* floor, so an account racing toward its 5h cap loses priority before it maxes.
|
|
11
11
|
*/
|
|
12
12
|
export declare const PROJECTION_HORIZON_MIN = 30;
|
|
13
|
+
/**
|
|
14
|
+
* The weight an account with NO usage snapshot draws. Absence of a usage signal
|
|
15
|
+
* is NOT capacity (specifications.md GWT-E5c, SING-1a): a null snapshot means
|
|
16
|
+
* "unverifiable", not "empty" — on a worker box every account reads null
|
|
17
|
+
* (setup-token lacks the `user:profile` scope, RUSH-2392), so scoring null as
|
|
18
|
+
* full capacity made the blind pool's top pick an account that was actually
|
|
19
|
+
* weekly-exhausted (PHNX-3392). Floored at 1, never 0: an all-unverified pool
|
|
20
|
+
* must still draw a pick rather than strand the launch.
|
|
21
|
+
*/
|
|
22
|
+
export declare const UNVERIFIED_WEIGHT = 1;
|
|
13
23
|
/**
|
|
14
24
|
* Weight one candidate by remaining routing capacity, deprioritized by how soon
|
|
15
25
|
* it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
|
|
16
|
-
* an account with no live snapshot is
|
|
17
|
-
* is
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
26
|
+
* an account with no live snapshot is NOT full-capacity — absence of a usage
|
|
27
|
+
* signal is not evidence of headroom, so it draws `UNVERIFIED_WEIGHT` and any
|
|
28
|
+
* verified-healthy account outranks it (GWT-E5c). `minutesToLimit` (the daemon's
|
|
29
|
+
* burn-rate projection on the 5h session window) then scales that base: >=
|
|
30
|
+
* horizon (or unknown) keeps full weight, and closer-to-cap scales toward the
|
|
31
|
+
* floor of 1 — so a launch avoids an account projected to cap soon, not just a
|
|
32
|
+
* 100%-maxed one. Pure + exported so the deprioritization is unit-tested
|
|
33
|
+
* directly (a weighted-random draw is not).
|
|
23
34
|
*/
|
|
24
35
|
export declare function capacityWeight(usedPercent: number | null, minutesToLimit: number | null): number;
|
|
@@ -10,19 +10,30 @@
|
|
|
10
10
|
* floor, so an account racing toward its 5h cap loses priority before it maxes.
|
|
11
11
|
*/
|
|
12
12
|
export const PROJECTION_HORIZON_MIN = 30;
|
|
13
|
+
/**
|
|
14
|
+
* The weight an account with NO usage snapshot draws. Absence of a usage signal
|
|
15
|
+
* is NOT capacity (specifications.md GWT-E5c, SING-1a): a null snapshot means
|
|
16
|
+
* "unverifiable", not "empty" — on a worker box every account reads null
|
|
17
|
+
* (setup-token lacks the `user:profile` scope, RUSH-2392), so scoring null as
|
|
18
|
+
* full capacity made the blind pool's top pick an account that was actually
|
|
19
|
+
* weekly-exhausted (PHNX-3392). Floored at 1, never 0: an all-unverified pool
|
|
20
|
+
* must still draw a pick rather than strand the launch.
|
|
21
|
+
*/
|
|
22
|
+
export const UNVERIFIED_WEIGHT = 1;
|
|
13
23
|
/**
|
|
14
24
|
* Weight one candidate by remaining routing capacity, deprioritized by how soon
|
|
15
25
|
* it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
|
|
16
|
-
* an account with no live snapshot is
|
|
17
|
-
* is
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
26
|
+
* an account with no live snapshot is NOT full-capacity — absence of a usage
|
|
27
|
+
* signal is not evidence of headroom, so it draws `UNVERIFIED_WEIGHT` and any
|
|
28
|
+
* verified-healthy account outranks it (GWT-E5c). `minutesToLimit` (the daemon's
|
|
29
|
+
* burn-rate projection on the 5h session window) then scales that base: >=
|
|
30
|
+
* horizon (or unknown) keeps full weight, and closer-to-cap scales toward the
|
|
31
|
+
* floor of 1 — so a launch avoids an account projected to cap soon, not just a
|
|
32
|
+
* 100%-maxed one. Pure + exported so the deprioritization is unit-tested
|
|
33
|
+
* directly (a weighted-random draw is not).
|
|
23
34
|
*/
|
|
24
35
|
export function capacityWeight(usedPercent, minutesToLimit) {
|
|
25
|
-
const base = usedPercent === null ?
|
|
36
|
+
const base = usedPercent === null ? UNVERIFIED_WEIGHT : Math.max(1, 100 - usedPercent);
|
|
26
37
|
if (minutesToLimit === null || !Number.isFinite(minutesToLimit))
|
|
27
38
|
return base;
|
|
28
39
|
const factor = Math.max(0, Math.min(1, minutesToLimit / PROJECTION_HORIZON_MIN));
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type DeviceProfile } from '../devices/registry.js';
|
|
2
2
|
import { type ConfiguredDeviceRole } from '../device-config.js';
|
|
3
|
-
import { type CachedUsageSnapshot } from './usage.js';
|
|
3
|
+
import { type CachedUsageSnapshot, type UsageSnapshot } from './usage.js';
|
|
4
4
|
/** How long a single peer push may take before it is abandoned for this tick. */
|
|
5
5
|
export declare const USAGE_PUSH_DEADLINE_MS = 20000;
|
|
6
|
+
export declare const USAGE_PULL_DEADLINE_MS = 20000;
|
|
6
7
|
/** The stdin envelope the `__usage-ingest` verb reads. `v` guards the shape. */
|
|
7
8
|
export interface UsageSyncPayload {
|
|
8
9
|
v: 1;
|
|
@@ -62,6 +63,33 @@ export interface UsageSyncDeps {
|
|
|
62
63
|
message?: string;
|
|
63
64
|
};
|
|
64
65
|
}
|
|
66
|
+
export interface UsagePullResult {
|
|
67
|
+
pulledFrom: string | null;
|
|
68
|
+
merged: number;
|
|
69
|
+
skipped: string | null;
|
|
70
|
+
error: string | null;
|
|
71
|
+
}
|
|
72
|
+
export interface UsagePullDeps {
|
|
73
|
+
selfRole?: () => ConfiguredDeviceRole | undefined;
|
|
74
|
+
listDevices?: () => DeviceProfile[];
|
|
75
|
+
listRoles?: () => Record<string, ConfiguredDeviceRole>;
|
|
76
|
+
isPinned?: (name: string) => boolean;
|
|
77
|
+
exportRows?: () => Record<string, CachedUsageSnapshot>;
|
|
78
|
+
readRow?: (usageKey: string) => Pick<UsageSnapshot, 'windows'> | null;
|
|
79
|
+
ingestRows?: (rows: Record<string, CachedUsageSnapshot>) => number;
|
|
80
|
+
/** Read the versioned payload from the primary. Default: ssh `__usage-export`. */
|
|
81
|
+
pull?: (device: DeviceProfile) => {
|
|
82
|
+
ok: boolean;
|
|
83
|
+
stdout?: string;
|
|
84
|
+
message?: string;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Pull usage from the fleet's primary headed device when this worker's local
|
|
89
|
+
* cache is empty or contains an expired row. `personal` is the primary role;
|
|
90
|
+
* a `desktop` is used only when the fleet has no personal device.
|
|
91
|
+
*/
|
|
92
|
+
export declare function pullUsageFromPrimary(deps?: UsagePullDeps): UsagePullResult;
|
|
65
93
|
/**
|
|
66
94
|
* Push the local identity-keyed usage snapshot to every reachable, pinned,
|
|
67
95
|
* non-headed peer. A no-op on a non-headed box or when the local cache is empty.
|
|
@@ -25,16 +25,17 @@
|
|
|
25
25
|
* The planner is pure so tests cover every skip/push branch with no SSH.
|
|
26
26
|
*/
|
|
27
27
|
import { sshExec } from '../ssh-exec.js';
|
|
28
|
-
import { buildRemoteAgentsInvocation, buildWindowsStdinAgentsCommand, remoteShellFor } from '../hosts/remote-cmd.js';
|
|
28
|
+
import { buildRemoteAgentsInvocation, buildWindowsStdinAgentsCommand, remoteShellFor, stripClixml } from '../hosts/remote-cmd.js';
|
|
29
29
|
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
30
30
|
import { loadDevicesSync } from '../devices/registry.js';
|
|
31
31
|
import { sshTargetFor } from '../devices/connect.js';
|
|
32
32
|
import { isHostPinned } from '../devices/known-hosts.js';
|
|
33
33
|
import { machineId, normalizeHost } from '../session/sync/config.js';
|
|
34
34
|
import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole, } from '../device-config.js';
|
|
35
|
-
import { exportClaudeUsageCacheRows } from './usage.js';
|
|
35
|
+
import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, readClaudeUsageCache, } from './usage.js';
|
|
36
36
|
/** How long a single peer push may take before it is abandoned for this tick. */
|
|
37
37
|
export const USAGE_PUSH_DEADLINE_MS = 20_000;
|
|
38
|
+
export const USAGE_PULL_DEADLINE_MS = 20_000;
|
|
38
39
|
/**
|
|
39
40
|
* Decide, per peer, whether to push the local usage snapshot. Pure.
|
|
40
41
|
*
|
|
@@ -84,6 +85,79 @@ function defaultUsagePush(device, payload) {
|
|
|
84
85
|
return { ok: false, message: res.stderr.trim() || `remote exit ${res.code}` };
|
|
85
86
|
return { ok: true };
|
|
86
87
|
}
|
|
88
|
+
/** Production pull: read the primary's cache through its hidden export verb. */
|
|
89
|
+
function defaultUsagePull(device) {
|
|
90
|
+
const target = sshTargetFor(device);
|
|
91
|
+
const os = resolveRemoteOsSync(device.name);
|
|
92
|
+
const remoteCmd = buildRemoteAgentsInvocation(['__usage-export'], undefined, os);
|
|
93
|
+
const res = sshExec(target, remoteCmd, { timeoutMs: USAGE_PULL_DEADLINE_MS });
|
|
94
|
+
if (res.timedOut)
|
|
95
|
+
return { ok: false, message: 'timed out' };
|
|
96
|
+
if (res.code !== 0)
|
|
97
|
+
return { ok: false, message: res.stderr.trim() || `remote exit ${res.code}` };
|
|
98
|
+
return { ok: true, stdout: res.stdout };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Pull usage from the fleet's primary headed device when this worker's local
|
|
102
|
+
* cache is empty or contains an expired row. `personal` is the primary role;
|
|
103
|
+
* a `desktop` is used only when the fleet has no personal device.
|
|
104
|
+
*/
|
|
105
|
+
export function pullUsageFromPrimary(deps = {}) {
|
|
106
|
+
const result = { pulledFrom: null, merged: 0, skipped: null, error: null };
|
|
107
|
+
if ((deps.selfRole ?? selfConfiguredDeviceRole)() !== 'worker') {
|
|
108
|
+
result.skipped = 'this device is not a worker';
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
const localRows = (deps.exportRows ?? exportClaudeUsageCacheRows)();
|
|
112
|
+
const readRow = deps.readRow ?? readClaudeUsageCache;
|
|
113
|
+
const localEntries = Object.entries(localRows);
|
|
114
|
+
if (localEntries.length > 0 && localEntries.every(([key, row]) => {
|
|
115
|
+
const fresh = readRow(key);
|
|
116
|
+
return fresh !== null && fresh.windows.length === row.windows.length;
|
|
117
|
+
})) {
|
|
118
|
+
result.skipped = 'local usage cache is fresh';
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
const devices = deps.listDevices?.() ?? Object.values(loadDevicesSync());
|
|
122
|
+
const roles = deps.listRoles?.() ?? (deps.listDevices
|
|
123
|
+
? listConfiguredDeviceRoles(devices.map((device) => device.name))
|
|
124
|
+
: listConfiguredDeviceRoles());
|
|
125
|
+
const isPinned = deps.isPinned ?? isHostPinned;
|
|
126
|
+
const primary = devices
|
|
127
|
+
.filter((device) => roles[device.name] === 'personal')
|
|
128
|
+
.concat(devices.filter((device) => roles[device.name] === 'desktop'))
|
|
129
|
+
.find((device) => device.tailscale?.online !== false && isPinned(device.name));
|
|
130
|
+
if (!primary) {
|
|
131
|
+
result.error = 'no reachable, pinned personal or desktop device is configured as the usage primary';
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
const outcome = (deps.pull ?? defaultUsagePull)(primary);
|
|
135
|
+
if (!outcome.ok) {
|
|
136
|
+
result.error = outcome.message ?? 'pull failed';
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
let payload;
|
|
140
|
+
try {
|
|
141
|
+
// A Windows usage primary's `agents.ps1` shim prepends a PowerShell CLIXML
|
|
142
|
+
// progress banner to stdout, exactly as every other remote-JSON boundary
|
|
143
|
+
// strips (remote-cmd.ts:325). Without this, a headed Windows box (win-mini
|
|
144
|
+
// is a live fleet device) makes every pull fail as "malformed JSON", the
|
|
145
|
+
// worker's cache stays null, and the PHNX-3392 capacity floor silently
|
|
146
|
+
// becomes the only thing standing between a blind pool and an exhausted pick.
|
|
147
|
+
payload = JSON.parse(stripClixml(outcome.stdout ?? ''));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
result.error = 'primary returned malformed JSON';
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
if (!payload || payload.v !== 1 || typeof payload.rows !== 'object' || payload.rows === null || Array.isArray(payload.rows)) {
|
|
154
|
+
result.error = 'primary returned an unrecognized usage-sync payload shape';
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
result.pulledFrom = primary.name;
|
|
158
|
+
result.merged = (deps.ingestRows ?? ingestPeerClaudeUsageRows)(payload.rows);
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
87
161
|
/**
|
|
88
162
|
* Push the local identity-keyed usage snapshot to every reachable, pinned,
|
|
89
163
|
* non-headed peer. A no-op on a non-headed box or when the local cache is empty.
|