@prohost/cli 0.12.1 → 0.12.2
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 +68 -0
- package/dist/agent/account_runtime.d.ts +25 -2
- package/dist/agent/account_runtime.js +63 -7
- package/dist/agent/accounts.d.ts +44 -2
- package/dist/agent/accounts.js +197 -21
- package/dist/agent/run.js +3 -1
- package/dist/agent/runtime_status.d.ts +21 -1
- package/dist/agent/runtime_status.js +34 -4
- package/dist/usage/discovery.js +11 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,74 @@ Versions follow [semver](https://semver.org/). Publishing is automated: merging
|
|
|
4
4
|
a version bump to `main` triggers `.github/workflows/npm-publish-cli.yml`, which
|
|
5
5
|
builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
|
|
6
6
|
|
|
7
|
+
## 0.12.2
|
|
8
|
+
|
|
9
|
+
- **Two agents on one computer no longer delete each other's account.** Every
|
|
10
|
+
agent home on a computer shares one account registry, and ProhostAI keeps
|
|
11
|
+
exactly the accounts a status frame lists. A running `agent run` re-read that
|
|
12
|
+
list only every 15 minutes, so when a second agent started on a login of its
|
|
13
|
+
own (a Codex agent beside a Claude Code one), the first kept sending a list
|
|
14
|
+
without it and the server deleted it on every frame. For up to 15 minutes
|
|
15
|
+
the second agent showed as "Not on a listed account", on and off.
|
|
16
|
+
- The list is now checked against the registry for every frame. A routine
|
|
17
|
+
frame, and the first frame after a reconnect, re-reads it when it has
|
|
18
|
+
changed, so an added account is listed and a removed one is gone within 5
|
|
19
|
+
minutes instead of 15. Only an account new to the list is asked whether it
|
|
20
|
+
is signed in; one whose CLI can't answer is listed as `unknown`. The check
|
|
21
|
+
of every login stays on its 15-minute cadence.
|
|
22
|
+
- A frame sent between routine ones (a run ended on another model, capacity
|
|
23
|
+
crossed a band) leaves the list out when the registry has changed since it
|
|
24
|
+
was read, and so does one sent after a re-read that failed. A frame without
|
|
25
|
+
a list deletes nothing; the next routine frame carries it.
|
|
26
|
+
- **A registry that can't be read is no longer reported as "no accounts".**
|
|
27
|
+
A registry file that is unreadable, cut short or not a registry used to
|
|
28
|
+
read as empty, and a status frame built from it listed no accounts, which
|
|
29
|
+
had ProhostAI delete every account on the computer. Now the frame keeps the
|
|
30
|
+
account it last knew and leaves the list out until the file reads again;
|
|
31
|
+
the first frame after start names no account at all. A registry with no
|
|
32
|
+
file yet is still an empty one.
|
|
33
|
+
- **An account this version can't read is no longer deleted for it.** A
|
|
34
|
+
registry entry that is damaged, or that names a runtime or billing this
|
|
35
|
+
version doesn't know (one a newer CLI on the same computer wrote), used to
|
|
36
|
+
be skipped, and the list sent without it had ProhostAI delete that account.
|
|
37
|
+
While such an entry is in the registry the agent sends no list. It still
|
|
38
|
+
names the account it runs on, checks its sign-in and follows a switch, and
|
|
39
|
+
every other command sees the accounts it can read, as before. After the
|
|
40
|
+
entry is gone the list returns within 15 minutes.
|
|
41
|
+
- **And it is no longer erased from the registry either.** Every write to the
|
|
42
|
+
registry (the default login being recorded on first use, `account add`,
|
|
43
|
+
`account remove`, a login `usage` discovers) used to write back only the
|
|
44
|
+
accounts this version understands, so one write erased a newer CLI's
|
|
45
|
+
account from the computer for good. Now a write changes only the entry it
|
|
46
|
+
is about: entries this version can't read, top-level fields it doesn't
|
|
47
|
+
know and a `version` that isn't its own are written back as they were, in
|
|
48
|
+
place. A registry this version wrote is written exactly as before.
|
|
49
|
+
- **One login keeps one identity beside a newer CLI's record of it.** Where
|
|
50
|
+
an entry this version can't read already stands for the same login (the
|
|
51
|
+
same runtime and label, or the same runtime and directory), nothing is
|
|
52
|
+
recorded beside it:
|
|
53
|
+
- the agent's `default` account takes that entry's key when ProhostAI
|
|
54
|
+
would accept it and the entry is the agent CLI's own login, and no second
|
|
55
|
+
default is made. Only the key is taken: the billing reported is what the
|
|
56
|
+
login itself says, or `subscription`. With no key to use, the agent names
|
|
57
|
+
no account, says why once in its log, and still runs;
|
|
58
|
+
- `account add <label>` is refused when such an entry uses the label, with
|
|
59
|
+
a message to update the CLI, and `usage` discovery skips a login such an
|
|
60
|
+
entry already holds;
|
|
61
|
+
- an entry whose runtime or label isn't text claims no login.
|
|
62
|
+
- **One account the server would refuse no longer costs the whole status
|
|
63
|
+
frame.** ProhostAI drops a status frame if any account in it has a key that
|
|
64
|
+
is not a UUID or a label that is empty, longer than 64 characters or not
|
|
65
|
+
storable text, and the agent's own account and capacity are lost with it.
|
|
66
|
+
Accounts made by this CLI always pass; a hand-edited registry may not.
|
|
67
|
+
While the registry holds such an account, or says it is at a version other
|
|
68
|
+
than 1, the agent sends no list, and it never names an account that would
|
|
69
|
+
be refused. Runs still go out on that account.
|
|
70
|
+
- An agent still running 0.12.1 or older keeps sending its old list for up to
|
|
71
|
+
15 minutes after another agent's account appears, and any write it makes to
|
|
72
|
+
the registry still erases the entries it can't read. Upgrade and restart
|
|
73
|
+
every agent on the computer, not only the newest one.
|
|
74
|
+
|
|
7
75
|
## 0.12.1
|
|
8
76
|
|
|
9
77
|
- **An account the 0.10.1-row upload un-reports waits for its next report.**
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `--account`, or in ProhostAI through `agent.config_updated`) takes effect on
|
|
7
7
|
* the next run without restarting or reinstalling the daemon.
|
|
8
8
|
*/
|
|
9
|
-
import type { AccountRecord, AccountRuntime, CommandRunner, ConfigUpdateOutcome } from './accounts.js';
|
|
9
|
+
import type { AccountBlock, AccountRecord, AccountRuntime, CommandRunner, ConfigUpdateOutcome } from './accounts.js';
|
|
10
10
|
import type { AccountsSnapshot } from './runtime_status.js';
|
|
11
11
|
export type AccountResolution = {
|
|
12
12
|
ok: true;
|
|
@@ -35,6 +35,9 @@ export declare class HarnessAccount {
|
|
|
35
35
|
private readonly inFlight;
|
|
36
36
|
/** `requested_at` of the newest pick applied, so a late older one is dropped. */
|
|
37
37
|
private appliedRequestedAt;
|
|
38
|
+
private readonly warn;
|
|
39
|
+
/** The last thing {@link warn} was told, so each reason is said once. */
|
|
40
|
+
private warned;
|
|
38
41
|
constructor(options: {
|
|
39
42
|
runtime: AccountRuntime;
|
|
40
43
|
/** Environment that locates `$PROHOST_HOME` and the accounts root. */
|
|
@@ -43,6 +46,8 @@ export declare class HarnessAccount {
|
|
|
43
46
|
/** Agent CLI binary used for sign-in checks. */
|
|
44
47
|
binary?: string;
|
|
45
48
|
run?: CommandRunner;
|
|
49
|
+
/** Told, once per reason, why this agent's account is not named to ProhostAI. */
|
|
50
|
+
warn?: (message: string) => void;
|
|
46
51
|
});
|
|
47
52
|
/** The label this agent home names now. */
|
|
48
53
|
label(): string;
|
|
@@ -84,8 +89,26 @@ export declare class HarnessAccount {
|
|
|
84
89
|
* credit that run's capacity to an account it isn't using. With several runs
|
|
85
90
|
* executing it is the oldest one's: the pick is not fulfilled until the last
|
|
86
91
|
* run on the previous account has ended.
|
|
92
|
+
*
|
|
93
|
+
* `known` are accounts whose sign-in state was checked recently enough to
|
|
94
|
+
* keep: they are not asked again, so a list that gained or lost an account
|
|
95
|
+
* costs one check at most, not one per account.
|
|
96
|
+
*
|
|
97
|
+
* Rejects when the registry can't be read. There is no snapshot of a
|
|
98
|
+
* registry nobody could read: "no accounts" would have the server delete
|
|
99
|
+
* every account on this machine. Checked before anything is resolved, since
|
|
100
|
+
* resolving the default account against an unreadable registry writes a new
|
|
101
|
+
* one over it.
|
|
102
|
+
*/
|
|
103
|
+
snapshot(busy?: boolean, known?: AccountBlock[]): Promise<AccountsSnapshot>;
|
|
104
|
+
private warnOnce;
|
|
105
|
+
/**
|
|
106
|
+
* Keys of every account in the machine's registry right now, or `undefined`
|
|
107
|
+
* when it can't be read or holds an entry this build doesn't understand —
|
|
108
|
+
* either way these would not be known to be all of them. A file read; no
|
|
109
|
+
* login is asked.
|
|
87
110
|
*/
|
|
88
|
-
|
|
111
|
+
keys(): string[] | undefined;
|
|
89
112
|
/** The command that signs the current account in, for a log line. */
|
|
90
113
|
signInHint(): string | undefined;
|
|
91
114
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `--account`, or in ProhostAI through `agent.config_updated`) takes effect on
|
|
7
7
|
* the next run without restarting or reinstalling the daemon.
|
|
8
8
|
*/
|
|
9
|
-
import { DEFAULT_ACCOUNT_LABEL, MAX_REPORTED_ACCOUNTS, accountBlock, accountEnv, applyAccountKey, checkAuth,
|
|
9
|
+
import { AccountError, DEFAULT_ACCOUNT_LABEL, MAX_REPORTED_ACCOUNTS, accountBlock, accountEnv, applyAccountKey, checkAuth, fitsStatusFrame, readRegistry, resolveAgentAccount, runtimeName, signInCommand, } from './accounts.js';
|
|
10
10
|
import { tryLoadCredentials } from './credentials.js';
|
|
11
11
|
export class HarnessAccount {
|
|
12
12
|
runtime;
|
|
@@ -28,12 +28,16 @@ export class HarnessAccount {
|
|
|
28
28
|
inFlight = [];
|
|
29
29
|
/** `requested_at` of the newest pick applied, so a late older one is dropped. */
|
|
30
30
|
appliedRequestedAt;
|
|
31
|
+
warn;
|
|
32
|
+
/** The last thing {@link warn} was told, so each reason is said once. */
|
|
33
|
+
warned;
|
|
31
34
|
constructor(options) {
|
|
32
35
|
this.runtime = options.runtime;
|
|
33
36
|
this.env = { ...process.env, ...options.env };
|
|
34
37
|
this.binary = options.binary;
|
|
35
38
|
this.run = options.run;
|
|
36
39
|
this.initialLabel = options.initialLabel;
|
|
40
|
+
this.warn = options.warn;
|
|
37
41
|
}
|
|
38
42
|
/** The label this agent home names now. */
|
|
39
43
|
label() {
|
|
@@ -119,18 +123,35 @@ export class HarnessAccount {
|
|
|
119
123
|
* credit that run's capacity to an account it isn't using. With several runs
|
|
120
124
|
* executing it is the oldest one's: the pick is not fulfilled until the last
|
|
121
125
|
* run on the previous account has ended.
|
|
126
|
+
*
|
|
127
|
+
* `known` are accounts whose sign-in state was checked recently enough to
|
|
128
|
+
* keep: they are not asked again, so a list that gained or lost an account
|
|
129
|
+
* costs one check at most, not one per account.
|
|
130
|
+
*
|
|
131
|
+
* Rejects when the registry can't be read. There is no snapshot of a
|
|
132
|
+
* registry nobody could read: "no accounts" would have the server delete
|
|
133
|
+
* every account on this machine. Checked before anything is resolved, since
|
|
134
|
+
* resolving the default account against an unreadable registry writes a new
|
|
135
|
+
* one over it.
|
|
122
136
|
*/
|
|
123
|
-
async snapshot(busy = false) {
|
|
137
|
+
async snapshot(busy = false, known = []) {
|
|
138
|
+
const registry = readRegistry(this.env);
|
|
139
|
+
if (!registry)
|
|
140
|
+
throw new AccountError('The account registry on this machine could not be read.');
|
|
141
|
+
const records = registry.accounts;
|
|
124
142
|
const label = busy ? (this.inFlight[0] ?? this.spawnedLabel ?? this.label()) : this.label();
|
|
125
143
|
const record = resolveAgentAccount(label, this.runtime, this.env);
|
|
126
144
|
const resolved = record ? { ok: true, record } : { ok: false, error: '' };
|
|
127
|
-
const records = loadAccounts(this.env);
|
|
128
145
|
// The current account first, so a machine with more than the cap still
|
|
129
146
|
// reports the one this agent is on.
|
|
130
147
|
const ordered = resolved.ok
|
|
131
148
|
? [resolved.record, ...records.filter((r) => r.key !== resolved.record.key)]
|
|
132
149
|
: records;
|
|
150
|
+
const checked = new Map(known.map((block) => [block.key, block]));
|
|
133
151
|
const all = await Promise.all(ordered.slice(0, MAX_REPORTED_ACCOUNTS).map(async (record) => {
|
|
152
|
+
const kept = checked.get(record.key);
|
|
153
|
+
if (kept)
|
|
154
|
+
return accountBlock(record, kept.auth_state, kept.billing);
|
|
134
155
|
// Only the login of this runtime's CLI is checked with this binary;
|
|
135
156
|
// another runtime's accounts are checked with that CLI's default name.
|
|
136
157
|
const check = await checkAuth(record, {
|
|
@@ -139,10 +160,45 @@ export class HarnessAccount {
|
|
|
139
160
|
});
|
|
140
161
|
return accountBlock(record, check.state, check.billing);
|
|
141
162
|
}));
|
|
142
|
-
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
163
|
+
// The server drops a whole frame over one account it refuses, so the
|
|
164
|
+
// account in use is named only when it would be taken. Runs still go out
|
|
165
|
+
// on it either way.
|
|
166
|
+
const named = resolved.ok && fitsStatusFrame(resolved.record);
|
|
167
|
+
const current = named ? all.find((a) => a.key === resolved.record.key) : undefined;
|
|
168
|
+
if (resolved.ok && !named) {
|
|
169
|
+
const { label: unnamed, runtime } = resolved.record;
|
|
170
|
+
// Not among the accounts this build reads: the default, held in an
|
|
171
|
+
// entry it can't read that has no key to report it under.
|
|
172
|
+
const unread = !records.some((r) => r.label === unnamed && r.runtime === runtime);
|
|
173
|
+
this.warnOnce(`this agent's ${runtimeName(runtime)} account "${unnamed}" is not named to ProhostAI: ` +
|
|
174
|
+
(unread
|
|
175
|
+
? "the account registry holds it in an entry this version of the CLI can't read, with no key to " +
|
|
176
|
+
'report it under. Runs still use it. Update @prohost/cli.'
|
|
177
|
+
: 'its entry in the account registry has a key or label ProhostAI would not accept. Runs still use it.'));
|
|
178
|
+
}
|
|
179
|
+
// More accounts than a frame carries (a hand-edited registry), an entry
|
|
180
|
+
// this build can't read (a newer CLI's account) or one the server would
|
|
181
|
+
// refuse: send none rather than a partial list the server would prune to,
|
|
182
|
+
// or a list that costs the whole frame. The account in use is still
|
|
183
|
+
// known, so it is still named, checked and followed through a switch.
|
|
184
|
+
const whole = registry.whole && ordered.length <= MAX_REPORTED_ACCOUNTS;
|
|
185
|
+
return { current, all: whole ? all : undefined };
|
|
186
|
+
}
|
|
187
|
+
warnOnce(message) {
|
|
188
|
+
if (message === this.warned)
|
|
189
|
+
return;
|
|
190
|
+
this.warned = message;
|
|
191
|
+
this.warn?.(message);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Keys of every account in the machine's registry right now, or `undefined`
|
|
195
|
+
* when it can't be read or holds an entry this build doesn't understand —
|
|
196
|
+
* either way these would not be known to be all of them. A file read; no
|
|
197
|
+
* login is asked.
|
|
198
|
+
*/
|
|
199
|
+
keys() {
|
|
200
|
+
const registry = readRegistry(this.env);
|
|
201
|
+
return registry?.whole ? registry.accounts.map((record) => record.key) : undefined;
|
|
146
202
|
}
|
|
147
203
|
/** The command that signs the current account in, for a log line. */
|
|
148
204
|
signInHint() {
|
package/dist/agent/accounts.d.ts
CHANGED
|
@@ -58,7 +58,42 @@ export declare function isValidLabel(label: string): boolean;
|
|
|
58
58
|
export declare function parseAccountRuntime(value: string | undefined): AccountRuntime | undefined;
|
|
59
59
|
/** Machine-wide root shared by every agent home. */
|
|
60
60
|
export declare function accountsRoot(env?: NodeJS.ProcessEnv): string;
|
|
61
|
-
/**
|
|
61
|
+
/** What the registry file holds, as far as this build can tell. */
|
|
62
|
+
export interface RegistryRead {
|
|
63
|
+
/** The accounts this build understands. */
|
|
64
|
+
accounts: AccountRecord[];
|
|
65
|
+
/**
|
|
66
|
+
* Whether `accounts` is every account on the machine, each one fit to send.
|
|
67
|
+
* `false` when an entry was left out — damaged, or written by a newer CLI
|
|
68
|
+
* with a runtime or billing this build doesn't know — when the file is not
|
|
69
|
+
* at {@link REGISTRY_VERSION}, or when an account fails
|
|
70
|
+
* {@link fitsStatusFrame}. The accounts are real and runs still go out on
|
|
71
|
+
* them, but they are not a list anyone should act on.
|
|
72
|
+
*/
|
|
73
|
+
whole: boolean;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Whether the server would take this account in a status frame: a UUID key,
|
|
77
|
+
* and a label of 1 to {@link MAX_FRAME_LABEL_CHARS} characters it can store.
|
|
78
|
+
* The server drops a whole frame over one field it refuses — the account in
|
|
79
|
+
* use and its capacity with it — so an account that fails this is never put
|
|
80
|
+
* in one. Accounts made here always pass; a hand-edited registry, or one
|
|
81
|
+
* another tool wrote, may not.
|
|
82
|
+
*/
|
|
83
|
+
export declare function fitsStatusFrame(record: AccountRecord): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* The registry, or `undefined` when the file is there but can't be read or
|
|
86
|
+
* isn't a registry — which says nothing about the accounts on this machine,
|
|
87
|
+
* least of all that there are none. For a caller whose answer someone else
|
|
88
|
+
* acts on: the server deletes the accounts a status frame leaves out, so that
|
|
89
|
+
* caller must know when the list is missing or short. No file yet is an empty
|
|
90
|
+
* registry.
|
|
91
|
+
*/
|
|
92
|
+
export declare function readRegistry(env?: NodeJS.ProcessEnv): RegistryRead | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* Every account on this machine that this build understands. An unreadable
|
|
95
|
+
* registry is an empty one, and an entry it can't read is skipped.
|
|
96
|
+
*/
|
|
62
97
|
export declare function loadAccounts(env?: NodeJS.ProcessEnv): AccountRecord[];
|
|
63
98
|
/**
|
|
64
99
|
* This computer's id: a random UUID made once and kept in the machine-wide
|
|
@@ -70,9 +105,16 @@ export declare function loadAccounts(env?: NodeJS.ProcessEnv): AccountRecord[];
|
|
|
70
105
|
*/
|
|
71
106
|
export declare function machineId(env?: NodeJS.ProcessEnv): string | undefined;
|
|
72
107
|
export declare function findAccount(label: string, runtime: AccountRuntime, env?: NodeJS.ProcessEnv): AccountRecord | undefined;
|
|
108
|
+
/** {@link unreadEntryFor} against the registry as it is now, for a caller about to register a login. */
|
|
109
|
+
export declare function heldByUnreadableEntry(runtime: AccountRuntime, claim: {
|
|
110
|
+
label?: string;
|
|
111
|
+
dir?: string;
|
|
112
|
+
}, env?: NodeJS.ProcessEnv): boolean;
|
|
73
113
|
/**
|
|
74
114
|
* The implicit default account for a runtime, created on first use so it has
|
|
75
|
-
* a stable key the server can recognise across restarts.
|
|
115
|
+
* a stable key the server can recognise across restarts. Where the registry
|
|
116
|
+
* already holds it in an entry this build can't read, that one is used (see
|
|
117
|
+
* {@link borrowedDefault}) and none is created.
|
|
76
118
|
*/
|
|
77
119
|
export declare function ensureDefaultAccount(runtime: AccountRuntime, env?: NodeJS.ProcessEnv): AccountRecord;
|
|
78
120
|
/** Create an account and its config directory. Signing in is the operator's next step. */
|
package/dist/agent/accounts.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { execFile } from 'node:child_process';
|
|
27
27
|
import { randomUUID } from 'node:crypto';
|
|
28
|
-
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
28
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
29
29
|
import os from 'node:os';
|
|
30
30
|
import path from 'node:path';
|
|
31
31
|
import { loadCredentials, saveCredentials } from './credentials.js';
|
|
@@ -37,6 +37,8 @@ export const DEFAULT_ACCOUNT_LABEL = 'default';
|
|
|
37
37
|
export const MAX_REPORTED_ACCOUNTS = 16;
|
|
38
38
|
/** How long one `auth status` / `login status` probe may take. */
|
|
39
39
|
const AUTH_PROBE_TIMEOUT_MS = 10_000;
|
|
40
|
+
/** The registry layout this build writes, and the only one it vouches for. */
|
|
41
|
+
const REGISTRY_VERSION = 1;
|
|
40
42
|
/** Labels are filenames and UI text: short, and nothing a shell or a path could misread. */
|
|
41
43
|
const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
|
|
42
44
|
export class AccountError extends Error {
|
|
@@ -59,25 +61,84 @@ export function accountsRoot(env = process.env) {
|
|
|
59
61
|
function registryPath(env) {
|
|
60
62
|
return path.join(accountsRoot(env), 'accounts.json');
|
|
61
63
|
}
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
+
/** `undefined` when the file is there but can't be read or isn't a registry. No file yet is an empty one. */
|
|
65
|
+
function readRawRegistry(env) {
|
|
66
|
+
let content;
|
|
67
|
+
try {
|
|
68
|
+
content = readFileSync(registryPath(env), 'utf8');
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return err.code === 'ENOENT'
|
|
72
|
+
? { fields: { version: REGISTRY_VERSION }, entries: [] }
|
|
73
|
+
: undefined;
|
|
74
|
+
}
|
|
64
75
|
let parsed;
|
|
65
76
|
try {
|
|
66
|
-
parsed = JSON.parse(
|
|
77
|
+
parsed = JSON.parse(content);
|
|
67
78
|
}
|
|
68
79
|
catch {
|
|
69
|
-
return
|
|
80
|
+
return undefined;
|
|
70
81
|
}
|
|
71
|
-
const
|
|
72
|
-
if (!Array.isArray(
|
|
73
|
-
return
|
|
74
|
-
return
|
|
82
|
+
const entries = parsed?.accounts;
|
|
83
|
+
if (!Array.isArray(entries))
|
|
84
|
+
return undefined;
|
|
85
|
+
return { fields: parsed, entries };
|
|
86
|
+
}
|
|
87
|
+
/** An entry this build understands: every field present, with a value it knows. */
|
|
88
|
+
function isAccountRecord(entry) {
|
|
89
|
+
const a = entry;
|
|
90
|
+
return (!!a &&
|
|
75
91
|
typeof a.key === 'string' &&
|
|
76
92
|
typeof a.label === 'string' &&
|
|
77
93
|
(a.runtime === 'claude_code' || a.runtime === 'codex') &&
|
|
78
94
|
(a.billing === 'subscription' || a.billing === 'api_key') &&
|
|
79
95
|
(a.dir === null || typeof a.dir === 'string'));
|
|
80
96
|
}
|
|
97
|
+
/** Longest label the server takes in a status frame, in characters. */
|
|
98
|
+
const MAX_FRAME_LABEL_CHARS = 64;
|
|
99
|
+
/** A NUL or half a surrogate pair: text the server's database cannot store. */
|
|
100
|
+
const UNSTORABLE = /[\0\p{Cs}]/u;
|
|
101
|
+
/**
|
|
102
|
+
* Whether the server would take this account in a status frame: a UUID key,
|
|
103
|
+
* and a label of 1 to {@link MAX_FRAME_LABEL_CHARS} characters it can store.
|
|
104
|
+
* The server drops a whole frame over one field it refuses — the account in
|
|
105
|
+
* use and its capacity with it — so an account that fails this is never put
|
|
106
|
+
* in one. Accounts made here always pass; a hand-edited registry, or one
|
|
107
|
+
* another tool wrote, may not.
|
|
108
|
+
*/
|
|
109
|
+
export function fitsStatusFrame(record) {
|
|
110
|
+
if (!UUID_RE.test(record.key) || UNSTORABLE.test(record.label))
|
|
111
|
+
return false;
|
|
112
|
+
const length = [...record.label].length;
|
|
113
|
+
return length >= 1 && length <= MAX_FRAME_LABEL_CHARS;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The registry, or `undefined` when the file is there but can't be read or
|
|
117
|
+
* isn't a registry — which says nothing about the accounts on this machine,
|
|
118
|
+
* least of all that there are none. For a caller whose answer someone else
|
|
119
|
+
* acts on: the server deletes the accounts a status frame leaves out, so that
|
|
120
|
+
* caller must know when the list is missing or short. No file yet is an empty
|
|
121
|
+
* registry.
|
|
122
|
+
*/
|
|
123
|
+
export function readRegistry(env = process.env) {
|
|
124
|
+
const raw = readRawRegistry(env);
|
|
125
|
+
if (!raw)
|
|
126
|
+
return undefined;
|
|
127
|
+
const accounts = raw.entries.filter(isAccountRecord);
|
|
128
|
+
return {
|
|
129
|
+
accounts,
|
|
130
|
+
whole: raw.fields.version === REGISTRY_VERSION &&
|
|
131
|
+
accounts.length === raw.entries.length &&
|
|
132
|
+
accounts.every(fitsStatusFrame),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Every account on this machine that this build understands. An unreadable
|
|
137
|
+
* registry is an empty one, and an entry it can't read is skipped.
|
|
138
|
+
*/
|
|
139
|
+
export function loadAccounts(env = process.env) {
|
|
140
|
+
return readRegistry(env)?.accounts ?? [];
|
|
141
|
+
}
|
|
81
142
|
/**
|
|
82
143
|
* Run ``fn`` holding the registry lock, so two processes adding accounts (or
|
|
83
144
|
* two agents creating their implicit default at once) can't each write an
|
|
@@ -122,7 +183,23 @@ function withRegistryLock(env, fn) {
|
|
|
122
183
|
}
|
|
123
184
|
const REGISTRY_LOCK_WAIT_MS = 5_000;
|
|
124
185
|
const REGISTRY_LOCK_STALE_MS = 30_000;
|
|
125
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Rewrite the registry with `change` applied to its entries — the one place
|
|
188
|
+
* the file is written. Call it holding the registry lock.
|
|
189
|
+
*
|
|
190
|
+
* Every agent home and every CLI version on the machine shares this file, so
|
|
191
|
+
* what this build doesn't understand is written back as it was read: entries
|
|
192
|
+
* that fail the shape check (damaged, or a newer CLI's account on a runtime
|
|
193
|
+
* this build has never heard of), top-level fields other than `accounts`, and
|
|
194
|
+
* a `version` that isn't ours. Writing only what {@link loadAccounts} returns
|
|
195
|
+
* would erase another CLI's account from the machine for good. `change` sees
|
|
196
|
+
* those entries too and must leave them be.
|
|
197
|
+
*
|
|
198
|
+
* A file that can't be read or parsed at all is an empty registry here, as it
|
|
199
|
+
* is for {@link loadAccounts}, and is replaced.
|
|
200
|
+
*/
|
|
201
|
+
function rewriteRegistry(env, change) {
|
|
202
|
+
const raw = readRawRegistry(env) ?? { fields: {}, entries: [] };
|
|
126
203
|
const root = accountsRoot(env);
|
|
127
204
|
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
128
205
|
chmodSync(root, 0o700);
|
|
@@ -130,11 +207,14 @@ function saveAccounts(accounts, env) {
|
|
|
130
207
|
// Write-then-rename: two agents creating the implicit default at once must
|
|
131
208
|
// not leave a half-written registry for the next reader.
|
|
132
209
|
const temp = `${file}.${process.pid}.tmp`;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
});
|
|
210
|
+
const registry = { version: REGISTRY_VERSION, ...raw.fields, accounts: change(raw.entries) };
|
|
211
|
+
writeFileSync(temp, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600 });
|
|
136
212
|
renameSync(temp, file);
|
|
137
213
|
}
|
|
214
|
+
/** Add one account to the registry, after everything already in it. Call it holding the lock. */
|
|
215
|
+
function appendAccount(record, env) {
|
|
216
|
+
rewriteRegistry(env, (entries) => [...entries, record]);
|
|
217
|
+
}
|
|
138
218
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
139
219
|
function readMachineId(env) {
|
|
140
220
|
try {
|
|
@@ -177,17 +257,96 @@ export function machineId(env = process.env) {
|
|
|
177
257
|
export function findAccount(label, runtime, env = process.env) {
|
|
178
258
|
return loadAccounts(env).find((a) => a.label === label && a.runtime === runtime);
|
|
179
259
|
}
|
|
260
|
+
/** Whether two paths are one directory, through symlinks where both exist. */
|
|
261
|
+
function sameDirectory(a, b) {
|
|
262
|
+
if (path.resolve(a) === path.resolve(b))
|
|
263
|
+
return true;
|
|
264
|
+
try {
|
|
265
|
+
return realpathSync(a) === realpathSync(b);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The entry this build can't read that says it is this account: the same
|
|
273
|
+
* runtime and the same label, or the same runtime and the same directory.
|
|
274
|
+
*
|
|
275
|
+
* Such an entry is a login this build knows how to run, recorded by a CLI
|
|
276
|
+
* that knows something this one doesn't (a billing value, say) — or a damaged
|
|
277
|
+
* record of one. Recording the login again beside it would give one login two
|
|
278
|
+
* identities, here and on the server. An entry whose `runtime` or `label`
|
|
279
|
+
* isn't even text says nothing about which account it is and claims none.
|
|
280
|
+
*/
|
|
281
|
+
function unreadEntryFor(entries, runtime, claim) {
|
|
282
|
+
for (const entry of entries) {
|
|
283
|
+
if (isAccountRecord(entry) || !entry || typeof entry !== 'object')
|
|
284
|
+
continue;
|
|
285
|
+
const e = entry;
|
|
286
|
+
if (e.runtime !== runtime || typeof e.label !== 'string')
|
|
287
|
+
continue;
|
|
288
|
+
if (e.label === claim.label)
|
|
289
|
+
return e;
|
|
290
|
+
if (claim.dir !== undefined && typeof e.dir === 'string' && sameDirectory(e.dir, claim.dir))
|
|
291
|
+
return e;
|
|
292
|
+
}
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
/** {@link unreadEntryFor} against the registry as it is now, for a caller about to register a login. */
|
|
296
|
+
export function heldByUnreadableEntry(runtime, claim, env = process.env) {
|
|
297
|
+
return unreadEntryFor(readRawRegistry(env)?.entries ?? [], runtime, claim) !== undefined;
|
|
298
|
+
}
|
|
299
|
+
function heldByUnreadableEntryError(what) {
|
|
300
|
+
return new AccountError(`The account registry already has ${what} in an entry this version of the CLI can't read — ` +
|
|
301
|
+
'most likely one a newer version wrote. Update @prohost/cli and try again.');
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The default account where an entry this build can't read already stands for
|
|
305
|
+
* it. One login gets one identity, so no second default is recorded beside
|
|
306
|
+
* the entry and nothing is written.
|
|
307
|
+
*
|
|
308
|
+
* Only the key is taken from the entry, and only when the server would take
|
|
309
|
+
* it and the entry is the login this build means by "default": the agent
|
|
310
|
+
* CLI's own directory (`dir: null`). Its billing is not taken — this build
|
|
311
|
+
* can't tell what it means. Otherwise the key is empty: the account has no
|
|
312
|
+
* identity here, {@link fitsStatusFrame} keeps it out of every status frame,
|
|
313
|
+
* and runs still go out on the CLI's own login.
|
|
314
|
+
*/
|
|
315
|
+
function borrowedDefault(entry, runtime) {
|
|
316
|
+
const record = {
|
|
317
|
+
key: typeof entry.key === 'string' ? entry.key : '',
|
|
318
|
+
label: DEFAULT_ACCOUNT_LABEL,
|
|
319
|
+
runtime,
|
|
320
|
+
billing: 'subscription',
|
|
321
|
+
dir: null,
|
|
322
|
+
created_at: '',
|
|
323
|
+
};
|
|
324
|
+
return entry.dir === null && fitsStatusFrame(record) ? record : { ...record, key: '' };
|
|
325
|
+
}
|
|
326
|
+
/** This runtime's default account as the registry holds it: read, borrowed, or not there yet. */
|
|
327
|
+
function defaultAccount(runtime, env) {
|
|
328
|
+
const entries = readRawRegistry(env)?.entries ?? [];
|
|
329
|
+
const known = entries
|
|
330
|
+
.filter(isAccountRecord)
|
|
331
|
+
.find((a) => a.label === DEFAULT_ACCOUNT_LABEL && a.runtime === runtime);
|
|
332
|
+
if (known)
|
|
333
|
+
return known;
|
|
334
|
+
const unread = unreadEntryFor(entries, runtime, { label: DEFAULT_ACCOUNT_LABEL });
|
|
335
|
+
return unread && borrowedDefault(unread, runtime);
|
|
336
|
+
}
|
|
180
337
|
/**
|
|
181
338
|
* The implicit default account for a runtime, created on first use so it has
|
|
182
|
-
* a stable key the server can recognise across restarts.
|
|
339
|
+
* a stable key the server can recognise across restarts. Where the registry
|
|
340
|
+
* already holds it in an entry this build can't read, that one is used (see
|
|
341
|
+
* {@link borrowedDefault}) and none is created.
|
|
183
342
|
*/
|
|
184
343
|
export function ensureDefaultAccount(runtime, env = process.env) {
|
|
185
|
-
const existing =
|
|
344
|
+
const existing = defaultAccount(runtime, env);
|
|
186
345
|
if (existing)
|
|
187
346
|
return existing;
|
|
188
347
|
return withRegistryLock(env, () => {
|
|
189
348
|
// Re-read under the lock: another agent may have just created it.
|
|
190
|
-
const again =
|
|
349
|
+
const again = defaultAccount(runtime, env);
|
|
191
350
|
if (again)
|
|
192
351
|
return again;
|
|
193
352
|
const record = {
|
|
@@ -198,7 +357,7 @@ export function ensureDefaultAccount(runtime, env = process.env) {
|
|
|
198
357
|
dir: null,
|
|
199
358
|
created_at: new Date().toISOString(),
|
|
200
359
|
};
|
|
201
|
-
|
|
360
|
+
appendAccount(record, env);
|
|
202
361
|
return record;
|
|
203
362
|
});
|
|
204
363
|
}
|
|
@@ -217,6 +376,9 @@ function addAccountLocked(label, runtime, billing, env) {
|
|
|
217
376
|
if (findAccount(label, runtime, env)) {
|
|
218
377
|
throw new AccountError(`A ${runtimeName(runtime)} account named "${label}" already exists on this machine.`);
|
|
219
378
|
}
|
|
379
|
+
if (heldByUnreadableEntry(runtime, { label }, env)) {
|
|
380
|
+
throw heldByUnreadableEntryError(`a ${runtimeName(runtime)} account named "${label}"`);
|
|
381
|
+
}
|
|
220
382
|
// The server keeps exactly the accounts a machine reports, and a frame
|
|
221
383
|
// carries at most MAX_REPORTED_ACCOUNTS (room left for both defaults).
|
|
222
384
|
if (loadAccounts(env).filter((a) => a.label !== DEFAULT_ACCOUNT_LABEL).length >= MAX_REPORTED_ACCOUNTS - 2) {
|
|
@@ -233,7 +395,7 @@ function addAccountLocked(label, runtime, billing, env) {
|
|
|
233
395
|
dir,
|
|
234
396
|
created_at: new Date().toISOString(),
|
|
235
397
|
};
|
|
236
|
-
|
|
398
|
+
appendAccount(record, env);
|
|
237
399
|
return record;
|
|
238
400
|
}
|
|
239
401
|
/**
|
|
@@ -253,9 +415,15 @@ export function registerAccountDir(options, env = process.env) {
|
|
|
253
415
|
const same = accounts.find((a) => a.runtime === runtime && a.dir !== null && path.resolve(a.dir) === dir);
|
|
254
416
|
if (same)
|
|
255
417
|
return same;
|
|
418
|
+
if (heldByUnreadableEntry(runtime, { dir }, env)) {
|
|
419
|
+
throw heldByUnreadableEntryError(`a ${runtimeName(runtime)} account for ${dir}`);
|
|
420
|
+
}
|
|
256
421
|
if (findAccount(label, runtime, env)) {
|
|
257
422
|
throw new AccountError(`A ${runtimeName(runtime)} account named "${label}" already exists on this machine.`);
|
|
258
423
|
}
|
|
424
|
+
if (heldByUnreadableEntry(runtime, { label }, env)) {
|
|
425
|
+
throw heldByUnreadableEntryError(`a ${runtimeName(runtime)} account named "${label}"`);
|
|
426
|
+
}
|
|
259
427
|
if (accounts.filter((a) => a.label !== DEFAULT_ACCOUNT_LABEL).length >= MAX_REPORTED_ACCOUNTS - 2) {
|
|
260
428
|
throw new AccountError(`This machine already has ${MAX_REPORTED_ACCOUNTS - 2} accounts, the most ProhostAI can show. Remove one first.`);
|
|
261
429
|
}
|
|
@@ -267,7 +435,7 @@ export function registerAccountDir(options, env = process.env) {
|
|
|
267
435
|
dir,
|
|
268
436
|
created_at: new Date().toISOString(),
|
|
269
437
|
};
|
|
270
|
-
|
|
438
|
+
appendAccount(record, env);
|
|
271
439
|
return record;
|
|
272
440
|
});
|
|
273
441
|
}
|
|
@@ -290,7 +458,12 @@ export function removeAccount(label, runtime, env = process.env) {
|
|
|
290
458
|
const found = accounts.find((a) => a.label === label && a.runtime === runtime);
|
|
291
459
|
if (!found)
|
|
292
460
|
throw new AccountError(`No ${runtimeName(runtime)} account named "${label}" on this machine.`);
|
|
293
|
-
|
|
461
|
+
rewriteRegistry(env, (entries) => {
|
|
462
|
+
// Only the entry asked for: the first one this build reads as that
|
|
463
|
+
// account, which is the one `found` came from.
|
|
464
|
+
const index = entries.findIndex((e) => isAccountRecord(e) && e.label === label && e.runtime === runtime);
|
|
465
|
+
return entries.filter((_, i) => i !== index);
|
|
466
|
+
});
|
|
294
467
|
return found;
|
|
295
468
|
});
|
|
296
469
|
// Only ever a directory this module created under the root.
|
|
@@ -421,7 +594,10 @@ export function resolveAgentAccount(label, runtime, env = process.env) {
|
|
|
421
594
|
export function applyAccountKey(key, runtime, env = process.env) {
|
|
422
595
|
if (typeof key !== 'string' || !key)
|
|
423
596
|
return { applied: false, reason: 'no account_key in the update' };
|
|
424
|
-
|
|
597
|
+
// The default this agent reports under a key borrowed from an entry this
|
|
598
|
+
// build can't read is one an operator can pick, like any other it reports.
|
|
599
|
+
const borrowed = defaultAccount(runtime, env);
|
|
600
|
+
const record = loadAccounts(env).find((a) => a.key === key) ?? (borrowed?.key === key ? borrowed : undefined);
|
|
425
601
|
if (!record)
|
|
426
602
|
return { applied: false, reason: `account ${key.slice(0, 8)}… is not on this machine` };
|
|
427
603
|
if (record.runtime !== runtime) {
|
package/dist/agent/run.js
CHANGED
|
@@ -293,6 +293,7 @@ function buildRuntime(options, log) {
|
|
|
293
293
|
initialLabel: options.credentials.account,
|
|
294
294
|
binary: brain === 'claude' ? (options.exec.trim().split(/\s+/)[0] ?? 'claude') : codexBinary(options.exec),
|
|
295
295
|
run: options.accountCommandRunner,
|
|
296
|
+
warn: (message) => log(`${timestamp()} ! ${message}`),
|
|
296
297
|
});
|
|
297
298
|
const mcpUrl = options.credentials.mcp_url;
|
|
298
299
|
let announced = '';
|
|
@@ -1098,8 +1099,9 @@ export async function runAgentHarness(options) {
|
|
|
1098
1099
|
? defaultQuotaProvider(runtime.brain, options.exec, () => runtime.account?.spawnEnv() ?? {})
|
|
1099
1100
|
: (options.quotaProvider ?? undefined),
|
|
1100
1101
|
accounts: runtime.account
|
|
1101
|
-
? () => runtime.account.snapshot([...accepted.values()].some((entry) => entry.executing))
|
|
1102
|
+
? (known) => runtime.account.snapshot([...accepted.values()].some((entry) => entry.executing), known)
|
|
1102
1103
|
: undefined,
|
|
1104
|
+
accountKeys: runtime.account ? () => runtime.account.keys() : undefined,
|
|
1103
1105
|
// A run in flight reports fresh numbers when it ends; don't probe beside it.
|
|
1104
1106
|
isBusy: () => [...accepted.values()].some((entry) => entry.executing),
|
|
1105
1107
|
log,
|
|
@@ -190,8 +190,20 @@ export interface RuntimeStatusReporterOptions {
|
|
|
190
190
|
* Accounts on this machine and which one this agent uses. Re-read on the
|
|
191
191
|
* same cadence as the idle quota probe (it asks each login whether it is
|
|
192
192
|
* signed in), and straight away after {@link RuntimeStatusReporter.accountsChanged}.
|
|
193
|
+
* Also re-read on the first tick after {@link accountKeys} shows the list
|
|
194
|
+
* moved, with `known`: the accounts whose sign-in state is recent enough to
|
|
195
|
+
* keep, so only the ones new to the list are asked.
|
|
193
196
|
*/
|
|
194
|
-
accounts?: () => Promise<AccountsSnapshot | undefined>;
|
|
197
|
+
accounts?: (known?: AccountBlock[]) => Promise<AccountsSnapshot | undefined>;
|
|
198
|
+
/**
|
|
199
|
+
* Keys of every account on this machine right now — one local file read,
|
|
200
|
+
* done for every frame. Every agent home on the machine shares the registry
|
|
201
|
+
* and the server deletes whatever a frame's list leaves out, so a list from
|
|
202
|
+
* before another home added its account would delete the account that home
|
|
203
|
+
* runs on. `undefined` when the registry can't be read, or not all of it:
|
|
204
|
+
* no list is sent until it can.
|
|
205
|
+
*/
|
|
206
|
+
accountKeys?: () => string[] | undefined;
|
|
195
207
|
/** A run is executing — it will bring fresh numbers, so don't probe beside it. */
|
|
196
208
|
isBusy?: () => boolean;
|
|
197
209
|
now?: () => number;
|
|
@@ -222,6 +234,7 @@ export declare class RuntimeStatusReporter {
|
|
|
222
234
|
private refreshing;
|
|
223
235
|
private accountGeneration;
|
|
224
236
|
private accounts;
|
|
237
|
+
/** When every login was last asked whether it is signed in. */
|
|
225
238
|
private accountsObservedAt;
|
|
226
239
|
constructor(options: RuntimeStatusReporterOptions);
|
|
227
240
|
/** Merge what a source learned. Absent fields keep their previous value. */
|
|
@@ -247,6 +260,13 @@ export declare class RuntimeStatusReporter {
|
|
|
247
260
|
detach(): void;
|
|
248
261
|
/** The frame as it would be sent now. */
|
|
249
262
|
frame(): Record<string, unknown>;
|
|
263
|
+
/**
|
|
264
|
+
* Whether the machine's registry no longer holds exactly the accounts the
|
|
265
|
+
* snapshot lists: another agent home started on a login of its own, or an
|
|
266
|
+
* operator added or removed one. A registry that can't be read counts as
|
|
267
|
+
* moved — it vouches for no list at all.
|
|
268
|
+
*/
|
|
269
|
+
private accountsMoved;
|
|
250
270
|
/** Routine tick: refresh idle quota if it is stale, send, and re-arm. */
|
|
251
271
|
private tick;
|
|
252
272
|
private refresh;
|
|
@@ -499,6 +499,7 @@ export class RuntimeStatusReporter {
|
|
|
499
499
|
refreshing;
|
|
500
500
|
accountGeneration = 0;
|
|
501
501
|
accounts;
|
|
502
|
+
/** When every login was last asked whether it is signed in. */
|
|
502
503
|
accountsObservedAt;
|
|
503
504
|
constructor(options) {
|
|
504
505
|
this.options = options;
|
|
@@ -537,7 +538,6 @@ export class RuntimeStatusReporter {
|
|
|
537
538
|
setAccounts(snapshot, options = {}) {
|
|
538
539
|
const before = this.accounts;
|
|
539
540
|
this.accounts = snapshot;
|
|
540
|
-
this.accountsObservedAt = this.now();
|
|
541
541
|
const switched = before?.current?.key !== snapshot?.current?.key;
|
|
542
542
|
if (switched) {
|
|
543
543
|
this.accountGeneration += 1;
|
|
@@ -597,9 +597,34 @@ export class RuntimeStatusReporter {
|
|
|
597
597
|
observed_at: new Date(nowMs).toISOString(),
|
|
598
598
|
// Optional additions: an older server ignores keys it doesn't know.
|
|
599
599
|
...(this.accounts?.current ? { account: this.accounts.current } : {}),
|
|
600
|
-
|
|
600
|
+
// Never a list the registry has moved on from — the server would delete
|
|
601
|
+
// what it leaves out. Left out, nothing is deleted; the next tick re-reads it.
|
|
602
|
+
...(this.accounts?.all && !this.accountsMoved() ? { machine_accounts: this.accounts.all } : {}),
|
|
601
603
|
};
|
|
602
604
|
}
|
|
605
|
+
/**
|
|
606
|
+
* Whether the machine's registry no longer holds exactly the accounts the
|
|
607
|
+
* snapshot lists: another agent home started on a login of its own, or an
|
|
608
|
+
* operator added or removed one. A registry that can't be read counts as
|
|
609
|
+
* moved — it vouches for no list at all.
|
|
610
|
+
*/
|
|
611
|
+
accountsMoved() {
|
|
612
|
+
const listed = this.accounts?.all;
|
|
613
|
+
if (!listed || !this.options.accountKeys)
|
|
614
|
+
return false;
|
|
615
|
+
let registry;
|
|
616
|
+
try {
|
|
617
|
+
registry = this.options.accountKeys();
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
registry = undefined;
|
|
621
|
+
}
|
|
622
|
+
if (!registry)
|
|
623
|
+
return true;
|
|
624
|
+
const keys = new Set(registry);
|
|
625
|
+
const known = new Set(listed.map((account) => account.key));
|
|
626
|
+
return keys.size !== known.size || [...keys].some((key) => !known.has(key));
|
|
627
|
+
}
|
|
603
628
|
/** Routine tick: refresh idle quota if it is stale, send, and re-arm. */
|
|
604
629
|
async tick() {
|
|
605
630
|
if (!this.send)
|
|
@@ -607,11 +632,16 @@ export class RuntimeStatusReporter {
|
|
|
607
632
|
const stale = this.quotaObservedAt === undefined || this.now() - this.quotaObservedAt >= QUOTA_REFRESH_AFTER_MS;
|
|
608
633
|
const sentBefore = this.lastSentAt;
|
|
609
634
|
const accountsStale = this.accountsObservedAt === undefined || this.now() - this.accountsObservedAt >= QUOTA_REFRESH_AFTER_MS;
|
|
610
|
-
if (accountsStale && this.options.accounts) {
|
|
635
|
+
if ((accountsStale || this.accountsMoved()) && this.options.accounts) {
|
|
611
636
|
// Before the quota probe: after a switch it must ask the new login.
|
|
612
637
|
try {
|
|
613
638
|
// Quiet: the tick sends one frame once the quota probe below is in.
|
|
614
|
-
|
|
639
|
+
// A list that only moved keeps the sign-in states it has, and the
|
|
640
|
+
// check every login is due stays on its own clock.
|
|
641
|
+
const known = accountsStale ? undefined : this.accounts?.all;
|
|
642
|
+
this.setAccounts(await this.options.accounts(known), { quiet: true });
|
|
643
|
+
if (accountsStale)
|
|
644
|
+
this.accountsObservedAt = this.now();
|
|
615
645
|
}
|
|
616
646
|
catch {
|
|
617
647
|
/* fail soft — keep what we had */
|
package/dist/usage/discovery.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import path from 'node:path';
|
|
19
|
-
import { AccountError, DEFAULT_ACCOUNT_LABEL, MAX_REPORTED_ACCOUNTS, ensureDefaultAccount, isValidLabel, loadAccounts, registerAccountDir, } from '../agent/accounts.js';
|
|
19
|
+
import { AccountError, DEFAULT_ACCOUNT_LABEL, MAX_REPORTED_ACCOUNTS, ensureDefaultAccount, heldByUnreadableEntry, isValidLabel, loadAccounts, registerAccountDir, } from '../agent/accounts.js';
|
|
20
20
|
/** Entries whose presence says "this is a Claude Code config directory". None of them hold a login. */
|
|
21
21
|
const CLAUDE_MARKERS = ['projects', 'settings.json', '.claude.json', 'history.jsonl', 'todos', 'statsig', 'shell-snapshots'];
|
|
22
22
|
/** Same for Codex. `auth.json` is deliberately not one of them. */
|
|
@@ -153,6 +153,16 @@ export function discoverAccounts(options = {}, env = process.env) {
|
|
|
153
153
|
result.known.push({ ...found, label: existing.label, record: existing });
|
|
154
154
|
continue;
|
|
155
155
|
}
|
|
156
|
+
// Already recorded, by a CLI that knows something this one doesn't:
|
|
157
|
+
// registering it again would give one login two identities.
|
|
158
|
+
const claim = found.isDefault ? { label: DEFAULT_ACCOUNT_LABEL } : { dir: found.dir };
|
|
159
|
+
if (heldByUnreadableEntry(found.runtime, claim, env)) {
|
|
160
|
+
result.skipped.push({
|
|
161
|
+
...found,
|
|
162
|
+
reason: "already in the account registry, in an entry this version of the CLI can't read (update @prohost/cli)",
|
|
163
|
+
});
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
156
166
|
if (found.isDefault) {
|
|
157
167
|
if (options.dryRun)
|
|
158
168
|
result.added.push(found);
|
package/dist/version.d.ts
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export declare const CLI_VERSION = "0.12.
|
|
10
|
+
export declare const CLI_VERSION = "0.12.2";
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
|
-
export declare const USER_AGENT = "prohost-cli/0.12.
|
|
12
|
+
export declare const USER_AGENT = "prohost-cli/0.12.2";
|
package/dist/version.js
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export const CLI_VERSION = '0.12.
|
|
10
|
+
export const CLI_VERSION = '0.12.2';
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
12
|
export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
|