@phnx-labs/agents-cli 1.22.55 → 1.22.57
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 +20 -0
- package/README.md +4 -4
- package/dist/bootstrap.js +3 -1
- package/dist/cli/command-registry.d.ts +0 -1
- package/dist/cli/command-registry.js +0 -3
- package/dist/commands/exec.js +1 -1
- package/dist/commands/hooks.js +4 -4
- package/dist/commands/insights.d.ts +7 -5
- package/dist/commands/insights.js +16 -9
- package/dist/commands/perf.d.ts +16 -7
- package/dist/commands/perf.js +29 -20
- package/dist/commands/rules.js +1 -1
- package/dist/commands/share.js +7 -6
- package/dist/commands/ssh.js +24 -14
- package/dist/commands/trash.d.ts +2 -2
- package/dist/commands/trash.js +2 -6
- package/dist/commands/versions.d.ts +2 -2
- package/dist/commands/versions.js +1 -10
- package/dist/commands/view.d.ts +2 -2
- package/dist/commands/view.js +7 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +9 -0
- package/dist/lib/accounting/usage-ingest.d.ts +1 -0
- package/dist/lib/accounting/usage-ingest.js +75 -0
- package/dist/lib/accounting/usage-sync.d.ts +69 -0
- package/dist/lib/accounting/usage-sync.js +129 -0
- package/dist/lib/accounting/usage.d.ts +48 -2
- package/dist/lib/accounting/usage.js +72 -1
- package/dist/lib/agent-spec/agents.js +1 -1
- package/dist/lib/analytics/mix-commands.d.ts +8 -7
- package/dist/lib/analytics/mix-commands.js +50 -73
- package/dist/lib/daemon/daemon.js +5 -0
- package/dist/lib/daemon/runner.js +9 -8
- package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
- package/dist/lib/daemon/usage-sync-service.js +36 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +17 -6
- package/dist/lib/device-config.js +25 -11
- package/dist/lib/devices/pool.d.ts +4 -3
- package/dist/lib/devices/pool.js +13 -5
- package/dist/lib/exec.d.ts +6 -41
- package/dist/lib/exec.js +6 -41
- package/dist/lib/git.d.ts +13 -1
- package/dist/lib/git.js +36 -7
- package/dist/lib/harness/adapter.d.ts +7 -7
- package/dist/lib/harness/adapters/claude.js +3 -2
- package/dist/lib/hosts/remote-cmd.d.ts +9 -0
- package/dist/lib/hosts/remote-cmd.js +22 -0
- package/dist/lib/overdue.js +7 -37
- package/dist/lib/perf/db.d.ts +1 -1
- package/dist/lib/perf/db.js +1 -1
- package/dist/lib/scheduler.d.ts +21 -2
- package/dist/lib/scheduler.js +28 -5
- package/dist/lib/scheduling/routines.d.ts +21 -0
- package/dist/lib/scheduling/routines.js +56 -0
- package/dist/lib/session/active.d.ts +3 -31
- package/dist/lib/session/active.js +8 -68
- package/dist/lib/session/db.d.ts +4 -35
- package/dist/lib/session/db.js +4 -35
- package/dist/lib/session/discover.d.ts +6 -58
- package/dist/lib/session/discover.js +5 -43
- package/dist/lib/session/parse.d.ts +1 -19
- package/dist/lib/session/parse.js +2 -15
- package/dist/lib/share/provision.d.ts +3 -2
- package/dist/lib/share/provision.js +9 -4
- package/dist/lib/share/worker-template.d.ts +24 -1
- package/dist/lib/share/worker-template.js +65 -84
- package/dist/lib/startup/command-registry.d.ts +8 -2
- package/dist/lib/startup/command-registry.js +12 -4
- package/package.json +1 -1
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Version management commands for installing, switching, and removing agent CLIs.
|
|
3
3
|
*
|
|
4
4
|
* Implements `agents add`, `agents prune`, `agents remove` (alias),
|
|
5
|
-
*
|
|
5
|
+
* and `agents use`. Handles npm-based installation,
|
|
6
6
|
* shim creation, config symlink
|
|
7
7
|
* switching, resource sync prompts, and project-level version pinning.
|
|
8
8
|
*/
|
|
9
9
|
import type { Command } from 'commander';
|
|
10
|
-
/** Register `agents add`, `agents prune`, `agents remove`,
|
|
10
|
+
/** Register `agents add`, `agents prune`, `agents remove`, and `agents use`. */
|
|
11
11
|
export declare function registerVersionsCommands(program: Command): void;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { addHostOption } from '../lib/hosts/option.js';
|
|
2
1
|
import chalk from 'chalk';
|
|
3
2
|
import ora from 'ora';
|
|
4
3
|
import * as fs from 'fs';
|
|
@@ -6,7 +5,6 @@ import * as path from 'path';
|
|
|
6
5
|
import { select, confirm, checkbox } from '@inquirer/prompts';
|
|
7
6
|
import { AGENTS, MANAGED_AGENT_IDS, accountOrgBadge, getAccountEmail, getAccountInfo, agentLabel, warnAgentDeprecated, isAgentHardDeprecated, hardDeprecationError, isSelfUpdatingAgent, } from '../lib/agents.js';
|
|
8
7
|
import { formatUsageSummary, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, isUsageHeadlessScopeError, } from '../lib/accounting/usage.js';
|
|
9
|
-
import { viewAction } from './view.js';
|
|
10
8
|
import { resolveConfiguredModel, formatAgentIdentity } from '../lib/models.js';
|
|
11
9
|
import { readManifest, writeManifest, createDefaultManifest } from '../lib/manifest.js';
|
|
12
10
|
import { installVersion, removeVersion, listInstalledVersions, isVersionInstalled, isLatestInstalled, isOldestInstalled, getGlobalDefault, setGlobalDefault, markVersionIsolated, setIsolatedDefault, isVersionIsolated, getVersionHomePath, getVersionDir, syncResourcesToVersion, parseAgentSpec, promptResourceSelection, promptNewResourceSelection, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, printTrashFooter, } from '../lib/installations/versions.js';
|
|
@@ -288,7 +286,7 @@ function configureVersionPruneCommand(cmd, commandName) {
|
|
|
288
286
|
});
|
|
289
287
|
cmd.action((specs, options) => versionPruneAction(specs, options, commandName));
|
|
290
288
|
}
|
|
291
|
-
/** Register `agents add`, `agents prune`, `agents remove`,
|
|
289
|
+
/** Register `agents add`, `agents prune`, `agents remove`, and `agents use`. */
|
|
292
290
|
export function registerVersionsCommands(program) {
|
|
293
291
|
const addCmd = program
|
|
294
292
|
.command('add <specs...>')
|
|
@@ -943,11 +941,4 @@ export function registerVersionsCommands(program) {
|
|
|
943
941
|
throw err;
|
|
944
942
|
}
|
|
945
943
|
});
|
|
946
|
-
// Deprecated: use `agents view` instead
|
|
947
|
-
addHostOption(program.command('list [agent]'))
|
|
948
|
-
.description('List installed agent CLI versions')
|
|
949
|
-
.action(async (agentArg) => {
|
|
950
|
-
console.log(chalk.red('Deprecated: "agents list" is now "agents view"\n'));
|
|
951
|
-
await viewAction(agentArg);
|
|
952
|
-
});
|
|
953
944
|
}
|
package/dist/commands/view.d.ts
CHANGED
|
@@ -72,8 +72,8 @@ export declare function renderHarnessBlocks(harnesses: ProfileSummary[], install
|
|
|
72
72
|
/**
|
|
73
73
|
* The USAGE-READ-2 decision, isolated from its runtime inputs so it can be
|
|
74
74
|
* tested directly: a usage read may fall through to the interactive OAuth login
|
|
75
|
-
* ONLY for a foreground human render on a `personal`
|
|
76
|
-
* required — role alone is not sufficient.
|
|
75
|
+
* ONLY for a foreground human render on a headed device (`personal` or
|
|
76
|
+
* `desktop`). Both conditions are required — role alone is not sufficient.
|
|
77
77
|
*/
|
|
78
78
|
export declare function allowInteractiveUsageLogin(role: ConfiguredDeviceRole | undefined, isTTY: boolean): boolean;
|
|
79
79
|
/** Build the set of resource sections to include in `--json` from the
|
package/dist/commands/view.js
CHANGED
|
@@ -9,7 +9,7 @@ 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';
|
|
11
11
|
import { agentReportsUsage, classifyUsageErrorKind, deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageBenignState, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, isUsageHeadlessScopeError, usageErrorForDisplay, } from '../lib/accounting/usage.js';
|
|
12
|
-
import { selfConfiguredDeviceRole } from '../lib/device-config.js';
|
|
12
|
+
import { isHeadedDeviceRole, selfConfiguredDeviceRole } from '../lib/device-config.js';
|
|
13
13
|
import { readManifest } from '../lib/manifest.js';
|
|
14
14
|
import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, isGlobalBinaryAgent, getLiveVersion, isVersionIsolated, getIsolatedDefault, } from '../lib/installations/versions.js';
|
|
15
15
|
import { ensureVersionedAliasCurrent, removeShim, } from '../lib/installations/shims.js';
|
|
@@ -320,17 +320,18 @@ function renderHostClisSection(cwd) {
|
|
|
320
320
|
/**
|
|
321
321
|
* The USAGE-READ-2 decision, isolated from its runtime inputs so it can be
|
|
322
322
|
* tested directly: a usage read may fall through to the interactive OAuth login
|
|
323
|
-
* ONLY for a foreground human render on a `personal`
|
|
324
|
-
* required — role alone is not sufficient.
|
|
323
|
+
* ONLY for a foreground human render on a headed device (`personal` or
|
|
324
|
+
* `desktop`). Both conditions are required — role alone is not sufficient.
|
|
325
325
|
*/
|
|
326
326
|
export function allowInteractiveUsageLogin(role, isTTY) {
|
|
327
|
-
return role
|
|
327
|
+
return isHeadedDeviceRole(role) && isTTY === true;
|
|
328
328
|
}
|
|
329
329
|
/**
|
|
330
330
|
* Whether this `agents view` invocation may fall through to the interactive
|
|
331
331
|
* OAuth login for a usage read (USAGE-READ-2). True only for a foreground human
|
|
332
|
-
* render on a `personal`
|
|
333
|
-
* carrying the `user:profile` scope the usage endpoint needs,
|
|
332
|
+
* render on a headed device (`personal` or `desktop`): the interactive login is
|
|
333
|
+
* the sole credential carrying the `user:profile` scope the usage endpoint needs,
|
|
334
|
+
* and a human
|
|
334
335
|
* running one command is not the unattended-loop revocation risk RUSH-1822
|
|
335
336
|
* fixed. The `--json` path never reaches these render functions (it returns
|
|
336
337
|
* early via `collectAgentsJson`), and a non-TTY (piped/scripted) run is excluded
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* - `__secrets-get` / `__secrets-ping` / `__secrets-lock` (SYNC_* tokens)
|
|
16
16
|
* - `__shim`
|
|
17
17
|
* - `__claude-statusline`
|
|
18
|
+
* - `__usage-ingest`
|
|
18
19
|
* - `__daemon-run`
|
|
19
20
|
*
|
|
20
21
|
* The tokens are imported from the leaf module sync-commands.ts — the SAME
|
|
@@ -75,6 +76,14 @@ if (process.argv[2] === '__claude-statusline') {
|
|
|
75
76
|
const { runClaudeStatusLine } = await import('./lib/claude-statusline.js');
|
|
76
77
|
process.exit(await runClaudeStatusLine());
|
|
77
78
|
}
|
|
79
|
+
// Fleet usage-sync receiver: a headed peer pipes its identity-keyed usage rows to
|
|
80
|
+
// stdin, we merge them newest-wins into this box's cache (PHNX-3392). Above the
|
|
81
|
+
// bootstrap line for the same reason as __claude-statusline — no update check, no
|
|
82
|
+
// detached sync fork, and nothing writes to stdout to corrupt the caller's view.
|
|
83
|
+
if (process.argv[2] === '__usage-ingest') {
|
|
84
|
+
const { runUsageIngest } = await import('./lib/accounting/usage-ingest.js');
|
|
85
|
+
process.exit(await runUsageIngest());
|
|
86
|
+
}
|
|
78
87
|
if (process.argv[2] === '__daemon-run') {
|
|
79
88
|
const { runDaemon, log: daemonLog } = await import('./lib/daemon/daemon.js');
|
|
80
89
|
// RUSH-2418: the daemon is the one always-on process here, and it ran with no
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runUsageIngest(): Promise<number>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `agents __usage-ingest` receiver (PHNX-3392 usage-sync).
|
|
3
|
+
*
|
|
4
|
+
* A headed peer's daemon pipes a {@link UsageSyncPayload} JSON envelope to our
|
|
5
|
+
* stdin; we merge its identity-keyed rows into the local usage cache newest-wins
|
|
6
|
+
* ({@link ingestPeerClaudeUsageRows}). Hidden internal verb — intercepted in
|
|
7
|
+
* index.ts before bootstrap, so it never triggers an update check or a detached
|
|
8
|
+
* sync, and it writes NOTHING to stdout (the caller only reads the exit code).
|
|
9
|
+
*
|
|
10
|
+
* Exit codes: 0 = merged (or nothing to merge — an empty payload is not an error),
|
|
11
|
+
* 2 = malformed input. It fails loud on a bad envelope rather than silently
|
|
12
|
+
* accepting a wrong shape, but a busy cache lock degrades to best-effort inside
|
|
13
|
+
* `ingestPeerClaudeUsageRows` like every other cache writer.
|
|
14
|
+
*
|
|
15
|
+
* The payload arrives on stdin, EXCEPT on a Windows receiver: the `agents.ps1`
|
|
16
|
+
* shim does not forward ssh-piped stdin to the node process, so the pusher writes
|
|
17
|
+
* the payload to a temp file and passes `agents __usage-ingest --from <path>`
|
|
18
|
+
* (the same workaround the secrets push uses — `buildWindowsStdinImportCommand`).
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from 'fs';
|
|
21
|
+
import { ingestPeerClaudeUsageRows } from './usage.js';
|
|
22
|
+
function readStdin() {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
let buf = '';
|
|
25
|
+
process.stdin.setEncoding('utf-8');
|
|
26
|
+
process.stdin.on('data', (chunk) => {
|
|
27
|
+
buf += chunk;
|
|
28
|
+
});
|
|
29
|
+
process.stdin.on('end', () => resolve(buf));
|
|
30
|
+
process.stdin.on('error', () => resolve(buf));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** `--from <path>` reads the payload from a file instead of stdin (Windows path). */
|
|
34
|
+
function fromFileArg(argv) {
|
|
35
|
+
const i = argv.indexOf('--from');
|
|
36
|
+
return i !== -1 && argv[i + 1] ? argv[i + 1] : null;
|
|
37
|
+
}
|
|
38
|
+
export async function runUsageIngest() {
|
|
39
|
+
const fromPath = fromFileArg(process.argv.slice(3));
|
|
40
|
+
let source;
|
|
41
|
+
if (fromPath) {
|
|
42
|
+
try {
|
|
43
|
+
source = fs.readFileSync(fromPath, 'utf-8');
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
process.stderr.write(`[agents] __usage-ingest: cannot read --from ${fromPath}: ${err.message}\n`);
|
|
47
|
+
return 2;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
source = await readStdin();
|
|
52
|
+
}
|
|
53
|
+
const raw = source.trim();
|
|
54
|
+
if (!raw)
|
|
55
|
+
return 0; // nothing piped — a no-op tick, not a failure.
|
|
56
|
+
let payload;
|
|
57
|
+
try {
|
|
58
|
+
payload = JSON.parse(raw);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
process.stderr.write('[agents] __usage-ingest: malformed JSON payload\n');
|
|
62
|
+
return 2;
|
|
63
|
+
}
|
|
64
|
+
if (!payload ||
|
|
65
|
+
payload.v !== 1 ||
|
|
66
|
+
typeof payload.rows !== 'object' ||
|
|
67
|
+
payload.rows === null ||
|
|
68
|
+
Array.isArray(payload.rows) // `typeof [] === 'object'` — an array is NOT a rows map
|
|
69
|
+
) {
|
|
70
|
+
process.stderr.write('[agents] __usage-ingest: unrecognized usage-sync payload shape\n');
|
|
71
|
+
return 2;
|
|
72
|
+
}
|
|
73
|
+
ingestPeerClaudeUsageRows(payload.rows);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type DeviceProfile } from '../devices/registry.js';
|
|
2
|
+
import { type ConfiguredDeviceRole } from '../device-config.js';
|
|
3
|
+
import { type CachedUsageSnapshot } from './usage.js';
|
|
4
|
+
/** How long a single peer push may take before it is abandoned for this tick. */
|
|
5
|
+
export declare const USAGE_PUSH_DEADLINE_MS = 20000;
|
|
6
|
+
/** The stdin envelope the `__usage-ingest` verb reads. `v` guards the shape. */
|
|
7
|
+
export interface UsageSyncPayload {
|
|
8
|
+
v: 1;
|
|
9
|
+
rows: Record<string, CachedUsageSnapshot>;
|
|
10
|
+
}
|
|
11
|
+
/** One peer the local publisher is considering, reduced to the plan inputs. */
|
|
12
|
+
export interface UsagePushTarget {
|
|
13
|
+
name: string;
|
|
14
|
+
/** The peer's configured role — headed peers are skipped (own reader). */
|
|
15
|
+
role: ConfiguredDeviceRole | undefined;
|
|
16
|
+
/** Live-ish reachability from the tailscale snapshot; offline peers are skipped. */
|
|
17
|
+
online: boolean;
|
|
18
|
+
/** Managed known-hosts pin — an unpinned host is skipped, never TOFU-accepted. */
|
|
19
|
+
pinned: boolean;
|
|
20
|
+
}
|
|
21
|
+
export type UsageSyncPlanItem = {
|
|
22
|
+
action: 'push';
|
|
23
|
+
device: string;
|
|
24
|
+
} | {
|
|
25
|
+
action: 'skip';
|
|
26
|
+
device: string;
|
|
27
|
+
reason: string;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Decide, per peer, whether to push the local usage snapshot. Pure.
|
|
31
|
+
*
|
|
32
|
+
* - `selfIsPublisher` is false on a `worker`/unmarked box: it has no authoritative
|
|
33
|
+
* usage to publish, so every peer is skipped.
|
|
34
|
+
* - `hasLocalRows` false means the local cache is empty (nothing to teach yet).
|
|
35
|
+
* - A HEADED peer is skipped — it reads its own usage; pushing risks nothing
|
|
36
|
+
* (the merge is newest-wins) but is wasted work, and keeping the fan-out to
|
|
37
|
+
* consumers only makes the intent legible.
|
|
38
|
+
*/
|
|
39
|
+
export declare function planUsagePush(selfIsPublisher: boolean, hasLocalRows: boolean, targets: UsagePushTarget[]): UsageSyncPlanItem[];
|
|
40
|
+
export interface UsageSyncResult {
|
|
41
|
+
pushed: string[];
|
|
42
|
+
skipped: Array<{
|
|
43
|
+
device: string;
|
|
44
|
+
reason: string;
|
|
45
|
+
}>;
|
|
46
|
+
errors: Array<{
|
|
47
|
+
device: string;
|
|
48
|
+
message: string;
|
|
49
|
+
}>;
|
|
50
|
+
}
|
|
51
|
+
export interface UsageSyncDeps {
|
|
52
|
+
selfRole?: () => ConfiguredDeviceRole | undefined;
|
|
53
|
+
listDevices?: () => DeviceProfile[];
|
|
54
|
+
/** Configured roles by device name; default reads the fleet config. */
|
|
55
|
+
listRoles?: () => Record<string, ConfiguredDeviceRole>;
|
|
56
|
+
localName?: () => string;
|
|
57
|
+
isPinned?: (name: string) => boolean;
|
|
58
|
+
exportRows?: () => Record<string, CachedUsageSnapshot>;
|
|
59
|
+
/** Deliver the serialized payload to one peer. Default: ssh `__usage-ingest`. */
|
|
60
|
+
push?: (device: DeviceProfile, payload: string) => {
|
|
61
|
+
ok: boolean;
|
|
62
|
+
message?: string;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Push the local identity-keyed usage snapshot to every reachable, pinned,
|
|
67
|
+
* non-headed peer. A no-op on a non-headed box or when the local cache is empty.
|
|
68
|
+
*/
|
|
69
|
+
export declare function syncFleetUsageSnapshots(deps?: UsageSyncDeps): UsageSyncResult;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet sync of the identity-keyed Claude usage snapshot (PHNX-3392 follow-up).
|
|
3
|
+
*
|
|
4
|
+
* A rate limit is metered per ACCOUNT, so an account's 5h/weekly usage is the
|
|
5
|
+
* same number on every box. But only a HEADED device (`personal`/`desktop`) can
|
|
6
|
+
* read it: the interactive OAuth login it holds carries the `user:profile` scope
|
|
7
|
+
* `/api/oauth/usage` requires, and its interactive Claude runs feed the native
|
|
8
|
+
* windows through the status-line writer. A headless `worker` has only the
|
|
9
|
+
* `user:inference` setup-token, which the usage endpoint 403s (RUSH-2392), so its
|
|
10
|
+
* local `claude-usage.json` stays blank and `agents view` shows no S:/W: bars.
|
|
11
|
+
*
|
|
12
|
+
* This closes that gap the same way {@link ../secrets/reserved-sync.ts} closes
|
|
13
|
+
* the auth-token gap: each headed daemon PUSHES its identity-keyed usage rows to
|
|
14
|
+
* the worker peers that cannot read them, which merge NEWEST-WINS
|
|
15
|
+
* ({@link ingestPeerClaudeUsageRows}). Publish direction is role-driven —
|
|
16
|
+
* personal/desktop publish, worker/unmarked consume — so it composes with the
|
|
17
|
+
* device-role taxonomy rather than adding a second notion of "which box is real".
|
|
18
|
+
*
|
|
19
|
+
* Double-fire safety: each daemon only writes the DESTINATION's own cache, and
|
|
20
|
+
* the merge is idempotent + timestamp-guarded, so two headed publishers pushing
|
|
21
|
+
* the same account to one worker converge on the freshest snapshot regardless of
|
|
22
|
+
* order. The input a publisher reads is its OWN cache (device-local state), so an
|
|
23
|
+
* unrestricted per-device fire is correct — no shared queue.
|
|
24
|
+
*
|
|
25
|
+
* The planner is pure so tests cover every skip/push branch with no SSH.
|
|
26
|
+
*/
|
|
27
|
+
import { sshExec } from '../ssh-exec.js';
|
|
28
|
+
import { buildRemoteAgentsInvocation, buildWindowsStdinAgentsCommand, remoteShellFor } from '../hosts/remote-cmd.js';
|
|
29
|
+
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
30
|
+
import { loadDevicesSync } from '../devices/registry.js';
|
|
31
|
+
import { sshTargetFor } from '../devices/connect.js';
|
|
32
|
+
import { isHostPinned } from '../devices/known-hosts.js';
|
|
33
|
+
import { machineId, normalizeHost } from '../session/sync/config.js';
|
|
34
|
+
import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole, } from '../device-config.js';
|
|
35
|
+
import { exportClaudeUsageCacheRows } from './usage.js';
|
|
36
|
+
/** How long a single peer push may take before it is abandoned for this tick. */
|
|
37
|
+
export const USAGE_PUSH_DEADLINE_MS = 20_000;
|
|
38
|
+
/**
|
|
39
|
+
* Decide, per peer, whether to push the local usage snapshot. Pure.
|
|
40
|
+
*
|
|
41
|
+
* - `selfIsPublisher` is false on a `worker`/unmarked box: it has no authoritative
|
|
42
|
+
* usage to publish, so every peer is skipped.
|
|
43
|
+
* - `hasLocalRows` false means the local cache is empty (nothing to teach yet).
|
|
44
|
+
* - A HEADED peer is skipped — it reads its own usage; pushing risks nothing
|
|
45
|
+
* (the merge is newest-wins) but is wasted work, and keeping the fan-out to
|
|
46
|
+
* consumers only makes the intent legible.
|
|
47
|
+
*/
|
|
48
|
+
export function planUsagePush(selfIsPublisher, hasLocalRows, targets) {
|
|
49
|
+
if (!selfIsPublisher) {
|
|
50
|
+
return targets.map((d) => ({
|
|
51
|
+
action: 'skip',
|
|
52
|
+
device: d.name,
|
|
53
|
+
reason: 'this device is not a usage publisher (mark it personal or desktop)',
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
if (!hasLocalRows) {
|
|
57
|
+
return targets.map((d) => ({ action: 'skip', device: d.name, reason: 'no local usage snapshot to publish' }));
|
|
58
|
+
}
|
|
59
|
+
return targets.map((d) => {
|
|
60
|
+
if (isHeadedDeviceRole(d.role))
|
|
61
|
+
return { action: 'skip', device: d.name, reason: 'headed peer reads its own usage' };
|
|
62
|
+
if (!d.online)
|
|
63
|
+
return { action: 'skip', device: d.name, reason: 'offline' };
|
|
64
|
+
if (!d.pinned) {
|
|
65
|
+
return { action: 'skip', device: d.name, reason: `host key not pinned; run \`agents ssh ${d.name}\` once` };
|
|
66
|
+
}
|
|
67
|
+
return { action: 'push', device: d.name };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Production push: pipe the payload to the peer's `agents __usage-ingest` over ssh. */
|
|
71
|
+
function defaultUsagePush(device, payload) {
|
|
72
|
+
const target = sshTargetFor(device);
|
|
73
|
+
const os = resolveRemoteOsSync(device.name);
|
|
74
|
+
// A Windows peer's `agents.ps1` shim does not forward ssh-piped stdin, so hand
|
|
75
|
+
// the payload through a temp file (`--from`) instead of stdin — same workaround
|
|
76
|
+
// the secrets push uses. A POSIX peer reads stdin directly.
|
|
77
|
+
const remoteCmd = remoteShellFor(os) === 'powershell'
|
|
78
|
+
? buildWindowsStdinAgentsCommand(['__usage-ingest'])
|
|
79
|
+
: buildRemoteAgentsInvocation(['__usage-ingest'], undefined, os);
|
|
80
|
+
const res = sshExec(target, remoteCmd, { input: payload, timeoutMs: USAGE_PUSH_DEADLINE_MS });
|
|
81
|
+
if (res.timedOut)
|
|
82
|
+
return { ok: false, message: 'timed out' };
|
|
83
|
+
if (res.code !== 0)
|
|
84
|
+
return { ok: false, message: res.stderr.trim() || `remote exit ${res.code}` };
|
|
85
|
+
return { ok: true };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Push the local identity-keyed usage snapshot to every reachable, pinned,
|
|
89
|
+
* non-headed peer. A no-op on a non-headed box or when the local cache is empty.
|
|
90
|
+
*/
|
|
91
|
+
export function syncFleetUsageSnapshots(deps = {}) {
|
|
92
|
+
const result = { pushed: [], skipped: [], errors: [] };
|
|
93
|
+
const selfRole = (deps.selfRole ?? selfConfiguredDeviceRole)();
|
|
94
|
+
const devices = deps.listDevices?.() ?? Object.values(loadDevicesSync());
|
|
95
|
+
const self = deps.localName?.() ?? machineId();
|
|
96
|
+
const selfNorm = normalizeHost(self);
|
|
97
|
+
const roles = deps.listRoles?.() ?? (deps.listDevices ? listConfiguredDeviceRoles(devices.map((d) => d.name)) : listConfiguredDeviceRoles());
|
|
98
|
+
const isPinned = deps.isPinned ?? isHostPinned;
|
|
99
|
+
const byName = new Map(devices.map((d) => [d.name, d]));
|
|
100
|
+
const targets = devices
|
|
101
|
+
.filter((d) => normalizeHost(d.name) !== selfNorm)
|
|
102
|
+
.map((d) => ({
|
|
103
|
+
name: d.name,
|
|
104
|
+
role: roles[d.name],
|
|
105
|
+
online: d.tailscale?.online !== false,
|
|
106
|
+
pinned: isPinned(d.name),
|
|
107
|
+
}));
|
|
108
|
+
const rows = (deps.exportRows ?? exportClaudeUsageCacheRows)();
|
|
109
|
+
const plan = planUsagePush(isHeadedDeviceRole(selfRole), Object.keys(rows).length > 0, targets);
|
|
110
|
+
const payload = JSON.stringify({ v: 1, rows });
|
|
111
|
+
const push = deps.push ?? defaultUsagePush;
|
|
112
|
+
for (const item of plan) {
|
|
113
|
+
if (item.action === 'skip') {
|
|
114
|
+
result.skipped.push({ device: item.device, reason: item.reason });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const device = byName.get(item.device);
|
|
118
|
+
if (!device) {
|
|
119
|
+
result.errors.push({ device: item.device, message: 'device left the registry mid-sync' });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const outcome = push(device, payload);
|
|
123
|
+
if (outcome.ok)
|
|
124
|
+
result.pushed.push(item.device);
|
|
125
|
+
else
|
|
126
|
+
result.errors.push({ device: item.device, message: outcome.message ?? 'push failed' });
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
@@ -228,8 +228,8 @@ interface UsageOptions {
|
|
|
228
228
|
* When true, a read that finds no file-based setup-token MAY fall through to
|
|
229
229
|
* Claude Code's interactive OAuth login (the only credential carrying
|
|
230
230
|
* `user:profile`, which `/api/oauth/usage` requires). OFF by default and set
|
|
231
|
-
* ONLY by a foreground human `agents view` on a
|
|
232
|
-
* USAGE-READ-2). Every background caller — daemon usage warm, auth-health
|
|
231
|
+
* ONLY by a foreground human `agents view` on a headed device (personal or
|
|
232
|
+
* desktop; see USAGE-READ-2). Every background caller — daemon usage warm, auth-health
|
|
233
233
|
* probe, watchdog — leaves it unset, preserving the RUSH-1822 guarantee that
|
|
234
234
|
* an unattended loop never transmits the interactive login to Anthropic.
|
|
235
235
|
*/
|
|
@@ -252,6 +252,26 @@ interface ClaudeOauthCredentials {
|
|
|
252
252
|
rateLimitTier?: string | null;
|
|
253
253
|
organizationUuid?: string | null;
|
|
254
254
|
}
|
|
255
|
+
/** Serialized usage window for the on-disk cache. */
|
|
256
|
+
interface CachedUsageWindow {
|
|
257
|
+
key: UsageWindowKey;
|
|
258
|
+
label: string;
|
|
259
|
+
shortLabel: string;
|
|
260
|
+
usedPercent: number;
|
|
261
|
+
resetsAt: string | null;
|
|
262
|
+
windowMinutes: number | null;
|
|
263
|
+
}
|
|
264
|
+
/** Serialized usage snapshot for the on-disk cache. */
|
|
265
|
+
export interface CachedUsageSnapshot {
|
|
266
|
+
capturedAt: string | null;
|
|
267
|
+
windows: CachedUsageWindow[];
|
|
268
|
+
plan?: string | null;
|
|
269
|
+
refreshHint?: string | null;
|
|
270
|
+
unavailable?: {
|
|
271
|
+
reason: 'session_limit' | 'out_of_credits';
|
|
272
|
+
resetsAt?: string;
|
|
273
|
+
};
|
|
274
|
+
}
|
|
255
275
|
/** The single registry of agent usage sources and their transport. */
|
|
256
276
|
declare const USAGE_SOURCES: {
|
|
257
277
|
readonly claude: {
|
|
@@ -666,6 +686,32 @@ export declare function pruneExpiredClaudeUsageCacheEntry(usageKey: string, cach
|
|
|
666
686
|
export declare function writeClaudeUsageCache(usageKey: string, snapshot: UsageSnapshot, cachePath?: string): void;
|
|
667
687
|
/** Atomically merge partial native Claude windows into the current fresh row. */
|
|
668
688
|
export declare function mergeClaudeUsageCacheWindows(usageKey: string, snapshot: UsageSnapshot, cachePath?: string): void;
|
|
689
|
+
/**
|
|
690
|
+
* Export the local usage cache rows worth publishing to fleet peers (PHNX-3392
|
|
691
|
+
* usage-sync). Returns the raw serialized rows keyed by usage identity, filtered
|
|
692
|
+
* to those carrying at least one window — an empty row has nothing to teach a
|
|
693
|
+
* worker. The transport is the on-disk cache form, so there is no Date round-trip.
|
|
694
|
+
*/
|
|
695
|
+
export declare function exportClaudeUsageCacheRows(cachePath?: string): Record<string, CachedUsageSnapshot>;
|
|
696
|
+
/**
|
|
697
|
+
* Merge usage rows received from a fleet peer into the local cache, NEWEST-WINS
|
|
698
|
+
* per identity by `capturedAt` (PHNX-3392 usage-sync). A worker has no local
|
|
699
|
+
* usage writer, so an incoming row is almost always the freshest it will get; the
|
|
700
|
+
* timestamp guard exists so a stale push from one headed peer can never overwrite
|
|
701
|
+
* a fresher row another peer (or, on a headed receiver, the local status-line)
|
|
702
|
+
* already wrote. An incoming row with no `capturedAt` cannot prove it is newer, so
|
|
703
|
+
* it never displaces an existing timestamped row. Returns the count updated.
|
|
704
|
+
* Locked + atomic like every other cache writer.
|
|
705
|
+
*
|
|
706
|
+
* Deliberately NOT role-gated on the receiver. "Consume only on worker/unmarked"
|
|
707
|
+
* is a SENDER-side optimization (don't waste a push on a headed peer that reads
|
|
708
|
+
* its own usage), not a safety invariant — the actual safety property is this
|
|
709
|
+
* newest-wins guard. Receiving on a headed box is harmless (its fresher local
|
|
710
|
+
* status-line row survives) or helpful (an account it is signed into but never
|
|
711
|
+
* runs now shows a usage bar), so gating here on the receiver's own — laggier —
|
|
712
|
+
* view of its role would only reject legitimate data.
|
|
713
|
+
*/
|
|
714
|
+
export declare function ingestPeerClaudeUsageRows(rows: Record<string, CachedUsageSnapshot>, cachePath?: string): number;
|
|
669
715
|
/**
|
|
670
716
|
* Persist a Claude tokens/credits exhaustion (`out of usage credits` / `monthly
|
|
671
717
|
* spend limit`) from a real run. Unlike a rate/session limit this does NOT reset
|
|
@@ -1558,7 +1558,7 @@ export async function loadClaudeOauth(home, opts) {
|
|
|
1558
1558
|
// setup-token via the mint-auth path to restore usage/probe for the account.
|
|
1559
1559
|
//
|
|
1560
1560
|
// The single sanctioned exception (USAGE-READ-1/2): a foreground human
|
|
1561
|
-
// `agents view` on a
|
|
1561
|
+
// `agents view` on a headed device (personal or desktop) sets allowInteractiveLogin, and only
|
|
1562
1562
|
// then do we fall through to the interactive-login read below — the one
|
|
1563
1563
|
// credential carrying `user:profile`, which the usage endpoint requires. This
|
|
1564
1564
|
// is a human running one command, not an unattended loop, so it is not the
|
|
@@ -1750,6 +1750,77 @@ export function mergeClaudeUsageCacheWindows(usageKey, snapshot, cachePath = get
|
|
|
1750
1750
|
/* best-effort cache write — lock busy or disk full */
|
|
1751
1751
|
}
|
|
1752
1752
|
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Export the local usage cache rows worth publishing to fleet peers (PHNX-3392
|
|
1755
|
+
* usage-sync). Returns the raw serialized rows keyed by usage identity, filtered
|
|
1756
|
+
* to those carrying at least one window — an empty row has nothing to teach a
|
|
1757
|
+
* worker. The transport is the on-disk cache form, so there is no Date round-trip.
|
|
1758
|
+
*/
|
|
1759
|
+
export function exportClaudeUsageCacheRows(cachePath = getClaudeUsageCachePath()) {
|
|
1760
|
+
const cache = readClaudeUsageCacheFile(cachePath);
|
|
1761
|
+
const out = {};
|
|
1762
|
+
for (const [key, row] of Object.entries(cache)) {
|
|
1763
|
+
if (row && Array.isArray(row.windows) && row.windows.length > 0)
|
|
1764
|
+
out[key] = row;
|
|
1765
|
+
}
|
|
1766
|
+
return out;
|
|
1767
|
+
}
|
|
1768
|
+
function parseCapturedAtMs(capturedAt) {
|
|
1769
|
+
if (!capturedAt)
|
|
1770
|
+
return null;
|
|
1771
|
+
const ms = Date.parse(capturedAt);
|
|
1772
|
+
return Number.isFinite(ms) ? ms : null;
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Merge usage rows received from a fleet peer into the local cache, NEWEST-WINS
|
|
1776
|
+
* per identity by `capturedAt` (PHNX-3392 usage-sync). A worker has no local
|
|
1777
|
+
* usage writer, so an incoming row is almost always the freshest it will get; the
|
|
1778
|
+
* timestamp guard exists so a stale push from one headed peer can never overwrite
|
|
1779
|
+
* a fresher row another peer (or, on a headed receiver, the local status-line)
|
|
1780
|
+
* already wrote. An incoming row with no `capturedAt` cannot prove it is newer, so
|
|
1781
|
+
* it never displaces an existing timestamped row. Returns the count updated.
|
|
1782
|
+
* Locked + atomic like every other cache writer.
|
|
1783
|
+
*
|
|
1784
|
+
* Deliberately NOT role-gated on the receiver. "Consume only on worker/unmarked"
|
|
1785
|
+
* is a SENDER-side optimization (don't waste a push on a headed peer that reads
|
|
1786
|
+
* its own usage), not a safety invariant — the actual safety property is this
|
|
1787
|
+
* newest-wins guard. Receiving on a headed box is harmless (its fresher local
|
|
1788
|
+
* status-line row survives) or helpful (an account it is signed into but never
|
|
1789
|
+
* runs now shows a usage bar), so gating here on the receiver's own — laggier —
|
|
1790
|
+
* view of its role would only reject legitimate data.
|
|
1791
|
+
*/
|
|
1792
|
+
export function ingestPeerClaudeUsageRows(rows, cachePath = getClaudeUsageCachePath()) {
|
|
1793
|
+
const incoming = Object.entries(rows).filter(([, row]) => row && Array.isArray(row.windows) && row.windows.length > 0);
|
|
1794
|
+
if (incoming.length === 0)
|
|
1795
|
+
return 0;
|
|
1796
|
+
let merged = 0;
|
|
1797
|
+
try {
|
|
1798
|
+
ensureLockTarget(cachePath, '{}');
|
|
1799
|
+
withFileLock(cachePath, () => {
|
|
1800
|
+
const cache = readClaudeUsageCacheFile(cachePath);
|
|
1801
|
+
for (const [key, row] of incoming) {
|
|
1802
|
+
const prior = cache[key];
|
|
1803
|
+
if (prior) {
|
|
1804
|
+
const priorMs = parseCapturedAtMs(prior.capturedAt);
|
|
1805
|
+
const incomingMs = parseCapturedAtMs(row.capturedAt);
|
|
1806
|
+
// Keep local unless the incoming row PROVES it is strictly newer.
|
|
1807
|
+
if (incomingMs === null)
|
|
1808
|
+
continue;
|
|
1809
|
+
if (priorMs !== null && priorMs >= incomingMs)
|
|
1810
|
+
continue;
|
|
1811
|
+
}
|
|
1812
|
+
cache[key] = row;
|
|
1813
|
+
merged += 1;
|
|
1814
|
+
}
|
|
1815
|
+
if (merged > 0)
|
|
1816
|
+
writeClaudeUsageCacheFile(cache, cachePath);
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
catch {
|
|
1820
|
+
/* best-effort cache write — lock busy or disk full */
|
|
1821
|
+
}
|
|
1822
|
+
return merged;
|
|
1823
|
+
}
|
|
1753
1824
|
/** Read the entire usage cache file from disk. */
|
|
1754
1825
|
function readClaudeUsageCacheFile(cachePath) {
|
|
1755
1826
|
if (!fs.existsSync(cachePath)) {
|
|
@@ -3093,7 +3093,7 @@ export function parseAgentVersionSpec(raw) {
|
|
|
3093
3093
|
const [rawAgent, rawVersion] = parts;
|
|
3094
3094
|
const agent = resolveAgentName(rawAgent);
|
|
3095
3095
|
if (!agent) {
|
|
3096
|
-
return { error: `Unknown agent, profile, or workflow: ${rawAgent}. See \`agents
|
|
3096
|
+
return { error: `Unknown agent, profile, or workflow: ${rawAgent}. See \`agents view\` for the installed harnesses.` };
|
|
3097
3097
|
}
|
|
3098
3098
|
if (rawVersion !== undefined && (rawVersion === '' || !VERSION_RE.test(rawVersion))) {
|
|
3099
3099
|
return { error: `Invalid version '${rawVersion}' in '${raw}'` };
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
* These used to live as the top-level `agents trends` tree. That name was a
|
|
5
5
|
* peer of `agents insights` with overlapping "analytics" meaning, so agents and
|
|
6
6
|
* humans kept picking the wrong verb. The cheap counter path (sessions index +
|
|
7
|
-
* usage.db) still exists — it is now `agents insights mix
|
|
8
|
-
*
|
|
7
|
+
* usage.db) still exists — it is now `agents insights mix`. Latency stays on
|
|
8
|
+
* `agents insights perf`; quota on `agents view`.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* `agents insights
|
|
10
|
+
* One surface, not five: the board is `agents insights mix`, one section is
|
|
11
|
+
* `agents insights mix <recipe>`, and `--list` names the recipe ids. The former
|
|
12
|
+
* per-recipe shortcut commands (`harness-mix`, `model-mix`, …), the `recipes`
|
|
13
|
+
* lister, and the `trends` alias were removed — `mix` already did all three.
|
|
12
14
|
*/
|
|
13
15
|
import type { Command } from 'commander';
|
|
14
16
|
export declare function parseMixDays(raw: string | undefined): number;
|
|
@@ -23,9 +25,8 @@ export declare function renderMixDashboard(days: number, asJson: boolean, banner
|
|
|
23
25
|
*
|
|
24
26
|
* Layout:
|
|
25
27
|
* <parent> mix multi-recipe board
|
|
26
|
-
* <parent>
|
|
27
|
-
* <parent>
|
|
28
|
+
* <parent> mix <recipe> one baked recipe (harness-mix, model-mix, …)
|
|
29
|
+
* <parent> mix --list list recipe ids
|
|
28
30
|
* <parent> query raw usage.db rows
|
|
29
|
-
* <parent> harness-mix|… one baked recipe
|
|
30
31
|
*/
|
|
31
32
|
export declare function registerMixCommands(parent: Command): void;
|