@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 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
- snapshot(busy?: boolean): Promise<AccountsSnapshot>;
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, loadAccounts, resolveAgentAccount, runtimeName, signInCommand, } from './accounts.js';
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
- const current = resolved.ok ? all.find((a) => a.key === resolved.record.key) : undefined;
143
- // More accounts than a frame carries (a hand-edited registry): send none
144
- // rather than a partial list the server would prune to.
145
- return { current, all: ordered.length > MAX_REPORTED_ACCOUNTS ? undefined : all };
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() {
@@ -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
- /** Every account on this machine. An unreadable registry is an empty one. */
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. */
@@ -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
- /** Every account on this machine. An unreadable registry is an empty one. */
63
- export function loadAccounts(env = process.env) {
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(readFileSync(registryPath(env), 'utf8'));
77
+ parsed = JSON.parse(content);
67
78
  }
68
79
  catch {
69
- return [];
80
+ return undefined;
70
81
  }
71
- const list = parsed?.accounts;
72
- if (!Array.isArray(list))
73
- return [];
74
- return list.filter((a) => !!a &&
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
- function saveAccounts(accounts, env) {
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
- writeFileSync(temp, `${JSON.stringify({ version: 1, accounts }, null, 2)}\n`, {
134
- mode: 0o600,
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 = findAccount(DEFAULT_ACCOUNT_LABEL, runtime, env);
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 = findAccount(DEFAULT_ACCOUNT_LABEL, runtime, env);
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
- saveAccounts([...loadAccounts(env), record], env);
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
- saveAccounts([...loadAccounts(env), record], env);
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
- saveAccounts([...accounts, record], env);
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
- saveAccounts(accounts.filter((a) => a !== found), env);
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
- const record = loadAccounts(env).find((a) => a.key === key);
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
- ...(this.accounts?.all ? { machine_accounts: this.accounts.all } : {}),
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
- this.setAccounts(await this.options.accounts(), { quiet: true });
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 */
@@ -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.1";
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.1";
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.1';
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}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",