@phnx-labs/agents-cli 1.22.117 → 1.22.119
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 +301 -0
- package/dist/bootstrap.js +4 -11
- package/dist/commands/accounts.js +7 -4
- package/dist/commands/browser.d.ts +52 -70
- package/dist/commands/browser.js +192 -3562
- package/dist/commands/daemon.d.ts +39 -2
- package/dist/commands/daemon.js +72 -73
- package/dist/commands/events.js +1 -1
- package/dist/commands/exec.js +53 -1
- package/dist/commands/projects.js +92 -7
- package/dist/commands/repo.js +13 -49
- package/dist/commands/route.js +5 -5
- package/dist/commands/routines.js +39 -12
- package/dist/commands/sessions-render.d.ts +1 -1
- package/dist/commands/sessions-render.js +0 -1
- package/dist/commands/sessions.test-fixture.d.ts +0 -1
- package/dist/commands/sessions.test-fixture.js +0 -37
- package/dist/commands/setup-browser.d.ts +17 -12
- package/dist/commands/setup-browser.js +52 -87
- package/dist/commands/setup-preferences.d.ts +0 -17
- package/dist/commands/setup-preferences.js +18 -49
- package/dist/commands/setup.js +9 -9
- package/dist/commands/ssh.js +10 -1
- package/dist/commands/sync.js +6 -44
- package/dist/commands/teams.js +0 -1
- package/dist/commands/update.d.ts +19 -0
- package/dist/commands/update.js +34 -6
- package/dist/commands/view.d.ts +12 -1
- package/dist/commands/view.js +47 -2
- package/dist/index.js +107 -26
- package/dist/lib/account-capabilities.js +0 -2
- package/dist/lib/account-catalog.d.ts +38 -0
- package/dist/lib/account-catalog.js +110 -26
- package/dist/lib/account-provider-registry.js +1 -1
- package/dist/lib/account-registry.d.ts +1 -1
- package/dist/lib/account-registry.js +36 -2
- package/dist/lib/accounting/rotate.js +22 -8
- package/dist/lib/accounting/usage-ingest.d.ts +6 -1
- package/dist/lib/accounting/usage-ingest.js +115 -45
- package/dist/lib/accounting/usage-sync.d.ts +134 -31
- package/dist/lib/accounting/usage-sync.js +227 -66
- package/dist/lib/accounting/usage.d.ts +16 -3
- package/dist/lib/accounting/usage.js +26 -8
- package/dist/lib/accounts/add.d.ts +3 -1
- package/dist/lib/accounts/add.js +24 -13
- package/dist/lib/accounts/slots.d.ts +8 -0
- package/dist/lib/accounts/slots.js +17 -0
- package/dist/lib/add-dir.js +0 -1
- package/dist/lib/agent-cli-commands.js +0 -1
- package/dist/lib/agent-spec/agents.d.ts +6 -7
- package/dist/lib/agent-spec/agents.js +7 -60
- package/dist/lib/auth-health.d.ts +69 -1
- package/dist/lib/auth-health.js +133 -4
- package/dist/lib/browser/context.d.ts +82 -0
- package/dist/lib/browser/context.js +88 -0
- package/dist/lib/browser/paths.d.ts +25 -0
- package/dist/lib/browser/paths.js +51 -0
- package/dist/lib/browser/record.d.ts +38 -0
- package/dist/lib/browser/record.js +57 -0
- package/dist/lib/browser/sessions-list.js +1 -2
- package/dist/lib/browser-client.d.ts +158 -0
- package/dist/lib/browser-client.js +234 -0
- package/dist/lib/channels/owner-sink.d.ts +1 -18
- package/dist/lib/channels/owner-sink.js +29 -82
- package/dist/lib/channels/providers/rush.d.ts +7 -7
- package/dist/lib/channels/providers/rush.js +127 -57
- package/dist/lib/claude-account-token.d.ts +7 -1
- package/dist/lib/claude-account-token.js +26 -4
- package/dist/lib/claude-statusline.d.ts +1 -1
- package/dist/lib/claude-statusline.js +15 -3
- package/dist/lib/commands.js +1 -1
- package/dist/lib/crabbox/lease.js +1 -1
- package/dist/lib/crabbox/runtimes.js +1 -3
- package/dist/lib/daemon/account-state-daemon-service.js +1 -0
- package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
- package/dist/lib/daemon/auth-sync-service.js +37 -43
- package/dist/lib/daemon/daemon.d.ts +25 -14
- package/dist/lib/daemon/daemon.js +92 -154
- package/dist/lib/daemon/runner.d.ts +2 -2
- package/dist/lib/daemon/runner.js +32 -9
- package/dist/lib/daemon/self-update-service.d.ts +35 -34
- package/dist/lib/daemon/self-update-service.js +40 -36
- package/dist/lib/daemon/service.d.ts +12 -8
- package/dist/lib/daemon/supervisor.d.ts +65 -54
- package/dist/lib/daemon/supervisor.js +136 -171
- package/dist/lib/daemon/testdata/process-view-start.js +5 -2
- package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
- package/dist/lib/daemon/usage-sync-service.js +46 -78
- package/dist/lib/daemon-health.d.ts +22 -5
- package/dist/lib/daemon-health.js +47 -3
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +0 -10
- package/dist/lib/daemon-ticks.js +4 -2
- package/dist/lib/devices/doctor-findings.js +8 -11
- package/dist/lib/devices/harness-inventory.js +14 -5
- package/dist/lib/exec-account-home.js +5 -0
- package/dist/lib/exec.js +10 -13
- package/dist/lib/feed/events.js +0 -1
- package/dist/lib/feed/tool-activity.js +5 -2
- package/dist/lib/fleet-shared-state.d.ts +45 -0
- package/dist/lib/fleet-shared-state.js +78 -9
- package/dist/lib/fs-atomic.js +25 -0
- package/dist/lib/github/pr-mergeable.d.ts +4 -2
- package/dist/lib/github/pr-mergeable.js +5 -3
- package/dist/lib/github/project-prs.d.ts +58 -3
- package/dist/lib/github/project-prs.js +205 -7
- package/dist/lib/harness/adapters/cursor.js +14 -4
- package/dist/lib/harness-auth-capabilities.d.ts +4 -3
- package/dist/lib/harness-auth-capabilities.js +1 -2
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/hooks/cache.d.ts +7 -0
- package/dist/lib/hooks/cache.js +27 -2
- package/dist/lib/hooks/install.js +7 -1
- package/dist/lib/hosts/ready.d.ts +38 -8
- package/dist/lib/hosts/ready.js +62 -14
- package/dist/lib/installations/active-check.d.ts +27 -0
- package/dist/lib/installations/active-check.js +61 -0
- package/dist/lib/installations/migrate.js +6 -3
- package/dist/lib/installations/shims.d.ts +1 -1
- package/dist/lib/installations/shims.js +32 -12
- package/dist/lib/installations/store.d.ts +9 -0
- package/dist/lib/installations/store.js +24 -0
- package/dist/lib/installations/update.js +21 -15
- package/dist/lib/installations/versions.js +15 -3
- package/dist/lib/manifest.js +1 -1
- package/dist/lib/models.js +0 -2
- package/dist/lib/open-url.js +28 -54
- package/dist/lib/profiles.js +0 -2
- package/dist/lib/projects.d.ts +16 -0
- package/dist/lib/projects.js +31 -0
- package/dist/lib/refresh.js +1 -1
- package/dist/lib/routine-readiness.d.ts +35 -0
- package/dist/lib/routine-readiness.js +74 -16
- package/dist/lib/rules/compile.js +6 -0
- package/dist/lib/sandbox.js +0 -2
- package/dist/lib/secrets-policy.d.ts +89 -11
- package/dist/lib/secrets-policy.js +175 -28
- package/dist/lib/session/active.d.ts +17 -6
- package/dist/lib/session/active.js +105 -49
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +65 -18
- package/dist/lib/session/discover.js +1 -283
- package/dist/lib/session/mirror.d.ts +4 -3
- package/dist/lib/session/mirror.js +4 -3
- package/dist/lib/session/parse.d.ts +0 -2
- package/dist/lib/session/parse.js +1 -138
- package/dist/lib/session/pid-registry.d.ts +6 -3
- package/dist/lib/session/pid-registry.js +18 -8
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +7 -3
- package/dist/lib/session/throughput.d.ts +1 -3
- package/dist/lib/session/throughput.js +0 -20
- package/dist/lib/session/types.d.ts +2 -2
- package/dist/lib/session/types.js +2 -2
- package/dist/lib/sessions-client.d.ts +60 -1
- package/dist/lib/sessions-client.js +227 -4
- package/dist/lib/setup-tool-status.js +5 -10
- package/dist/lib/signin-badge.d.ts +3 -2
- package/dist/lib/signin-badge.js +6 -3
- package/dist/lib/smart-launch.js +4 -1
- package/dist/lib/staleness/detectors/commands.d.ts +1 -1
- package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
- package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
- package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
- package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
- package/dist/lib/staleness/detectors/rules.d.ts +1 -1
- package/dist/lib/staleness/detectors/skills.d.ts +1 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
- package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
- package/dist/lib/staleness/writers/commands.d.ts +1 -1
- package/dist/lib/staleness/writers/hooks.d.ts +1 -1
- package/dist/lib/staleness/writers/mcp.d.ts +1 -1
- package/dist/lib/staleness/writers/permissions.d.ts +1 -1
- package/dist/lib/staleness/writers/plugins.d.ts +1 -1
- package/dist/lib/staleness/writers/rules.d.ts +1 -1
- package/dist/lib/staleness/writers/skills.d.ts +1 -1
- package/dist/lib/staleness/writers/sources.js +5 -0
- package/dist/lib/staleness/writers/subagents.d.ts +1 -1
- package/dist/lib/staleness/writers/workflows.d.ts +1 -1
- package/dist/lib/state.d.ts +6 -4
- package/dist/lib/state.js +7 -17
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/teams/parsers.js +1 -136
- package/dist/lib/teams/placement-probe.js +50 -15
- package/dist/lib/teams/scheduler.d.ts +7 -0
- package/dist/lib/types.d.ts +16 -1
- package/dist/lib/types.js +1 -1
- package/dist/lib/view-types.d.ts +32 -0
- package/dist/lib/watchdog/read.js +0 -1
- package/dist/lib/watchdog/rotate.js +1 -2
- package/dist/lib/watchdog/runner.js +2 -2
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdogTail.js +0 -31
- package/package.json +1 -1
- package/scripts/postinstall.js +8 -8
- package/dist/browser.d.ts +0 -2
- package/dist/browser.js +0 -17
- package/dist/commands/browser-picker.d.ts +0 -18
- package/dist/commands/browser-picker.js +0 -97
- package/dist/lib/browser/arc-discovery.d.ts +0 -52
- package/dist/lib/browser/arc-discovery.js +0 -197
- package/dist/lib/browser/arc-dom.d.ts +0 -14
- package/dist/lib/browser/arc-dom.js +0 -121
- package/dist/lib/browser/caller-identity.d.ts +0 -40
- package/dist/lib/browser/caller-identity.js +0 -175
- package/dist/lib/browser/cdp.d.ts +0 -51
- package/dist/lib/browser/cdp.js +0 -254
- package/dist/lib/browser/chrome.d.ts +0 -141
- package/dist/lib/browser/chrome.js +0 -704
- package/dist/lib/browser/chromium-discovery.d.ts +0 -28
- package/dist/lib/browser/chromium-discovery.js +0 -96
- package/dist/lib/browser/devices.d.ts +0 -34
- package/dist/lib/browser/devices.js +0 -61
- package/dist/lib/browser/domain-skills.d.ts +0 -71
- package/dist/lib/browser/domain-skills.js +0 -195
- package/dist/lib/browser/drivers/arc.d.ts +0 -40
- package/dist/lib/browser/drivers/arc.js +0 -276
- package/dist/lib/browser/drivers/firefox.d.ts +0 -99
- package/dist/lib/browser/drivers/firefox.js +0 -377
- package/dist/lib/browser/drivers/local.d.ts +0 -55
- package/dist/lib/browser/drivers/local.js +0 -266
- package/dist/lib/browser/drivers/ssh.d.ts +0 -120
- package/dist/lib/browser/drivers/ssh.js +0 -467
- package/dist/lib/browser/editor.d.ts +0 -3
- package/dist/lib/browser/editor.js +0 -50
- package/dist/lib/browser/ffmpeg.d.ts +0 -12
- package/dist/lib/browser/ffmpeg.js +0 -184
- package/dist/lib/browser/firefox-discovery.d.ts +0 -69
- package/dist/lib/browser/firefox-discovery.js +0 -162
- package/dist/lib/browser/har.d.ts +0 -85
- package/dist/lib/browser/har.js +0 -77
- package/dist/lib/browser/hygiene.d.ts +0 -97
- package/dist/lib/browser/hygiene.js +0 -153
- package/dist/lib/browser/index.d.ts +0 -5
- package/dist/lib/browser/index.js +0 -5
- package/dist/lib/browser/input.d.ts +0 -7
- package/dist/lib/browser/input.js +0 -92
- package/dist/lib/browser/ipc.d.ts +0 -204
- package/dist/lib/browser/ipc.js +0 -1453
- package/dist/lib/browser/login-detection.d.ts +0 -87
- package/dist/lib/browser/login-detection.js +0 -274
- package/dist/lib/browser/profiles.d.ts +0 -422
- package/dist/lib/browser/profiles.js +0 -1157
- package/dist/lib/browser/refs.d.ts +0 -89
- package/dist/lib/browser/refs.js +0 -191
- package/dist/lib/browser/registry.d.ts +0 -70
- package/dist/lib/browser/registry.js +0 -252
- package/dist/lib/browser/remote-control.d.ts +0 -59
- package/dist/lib/browser/remote-control.js +0 -85
- package/dist/lib/browser/resolve-target.d.ts +0 -66
- package/dist/lib/browser/resolve-target.js +0 -264
- package/dist/lib/browser/runtime-state.d.ts +0 -291
- package/dist/lib/browser/runtime-state.js +0 -584
- package/dist/lib/browser/secret-ref.d.ts +0 -10
- package/dist/lib/browser/secret-ref.js +0 -14
- package/dist/lib/browser/service.d.ts +0 -812
- package/dist/lib/browser/service.js +0 -4693
- package/dist/lib/browser/stream.d.ts +0 -17
- package/dist/lib/browser/stream.js +0 -72
- package/dist/lib/browser/task-index.d.ts +0 -78
- package/dist/lib/browser/task-index.js +0 -199
- package/dist/lib/browser/types.d.ts +0 -647
- package/dist/lib/browser/types.js +0 -61
- package/dist/lib/browser/upload.d.ts +0 -29
- package/dist/lib/browser/upload.js +0 -298
- package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
- package/dist/lib/daemon/browser-ipc-service.js +0 -46
- package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
- package/dist/lib/daemon/browser-task-reap-service.js +0 -26
- package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
- package/dist/lib/fleet-shared-repo-sync.js +0 -490
|
@@ -1,21 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fleet distribution of identity-keyed Claude usage
|
|
2
|
+
* Fleet distribution of identity-keyed Claude usage over SSH (PHNX-4116).
|
|
3
3
|
*
|
|
4
|
-
* A headed device can read authoritative usage; a worker's setup-token cannot
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* A headed device can read authoritative usage; a worker's setup-token cannot
|
|
5
|
+
* (credential-management.md invariant 5: a worker never polls
|
|
6
|
+
* `/api/oauth/usage`). So the headed daemon's `usage-sync` tick dials every
|
|
7
|
+
* dialable peer in parallel with `agents __usage-ingest --reply`, sending this
|
|
8
|
+
* box's own daemon-state envelope on stdin; the peer merges the usage rows into
|
|
9
|
+
* its cache newest-wins and prints its own envelope back, which the headed box
|
|
10
|
+
* stores in `~/.agents/devices/<peer>/daemon-state.json` stamped `receivedAt`.
|
|
11
|
+
* Workers never initiate. A peer that times out is skipped this tick and never
|
|
12
|
+
* blocks another. Only usage rows, verdicts, and session digests move here —
|
|
13
|
+
* never a credential (invariant 7: roles never cross).
|
|
14
|
+
*
|
|
15
|
+
* This replaced a git exchange over the fleet-synced user repo: every daemon
|
|
16
|
+
* committed its own file each tick, so the shared store reached 1.1 GiB /
|
|
17
|
+
* 18,358 commits with 100% of the last 2,000 being `chore(devices): publish
|
|
18
|
+
* <device> daemon state`, and a worker's clone fell 10k commits behind with
|
|
19
|
+
* `git fetch timed out` on every tick — a valid setup-token could not be
|
|
20
|
+
* scheduled because a repo was bloated. Git now carries human-authored
|
|
21
|
+
* resources only; `devices/<device>/daemon-state.json` is untracked.
|
|
9
22
|
*/
|
|
10
|
-
import { isHeadedDeviceRole,
|
|
11
|
-
import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from '../fleet-shared-state.js';
|
|
23
|
+
import { isHeadedDeviceRole, selfConfiguredDeviceRole } from '../device-config.js';
|
|
24
|
+
import { FLEET_SHARED_STATE_VERSION, parseFleetSharedDeviceStateEnvelope, readFleetSharedDeviceStates, readOwnFleetSharedDeviceState, storePeerFleetSharedDeviceState, updateFleetSharedDeviceStateAsync, } from '../fleet-shared-state.js';
|
|
12
25
|
import { getUserAgentsDir } from '../state.js';
|
|
13
26
|
import { machineId, normalizeHost } from '../session/sync/config.js';
|
|
14
27
|
import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
|
|
15
|
-
/** Cadence of the usage-sync
|
|
28
|
+
/** Cadence of the usage-sync tick — the trust window for a `sync` snapshot. */
|
|
16
29
|
export const USAGE_SYNC_INTERVAL_MS = 15 * 60_000;
|
|
17
30
|
/** Re-publish an unchanged windowed row at least this often so workers' 15-min trust does not lapse. */
|
|
18
31
|
export const USAGE_PUBLISH_HEARTBEAT_MS = 10 * 60_000;
|
|
32
|
+
/** Per-peer SSH budget for one exchange; a peer past it is skipped this tick. */
|
|
33
|
+
export const USAGE_EXCHANGE_PEER_TIMEOUT_MS = 20_000;
|
|
34
|
+
/** Marks the start of the JSON reply on the peer's stdout, so login-shell noise before it is ignored. */
|
|
35
|
+
export const FLEET_STATE_REPLY_MARKER = '@@AGENTS_FLEET_STATE@@';
|
|
19
36
|
function usageMeterSignature(row) {
|
|
20
37
|
return JSON.stringify({
|
|
21
38
|
windows: row.windows,
|
|
@@ -28,7 +45,7 @@ function usageMeterSignature(row) {
|
|
|
28
45
|
/**
|
|
29
46
|
* Keep the previously published `capturedAt` when meters have not moved and
|
|
30
47
|
* the last publish is still inside the heartbeat. Stops statusline re-renders
|
|
31
|
-
* from
|
|
48
|
+
* from rewriting the own-state file every few seconds.
|
|
32
49
|
*/
|
|
33
50
|
export function mergeUsageRowsForPublish(previous, next, nowMs = Date.now()) {
|
|
34
51
|
if (!previous)
|
|
@@ -47,7 +64,7 @@ export function mergeUsageRowsForPublish(previous, next, nowMs = Date.now()) {
|
|
|
47
64
|
}
|
|
48
65
|
return out;
|
|
49
66
|
}
|
|
50
|
-
/** Publish this headed device's stable usage snapshot into its
|
|
67
|
+
/** Publish this headed device's stable usage snapshot into its own state file. */
|
|
51
68
|
export async function publishUsageSnapshotToSharedStore(options = {}) {
|
|
52
69
|
const result = {
|
|
53
70
|
published: false,
|
|
@@ -84,68 +101,212 @@ export async function publishUsageSnapshotToSharedStore(options = {}) {
|
|
|
84
101
|
return result;
|
|
85
102
|
}
|
|
86
103
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
104
|
+
* Refresh every field this device owns in its own state file: the usage
|
|
105
|
+
* snapshot (headed only), the session digests, and the reserved-auth readiness
|
|
106
|
+
* verdict. Run before the envelope is sent (the headed tick) or printed back
|
|
107
|
+
* (`__usage-ingest --reply`) so a peer always receives current fields. Each
|
|
108
|
+
* publisher is independent: one failing is reported in `errors` and the others
|
|
109
|
+
* still land. All three writers are the async, non-blocking variants
|
|
110
|
+
* (`updateFleetSharedDeviceStateAsync` underneath).
|
|
90
111
|
*/
|
|
91
|
-
export async function
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
112
|
+
export async function publishOwnFleetState(options = {}) {
|
|
113
|
+
const errors = [];
|
|
114
|
+
const usage = await publishUsageSnapshotToSharedStore(options);
|
|
115
|
+
if (usage.error)
|
|
116
|
+
errors.push(`usage: ${usage.error}`);
|
|
117
|
+
let mirror = { changed: false, count: 0, error: null };
|
|
118
|
+
try {
|
|
119
|
+
const { publishSessionMirrorToSharedStore } = await import('../session/mirror.js');
|
|
120
|
+
const published = await publishSessionMirrorToSharedStore({ userAgentsDir: options.userAgentsDir, device: options.device });
|
|
121
|
+
mirror = { changed: published.changed, count: published.count, error: published.error };
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
mirror = { changed: false, count: 0, error: err.message };
|
|
125
|
+
}
|
|
126
|
+
if (mirror.error)
|
|
127
|
+
errors.push(`sessions: ${mirror.error}`);
|
|
128
|
+
let auth = { status: 'unknown', error: null };
|
|
129
|
+
try {
|
|
130
|
+
const { publishReservedAuthVerdict } = await import('../secrets-policy.js');
|
|
131
|
+
const verdict = await publishReservedAuthVerdict({ userAgentsDir: options.userAgentsDir, localName: options.device });
|
|
132
|
+
auth = { status: verdict.status, error: verdict.error };
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
auth = { status: 'unknown', error: err.message };
|
|
136
|
+
}
|
|
137
|
+
if (auth.error)
|
|
138
|
+
errors.push(`auth: ${auth.error}`);
|
|
139
|
+
return { usage, mirror, auth, errors };
|
|
140
|
+
}
|
|
141
|
+
/** This device's exchange envelope, from its own state file (or usage-only from the cache). */
|
|
142
|
+
export function buildFleetStatePayload(options = {}) {
|
|
143
|
+
const device = options.device ?? machineId();
|
|
144
|
+
if (options.usageOnly) {
|
|
145
|
+
const state = { version: FLEET_SHARED_STATE_VERSION, device };
|
|
146
|
+
if (isHeadedDeviceRole(options.role ?? selfConfiguredDeviceRole())) {
|
|
147
|
+
const rows = exportClaudeUsageCacheRows(options.cachePath);
|
|
148
|
+
if (Object.keys(rows).length > 0)
|
|
149
|
+
state.usage = { rows };
|
|
150
|
+
}
|
|
151
|
+
return { v: 2, state };
|
|
152
|
+
}
|
|
153
|
+
const state = readOwnFleetSharedDeviceState(device, options.userAgentsDir);
|
|
154
|
+
return { v: 2, state, ...(options.errors?.length ? { errors: options.errors } : {}) };
|
|
103
155
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const ms = Date.parse(row.capturedAt);
|
|
108
|
-
return Number.isFinite(ms) ? ms : null;
|
|
156
|
+
/** Serialize the reply as the peer prints it: marker line, then one JSON line. */
|
|
157
|
+
export function formatFleetStateReply(payload) {
|
|
158
|
+
return `${FLEET_STATE_REPLY_MARKER}\n${JSON.stringify(payload)}\n`;
|
|
109
159
|
}
|
|
110
160
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
161
|
+
* Parse what a peer sent — a v2 exchange envelope, or the legacy v1 bare-rows
|
|
162
|
+
* envelope from an older headed peer. Throws a clear message on anything else;
|
|
163
|
+
* the caller decides whether that is exit code 2 (the receiver) or a per-peer
|
|
164
|
+
* error (the headed tick).
|
|
113
165
|
*/
|
|
114
|
-
export function
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return result;
|
|
166
|
+
export function parseFleetStateExchangeInput(raw) {
|
|
167
|
+
let parsed;
|
|
168
|
+
try {
|
|
169
|
+
parsed = JSON.parse(raw);
|
|
119
170
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
171
|
+
catch {
|
|
172
|
+
throw new Error('malformed JSON payload');
|
|
173
|
+
}
|
|
174
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
175
|
+
throw new Error('unrecognized usage-sync payload shape');
|
|
176
|
+
}
|
|
177
|
+
const record = parsed;
|
|
178
|
+
if (record.v === 2) {
|
|
179
|
+
const state = parseFleetSharedDeviceStateEnvelope(record.state);
|
|
180
|
+
const errors = Array.isArray(record.errors) ? record.errors.filter((e) => typeof e === 'string') : undefined;
|
|
181
|
+
return { v: 2, state, ...(errors?.length ? { errors } : {}) };
|
|
182
|
+
}
|
|
183
|
+
if (record.v === 1) {
|
|
184
|
+
const rows = record.rows;
|
|
185
|
+
// `typeof [] === 'object'` — an array is NOT a rows map.
|
|
186
|
+
if (typeof rows !== 'object' || rows === null || Array.isArray(rows)) {
|
|
187
|
+
throw new Error('unrecognized usage-sync payload shape');
|
|
188
|
+
}
|
|
189
|
+
return { v: 1, rows: rows };
|
|
190
|
+
}
|
|
191
|
+
throw new Error('unrecognized usage-sync payload shape');
|
|
192
|
+
}
|
|
193
|
+
/** Extract the reply envelope from a peer's stdout (text before the marker is login-shell noise). */
|
|
194
|
+
export function parseFleetStateReply(stdout) {
|
|
195
|
+
const idx = stdout.lastIndexOf(FLEET_STATE_REPLY_MARKER);
|
|
196
|
+
if (idx === -1) {
|
|
197
|
+
throw new Error(stdout.trim()
|
|
198
|
+
? 'no reply envelope on stdout (peer agents-cli predates `__usage-ingest --reply`?)'
|
|
199
|
+
: 'empty reply (peer agents-cli predates `__usage-ingest --reply`?)');
|
|
200
|
+
}
|
|
201
|
+
const body = stdout.slice(idx + FLEET_STATE_REPLY_MARKER.length).trim();
|
|
202
|
+
const payload = parseFleetStateExchangeInput(body);
|
|
203
|
+
if (payload.v !== 2)
|
|
204
|
+
throw new Error('reply is not a v2 exchange envelope');
|
|
205
|
+
return payload;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Rows as the receiver stores them: provenance `sync` (so the trust window is
|
|
209
|
+
* `USAGE_SYNC_TRUST_MS`, not the local-capture bar) and the poller pinned to the
|
|
210
|
+
* device that actually read the endpoint, defaulting to the sender.
|
|
211
|
+
*/
|
|
212
|
+
function stampSyncRows(rows, sender) {
|
|
213
|
+
const out = {};
|
|
214
|
+
for (const [identity, incoming] of Object.entries(rows)) {
|
|
215
|
+
if (!incoming || !Array.isArray(incoming.windows) || incoming.windows.length === 0)
|
|
127
216
|
continue;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
217
|
+
out[identity] = { ...incoming, freshnessSource: 'sync', pollerDevice: incoming.pollerDevice ?? sender };
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Take a peer's envelope in: store it as that peer's file (stamped `receivedAt`)
|
|
223
|
+
* and merge its usage rows into this box's cache newest-wins. The same step runs
|
|
224
|
+
* on both ends — the worker on the pushed envelope, the headed box on the reply.
|
|
225
|
+
* Both writes take their file lock asynchronously: this runs on the daemon tick
|
|
226
|
+
* once per peer, and a `sleepSync` lock there freezes every other service.
|
|
227
|
+
*/
|
|
228
|
+
export async function applyPeerFleetState(state, options = {}) {
|
|
229
|
+
const self = normalizeHost(options.device ?? machineId());
|
|
230
|
+
if (normalizeHost(state.device) === self) {
|
|
231
|
+
throw new Error(`peer envelope names this device (${state.device}); refusing to overwrite the own state file`);
|
|
232
|
+
}
|
|
233
|
+
const receivedAt = options.receivedAt ?? Date.now();
|
|
234
|
+
const write = await storePeerFleetSharedDeviceState(state, options.userAgentsDir ?? getUserAgentsDir(), receivedAt);
|
|
235
|
+
const merged = state.usage
|
|
236
|
+
? await ingestPeerClaudeUsageRows(stampSyncRows(state.usage.rows, state.device), options.cachePath)
|
|
237
|
+
: 0;
|
|
238
|
+
return { path: write.path, merged, receivedAt };
|
|
239
|
+
}
|
|
240
|
+
/** The remote command one exchange runs on a peer, in the peer's shell dialect. */
|
|
241
|
+
export async function buildFleetStateExchangeCommand(os) {
|
|
242
|
+
const { buildRemoteAgentsInvocation, buildWindowsStdinAgentsCommand, remoteShellFor } = await import('../hosts/remote-cmd.js');
|
|
243
|
+
// The Windows `agents.ps1` shim does not forward ssh-piped stdin, so the
|
|
244
|
+
// payload goes through a temp file and `--from <path>` there.
|
|
245
|
+
return remoteShellFor(os) === 'powershell'
|
|
246
|
+
? buildWindowsStdinAgentsCommand(['__usage-ingest', '--reply'])
|
|
247
|
+
: buildRemoteAgentsInvocation(['__usage-ingest', '--reply'], undefined, os);
|
|
248
|
+
}
|
|
249
|
+
async function sshDial(timeoutMs) {
|
|
250
|
+
const { sshExecAsync } = await import('../ssh-exec.js');
|
|
251
|
+
const { sshTargetFor, deviceIdentityArgs } = await import('../devices/connect.js');
|
|
252
|
+
return (peer, remoteCmd, input) => sshExecAsync(sshTargetFor(peer), remoteCmd, {
|
|
253
|
+
input,
|
|
254
|
+
timeoutMs,
|
|
255
|
+
extraSshArgs: deviceIdentityArgs(peer),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async function defaultPeers(self) {
|
|
259
|
+
const { isDialableDevice, loadDevicesSync } = await import('../devices/registry.js');
|
|
260
|
+
return Object.values(loadDevicesSync())
|
|
261
|
+
.filter((d) => normalizeHost(d.name) !== self && isDialableDevice(d))
|
|
262
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The headed publisher's fan-out: dial every peer in parallel, each under its
|
|
266
|
+
* own timeout, and apply each reply as it arrives. Never throws for a peer — a
|
|
267
|
+
* timeout, an old CLI with no `--reply`, or a reply naming the wrong device is
|
|
268
|
+
* that peer's `error` and the others are unaffected. A non-headed device skips:
|
|
269
|
+
* workers never initiate (they only answer), so a worker's tick is a no-op here.
|
|
270
|
+
*/
|
|
271
|
+
export async function exchangeFleetStateWithPeers(options = {}) {
|
|
272
|
+
const device = options.device ?? machineId();
|
|
273
|
+
const self = normalizeHost(device);
|
|
274
|
+
const peers = options.peers ?? await defaultPeers(self);
|
|
275
|
+
if (peers.length === 0)
|
|
276
|
+
return { skipped: 'no dialable peer in the device registry', outcomes: [] };
|
|
277
|
+
const timeoutMs = options.timeoutMs ?? USAGE_EXCHANGE_PEER_TIMEOUT_MS;
|
|
278
|
+
const dial = options.dial ?? await sshDial(timeoutMs);
|
|
279
|
+
const payload = options.payload ?? buildFleetStatePayload({ device, userAgentsDir: options.userAgentsDir });
|
|
280
|
+
const input = JSON.stringify(payload);
|
|
281
|
+
const outcomes = await Promise.all(peers.map(async (peer) => {
|
|
282
|
+
const outcome = { device: peer.name, delivered: false, receivedAt: null, merged: 0, error: null, peerErrors: [] };
|
|
283
|
+
try {
|
|
284
|
+
const remoteCmd = await buildFleetStateExchangeCommand(peer.shell === 'powershell' ? 'windows' : undefined);
|
|
285
|
+
const res = await dial(peer, remoteCmd, input);
|
|
286
|
+
if (res.timedOut)
|
|
287
|
+
throw new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
288
|
+
if (res.code !== 0) {
|
|
289
|
+
const detail = res.stderr.trim().split('\n').pop() ?? '';
|
|
290
|
+
throw new Error(`exited ${res.code ?? 'without a status'}${detail ? `: ${detail}` : ''}`);
|
|
291
|
+
}
|
|
292
|
+
const reply = parseFleetStateReply(res.stdout);
|
|
293
|
+
if (normalizeHost(reply.state.device) !== normalizeHost(peer.name)) {
|
|
294
|
+
throw new Error(`reply names device '${reply.state.device}', expected '${peer.name}'`);
|
|
141
295
|
}
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
296
|
+
const applied = await applyPeerFleetState(reply.state, {
|
|
297
|
+
device,
|
|
298
|
+
userAgentsDir: options.userAgentsDir,
|
|
299
|
+
cachePath: options.cachePath,
|
|
300
|
+
});
|
|
301
|
+
outcome.delivered = true;
|
|
302
|
+
outcome.receivedAt = applied.receivedAt;
|
|
303
|
+
outcome.merged = applied.merged;
|
|
304
|
+
outcome.peerErrors = reply.errors ?? [];
|
|
146
305
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
306
|
+
catch (err) {
|
|
307
|
+
outcome.error = err.message;
|
|
308
|
+
}
|
|
309
|
+
return outcome;
|
|
310
|
+
}));
|
|
311
|
+
return { skipped: null, outcomes };
|
|
151
312
|
}
|
|
@@ -494,6 +494,17 @@ export interface FormatUsageSummaryOpts {
|
|
|
494
494
|
export declare function viewUsageSummaryOptions(agentId: AgentId, signedIn: boolean, usageInfo: UsageInfo | undefined, maxWindows: number | undefined, version?: string): FormatUsageSummaryOpts;
|
|
495
495
|
/** Format a one-line usage summary with compact bars for inline display. */
|
|
496
496
|
export declare function formatUsageSummary(plan: string | null, snapshot: UsageSnapshot | null, planWidth?: number, opts?: FormatUsageSummaryOpts): string;
|
|
497
|
+
/**
|
|
498
|
+
* The snapshot's windows that are still LIVE — reset time not yet passed. A
|
|
499
|
+
* window past its `resetsAt` has rolled over: its `usedPercent` is a reading of
|
|
500
|
+
* the PREVIOUS period, not a live throttle (PHNX-4116). Both the throttle
|
|
501
|
+
* verdict ({@link deriveUsageStatusFromSnapshot}) and the displayed
|
|
502
|
+
* `usedPercent` (`devices/harness-inventory.ts`) MUST select from this SAME set,
|
|
503
|
+
* or a maxed-then-reset account reads `available` yet shows a stale 99% next to
|
|
504
|
+
* its "ready" verdict (#3705). Callers still narrow further (dropping
|
|
505
|
+
* `sonnet_week`, the model sub-limit); this is only the reset-rollover cut.
|
|
506
|
+
*/
|
|
507
|
+
export declare function liveUsageWindows(snapshot: UsageSnapshot, now?: number): UsageWindow[];
|
|
497
508
|
/**
|
|
498
509
|
* Derive an account's real throttle state from its live usage windows — the
|
|
499
510
|
* single signal both the `agents view` badge and run-rotation eligibility share
|
|
@@ -513,7 +524,7 @@ export declare function formatUsageSummary(plan: string | null, snapshot: UsageS
|
|
|
513
524
|
* The model-specific `sonnet_week` sub-limit is excluded: hitting it throttles
|
|
514
525
|
* one model, not the account, so it shouldn't flip the whole row to throttled.
|
|
515
526
|
*/
|
|
516
|
-
export declare function deriveUsageStatusFromSnapshot(snapshot: UsageSnapshot | null | undefined): 'available' | 'rate_limited' | null;
|
|
527
|
+
export declare function deriveUsageStatusFromSnapshot(snapshot: UsageSnapshot | null | undefined, now?: number): 'available' | 'rate_limited' | null;
|
|
517
528
|
/** A prior sample of one window's utilization, for burn-rate projection. */
|
|
518
529
|
interface UsagePriorSample {
|
|
519
530
|
/** Epoch ms the prior snapshot was captured. */
|
|
@@ -768,7 +779,9 @@ export declare function exportClaudeUsageCacheRows(cachePath?: string): Record<s
|
|
|
768
779
|
* a fresher row another peer (or, on a headed receiver, the local status-line)
|
|
769
780
|
* already wrote. An incoming row with no `capturedAt` cannot prove it is newer, so
|
|
770
781
|
* it never displaces an existing timestamped row. Returns the count updated.
|
|
771
|
-
* Locked + atomic like every other cache writer
|
|
782
|
+
* Locked + atomic like every other cache writer, but through the ASYNC lock: it
|
|
783
|
+
* runs on the daemon's usage-sync tick once per peer reply (PHNX-4116), where
|
|
784
|
+
* the sync lock's `sleepSync` would freeze every other service under contention.
|
|
772
785
|
*
|
|
773
786
|
* Deliberately NOT role-gated on the receiver. "Consume only on worker/unmarked"
|
|
774
787
|
* is a SENDER-side optimization (don't waste a push on a headed peer that reads
|
|
@@ -778,7 +791,7 @@ export declare function exportClaudeUsageCacheRows(cachePath?: string): Record<s
|
|
|
778
791
|
* runs now shows a usage bar), so gating here on the receiver's own — laggier —
|
|
779
792
|
* view of its role would only reject legitimate data.
|
|
780
793
|
*/
|
|
781
|
-
export declare function ingestPeerClaudeUsageRows(rows: Record<string, CachedUsageSnapshot>, cachePath?: string): number
|
|
794
|
+
export declare function ingestPeerClaudeUsageRows(rows: Record<string, CachedUsageSnapshot>, cachePath?: string): Promise<number>;
|
|
782
795
|
/**
|
|
783
796
|
* Persist a Claude tokens/credits exhaustion (`out of usage credits` / `monthly
|
|
784
797
|
* spend limit`) from a real run. Unlike a rate/session limit this does NOT reset
|
|
@@ -24,7 +24,7 @@ import { resolveClaudeSetupToken } from '../claude-account-token.js';
|
|
|
24
24
|
import { formatBackoffRemaining, noteUsageRateLimited, usageRateLimitedUntil, } from '../usage-backoff.js';
|
|
25
25
|
import { getCacheDir } from '../state.js';
|
|
26
26
|
import { mapBounded } from '../concurrency.js';
|
|
27
|
-
import { atomicWriteFileSync, ensureLockTarget, withFileLock } from '../fs-atomic.js';
|
|
27
|
+
import { atomicWriteFileSync, ensureLockTarget, withFileLock, withFileLockAsync } from '../fs-atomic.js';
|
|
28
28
|
import { withRefreshLease } from '../refresh-coordinator.js';
|
|
29
29
|
import { padToWidth } from '../session/width.js';
|
|
30
30
|
const execFileAsync = promisify(execFile);
|
|
@@ -723,6 +723,19 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
|
|
|
723
723
|
}
|
|
724
724
|
return parts.join(' ');
|
|
725
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* The snapshot's windows that are still LIVE — reset time not yet passed. A
|
|
728
|
+
* window past its `resetsAt` has rolled over: its `usedPercent` is a reading of
|
|
729
|
+
* the PREVIOUS period, not a live throttle (PHNX-4116). Both the throttle
|
|
730
|
+
* verdict ({@link deriveUsageStatusFromSnapshot}) and the displayed
|
|
731
|
+
* `usedPercent` (`devices/harness-inventory.ts`) MUST select from this SAME set,
|
|
732
|
+
* or a maxed-then-reset account reads `available` yet shows a stale 99% next to
|
|
733
|
+
* its "ready" verdict (#3705). Callers still narrow further (dropping
|
|
734
|
+
* `sonnet_week`, the model sub-limit); this is only the reset-rollover cut.
|
|
735
|
+
*/
|
|
736
|
+
export function liveUsageWindows(snapshot, now = Date.now()) {
|
|
737
|
+
return snapshot.windows.filter((window) => !(window.resetsAt && window.resetsAt.getTime() <= now));
|
|
738
|
+
}
|
|
726
739
|
/**
|
|
727
740
|
* Derive an account's real throttle state from its live usage windows — the
|
|
728
741
|
* single signal both the `agents view` badge and run-rotation eligibility share
|
|
@@ -742,7 +755,7 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
|
|
|
742
755
|
* The model-specific `sonnet_week` sub-limit is excluded: hitting it throttles
|
|
743
756
|
* one model, not the account, so it shouldn't flip the whole row to throttled.
|
|
744
757
|
*/
|
|
745
|
-
export function deriveUsageStatusFromSnapshot(snapshot) {
|
|
758
|
+
export function deriveUsageStatusFromSnapshot(snapshot, now = Date.now()) {
|
|
746
759
|
if (!snapshot)
|
|
747
760
|
return null;
|
|
748
761
|
if (snapshot.unavailable) {
|
|
@@ -750,14 +763,17 @@ export function deriveUsageStatusFromSnapshot(snapshot) {
|
|
|
750
763
|
// clears it. session_limit blocks only until its reset time.
|
|
751
764
|
if (snapshot.unavailable.reason === 'out_of_credits')
|
|
752
765
|
return 'rate_limited';
|
|
753
|
-
if (snapshot.unavailable.resetsAt && snapshot.unavailable.resetsAt.getTime() >
|
|
766
|
+
if (snapshot.unavailable.resetsAt && snapshot.unavailable.resetsAt.getTime() > now) {
|
|
754
767
|
return 'rate_limited';
|
|
755
768
|
}
|
|
756
769
|
}
|
|
757
770
|
if (snapshot.windows.length === 0)
|
|
758
771
|
return null;
|
|
759
|
-
const
|
|
760
|
-
|
|
772
|
+
const live = liveUsageWindows(snapshot, now);
|
|
773
|
+
if (live.length === 0)
|
|
774
|
+
return 'available';
|
|
775
|
+
const blocking = live.filter((window) => window.key !== 'sonnet_week');
|
|
776
|
+
const windows = blocking.length > 0 ? blocking : live;
|
|
761
777
|
const maxUsed = Math.max(...windows.map((window) => window.usedPercent));
|
|
762
778
|
return maxUsed >= 100 ? 'rate_limited' : 'available';
|
|
763
779
|
}
|
|
@@ -1897,7 +1913,9 @@ function parseCapturedAtMs(capturedAt) {
|
|
|
1897
1913
|
* a fresher row another peer (or, on a headed receiver, the local status-line)
|
|
1898
1914
|
* already wrote. An incoming row with no `capturedAt` cannot prove it is newer, so
|
|
1899
1915
|
* it never displaces an existing timestamped row. Returns the count updated.
|
|
1900
|
-
* Locked + atomic like every other cache writer
|
|
1916
|
+
* Locked + atomic like every other cache writer, but through the ASYNC lock: it
|
|
1917
|
+
* runs on the daemon's usage-sync tick once per peer reply (PHNX-4116), where
|
|
1918
|
+
* the sync lock's `sleepSync` would freeze every other service under contention.
|
|
1901
1919
|
*
|
|
1902
1920
|
* Deliberately NOT role-gated on the receiver. "Consume only on worker/unmarked"
|
|
1903
1921
|
* is a SENDER-side optimization (don't waste a push on a headed peer that reads
|
|
@@ -1907,14 +1925,14 @@ function parseCapturedAtMs(capturedAt) {
|
|
|
1907
1925
|
* runs now shows a usage bar), so gating here on the receiver's own — laggier —
|
|
1908
1926
|
* view of its role would only reject legitimate data.
|
|
1909
1927
|
*/
|
|
1910
|
-
export function ingestPeerClaudeUsageRows(rows, cachePath = getClaudeUsageCachePath()) {
|
|
1928
|
+
export async function ingestPeerClaudeUsageRows(rows, cachePath = getClaudeUsageCachePath()) {
|
|
1911
1929
|
const incoming = Object.entries(rows).filter(([, row]) => row && Array.isArray(row.windows) && row.windows.length > 0);
|
|
1912
1930
|
if (incoming.length === 0)
|
|
1913
1931
|
return 0;
|
|
1914
1932
|
let merged = 0;
|
|
1915
1933
|
try {
|
|
1916
1934
|
ensureLockTarget(cachePath, '{}');
|
|
1917
|
-
|
|
1935
|
+
await withFileLockAsync(cachePath, () => {
|
|
1918
1936
|
const cache = readClaudeUsageCacheFile(cachePath);
|
|
1919
1937
|
for (const [key, row] of incoming) {
|
|
1920
1938
|
const prior = cache[key];
|
|
@@ -128,6 +128,8 @@ export declare function runAdd(agent: AgentId, name: string | undefined, opts: A
|
|
|
128
128
|
* Drive one `agents accounts login <harness>#<name>`: re-auth into the SAME
|
|
129
129
|
* slot (re-running add's steps 4–8), re-minting the worker credential and
|
|
130
130
|
* re-syncing. On a per-device harness (worker `none`) any box may run it —
|
|
131
|
-
* that IS how such a box logs in.
|
|
131
|
+
* that IS how such a box logs in. `--per-device` extends that to a dual-path
|
|
132
|
+
* harness (codex, grok): this box signs the account's subscription seat in for
|
|
133
|
+
* itself through the device-code flow instead of using a portable key.
|
|
132
134
|
*/
|
|
133
135
|
export declare function runLogin(agent: AgentId, name: string, opts: AddOptions, runners?: AddRunners): Promise<AddResult>;
|
package/dist/lib/accounts/add.js
CHANGED
|
@@ -73,6 +73,12 @@ export function workerApiKeyEnv(agent) {
|
|
|
73
73
|
function hasPortableWorkerKind(agent) {
|
|
74
74
|
return harnessWorkerKinds(agent).some((k) => k === 'setup-token' || k.startsWith('api-key:'));
|
|
75
75
|
}
|
|
76
|
+
function hasPerDeviceWorkerKind(agent) {
|
|
77
|
+
return harnessWorkerKinds(agent).some((k) => k.startsWith('per-device'));
|
|
78
|
+
}
|
|
79
|
+
function perDeviceRefusal(agent) {
|
|
80
|
+
return `--per-device is only valid for a harness with a per-device worker path; ${agent} is provisioned from ${workerProvisioningHint(agent)}.`;
|
|
81
|
+
}
|
|
76
82
|
/** Whether `accounts add` can drive this harness (nameable, version-scoped, a real login command). */
|
|
77
83
|
export function addSupported(agent) {
|
|
78
84
|
const cap = nativeAccountCapability(agent);
|
|
@@ -232,9 +238,8 @@ async function mintWorkerCredential(agent, account, home, opts, runners, warning
|
|
|
232
238
|
return { outcome: 'skipped', provisioning: 'per-device' };
|
|
233
239
|
}
|
|
234
240
|
if (opts.perDevice) {
|
|
235
|
-
if (!
|
|
236
|
-
throw new Error(
|
|
237
|
-
}
|
|
241
|
+
if (!hasPerDeviceWorkerKind(agent))
|
|
242
|
+
throw new Error(perDeviceRefusal(agent));
|
|
238
243
|
return { outcome: 'per-device', provisioning: 'per-device' };
|
|
239
244
|
}
|
|
240
245
|
const { seedReservedStoreKey, seedReservedAuthToken, workerCredentialStoreKey, } = await import('../auth-mint.js');
|
|
@@ -260,7 +265,7 @@ async function mintWorkerCredential(agent, account, home, opts, runners, warning
|
|
|
260
265
|
let value = opts.apiKey?.trim();
|
|
261
266
|
if (!value) {
|
|
262
267
|
if (!runners.promptApiKey) {
|
|
263
|
-
throw new Error(`${agent}'s worker credential is an ${env} API key. Pass --api-key <key> (or --no-worker-token to skip, --per-device for a
|
|
268
|
+
throw new Error(`${agent}'s worker credential is an ${env} API key. Pass --api-key <key> (or --no-worker-token to skip, or --per-device for a subscription seat when the harness has one).`);
|
|
264
269
|
}
|
|
265
270
|
const entered = await runners.promptApiKey(agent, env);
|
|
266
271
|
if (entered === null) {
|
|
@@ -290,8 +295,8 @@ export async function runAdd(agent, name, opts, runners) {
|
|
|
290
295
|
// before the lock, slot, install, or browser — no side effects on a worker.
|
|
291
296
|
assertAddAllowedOnThisDevice(agent, name);
|
|
292
297
|
const kinds = harnessWorkerKinds(agent);
|
|
293
|
-
if (opts.perDevice && !
|
|
294
|
-
throw new Error(
|
|
298
|
+
if (opts.perDevice && !hasPerDeviceWorkerKind(agent)) {
|
|
299
|
+
throw new Error(perDeviceRefusal(agent));
|
|
295
300
|
}
|
|
296
301
|
// The ambient-token refusal is checked up front (not at the mint step) so the
|
|
297
302
|
// user doesn't complete a browser login only to be refused at the end.
|
|
@@ -410,13 +415,18 @@ async function _runAddLocked(agent, name, opts, lock, runners) {
|
|
|
410
415
|
* Drive one `agents accounts login <harness>#<name>`: re-auth into the SAME
|
|
411
416
|
* slot (re-running add's steps 4–8), re-minting the worker credential and
|
|
412
417
|
* re-syncing. On a per-device harness (worker `none`) any box may run it —
|
|
413
|
-
* that IS how such a box logs in.
|
|
418
|
+
* that IS how such a box logs in. `--per-device` extends that to a dual-path
|
|
419
|
+
* harness (codex, grok): this box signs the account's subscription seat in for
|
|
420
|
+
* itself through the device-code flow instead of using a portable key.
|
|
414
421
|
*/
|
|
415
422
|
export async function runLogin(agent, name, opts, runners) {
|
|
416
423
|
// A harness with a portable worker credential authenticates workers from that
|
|
417
424
|
// credential, so the interactive re-login stays headed-only. A per-device
|
|
418
|
-
// harness (kimi)
|
|
419
|
-
|
|
425
|
+
// harness (kimi), or a per-device login of a dual-path harness, is logged in
|
|
426
|
+
// per box BY DESIGN — any role may run it.
|
|
427
|
+
if (opts.perDevice && !hasPerDeviceWorkerKind(agent))
|
|
428
|
+
throw new Error(perDeviceRefusal(agent));
|
|
429
|
+
if (hasPortableWorkerKind(agent) && !opts.perDevice)
|
|
420
430
|
assertAddAllowedOnThisDevice(agent, name);
|
|
421
431
|
if (!opts.noWorkerToken && harnessWorkerKinds(agent).includes('setup-token')) {
|
|
422
432
|
const refusal = ambientTokenRefusal(agent, opts.env ?? process.env);
|
|
@@ -437,6 +447,7 @@ export async function runLogin(agent, name, opts, runners) {
|
|
|
437
447
|
async function _runLoginLocked(agent, name, opts, lock, runners) {
|
|
438
448
|
const run = runners ?? await defaultAddRunners();
|
|
439
449
|
lock.assertHeld();
|
|
450
|
+
const perDevice = !!opts.perDevice || !hasPortableWorkerKind(agent);
|
|
440
451
|
const meta = readMeta();
|
|
441
452
|
const account = findAddAccount(agent, name, meta);
|
|
442
453
|
if (!account) {
|
|
@@ -452,7 +463,7 @@ async function _runLoginLocked(agent, name, opts, lock, runners) {
|
|
|
452
463
|
const slot = ensureSlot(agent, account.id);
|
|
453
464
|
recordSlot(account.id, {
|
|
454
465
|
...slot,
|
|
455
|
-
authMode:
|
|
466
|
+
authMode: perDevice ? 'per-device' : 'native',
|
|
456
467
|
});
|
|
457
468
|
}
|
|
458
469
|
// Pre-launch guard: refuse to launch a login into a slot signed in as a
|
|
@@ -481,8 +492,8 @@ async function _runLoginLocked(agent, name, opts, lock, runners) {
|
|
|
481
492
|
const warnings = [];
|
|
482
493
|
let outcome = 'per-device';
|
|
483
494
|
let ref;
|
|
484
|
-
let provisioning =
|
|
485
|
-
if (
|
|
495
|
+
let provisioning = perDevice ? 'per-device' : (account.provisioning ?? 'portable');
|
|
496
|
+
if (!perDevice) {
|
|
486
497
|
if (opts.apiKey && workerApiKeyEnv(agent)) {
|
|
487
498
|
// Explicit rotation of the stored API key.
|
|
488
499
|
const minted = await mintWorkerCredential(agent, account, home, opts, run, warnings);
|
|
@@ -520,7 +531,7 @@ async function _runLoginLocked(agent, name, opts, lock, runners) {
|
|
|
520
531
|
recordSlot(account.id, {
|
|
521
532
|
accountId: account.id,
|
|
522
533
|
slotDir: home,
|
|
523
|
-
authMode:
|
|
534
|
+
authMode: perDevice ? 'per-device' : 'native',
|
|
524
535
|
verdict: 'live',
|
|
525
536
|
checkedAt: new Date().toISOString(),
|
|
526
537
|
});
|
|
@@ -2,6 +2,14 @@ import type { AgentId, DeviceAccountSlot, Meta } from '../types.js';
|
|
|
2
2
|
export type { DeviceAccountSlot };
|
|
3
3
|
export declare function slotDir(harness: AgentId, accountId: string): string;
|
|
4
4
|
export declare function readSlots(meta: Pick<Meta, 'deviceAccounts'>): Record<string, DeviceAccountSlot>;
|
|
5
|
+
/**
|
|
6
|
+
* Remove slot records from THIS box's device doc (`deviceAccounts.slots`).
|
|
7
|
+
* Symmetric with {@link recordSlot}. The slot DIRECTORY is not touched — a
|
|
8
|
+
* caller that also wants the on-disk home gone removes it separately; a stale
|
|
9
|
+
* worker slot keeps its dir because `.claude/projects` holds transcripts
|
|
10
|
+
* (PHNX-4116). A no-op for an empty list or an id with no record.
|
|
11
|
+
*/
|
|
12
|
+
export declare function dropSlots(accountIds: readonly string[]): void;
|
|
5
13
|
export declare function recordSlot(accountId: string, slot: DeviceAccountSlot): void;
|
|
6
14
|
export interface SlotProjection {
|
|
7
15
|
accountId: string;
|
|
@@ -35,6 +35,23 @@ export function slotDir(harness, accountId) {
|
|
|
35
35
|
export function readSlots(meta) {
|
|
36
36
|
return { ...(meta.deviceAccounts?.slots ?? {}) };
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Remove slot records from THIS box's device doc (`deviceAccounts.slots`).
|
|
40
|
+
* Symmetric with {@link recordSlot}. The slot DIRECTORY is not touched — a
|
|
41
|
+
* caller that also wants the on-disk home gone removes it separately; a stale
|
|
42
|
+
* worker slot keeps its dir because `.claude/projects` holds transcripts
|
|
43
|
+
* (PHNX-4116). A no-op for an empty list or an id with no record.
|
|
44
|
+
*/
|
|
45
|
+
export function dropSlots(accountIds) {
|
|
46
|
+
if (accountIds.length === 0)
|
|
47
|
+
return;
|
|
48
|
+
updateMeta((current) => {
|
|
49
|
+
const slots = { ...current.deviceAccounts?.slots };
|
|
50
|
+
for (const id of accountIds)
|
|
51
|
+
delete slots[id];
|
|
52
|
+
return { ...current, deviceAccounts: { ...current.deviceAccounts, slots } };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
38
55
|
export function recordSlot(accountId, slot) {
|
|
39
56
|
if (slot.accountId !== accountId) {
|
|
40
57
|
throw new Error(`recordSlot accountId mismatch: key '${accountId}' vs slot.accountId '${slot.accountId}'.`);
|
package/dist/lib/add-dir.js
CHANGED