@prohost/cli 0.12.1 → 0.12.3

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,93 @@ 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.3
8
+
9
+ - **A run stopped by the Claude usage limit says so.** It used to fail with
10
+ "agent command exited 1", the reason left in the stream the CLI never read.
11
+ It now reports `Claude usage limit reached on <machine>: You've hit your
12
+ weekly limit · resets Oct 16 at 11am (America/New_York)`, and the thread's
13
+ notice shows it.
14
+ - **That run keeps its conversation's session, and runs once.** A resumed run
15
+ that failed for any reason was taken for a session Claude Code could no
16
+ longer find, because every stream event names the session. The CLI then
17
+ dropped the conversation's session and ran the turn again from scratch, which
18
+ spent quota and, at a usage limit, failed a second time. A resume now counts
19
+ as rejected only when no session started. A run stopped by the usage limit
20
+ is not run again, and the next run after the reset continues the
21
+ conversation.
22
+ - **Any Claude Code or Codex run that exits non-zero reports its own error**
23
+ (an API error, a failed turn), not just the exit code. Only that message is
24
+ read from the stream; tool output stays on this machine.
25
+
26
+ ## 0.12.2
27
+
28
+ - **Two agents on one computer no longer delete each other's account.** Every
29
+ agent home on a computer shares one account registry, and ProhostAI keeps
30
+ exactly the accounts a status frame lists. A running `agent run` re-read that
31
+ list only every 15 minutes, so when a second agent started on a login of its
32
+ own (a Codex agent beside a Claude Code one), the first kept sending a list
33
+ without it and the server deleted it on every frame. For up to 15 minutes
34
+ the second agent showed as "Not on a listed account", on and off.
35
+ - The list is now checked against the registry for every frame. A routine
36
+ frame, and the first frame after a reconnect, re-reads it when it has
37
+ changed, so an added account is listed and a removed one is gone within 5
38
+ minutes instead of 15. Only an account new to the list is asked whether it
39
+ is signed in; one whose CLI can't answer is listed as `unknown`. The check
40
+ of every login stays on its 15-minute cadence.
41
+ - A frame sent between routine ones (a run ended on another model, capacity
42
+ crossed a band) leaves the list out when the registry has changed since it
43
+ was read, and so does one sent after a re-read that failed. A frame without
44
+ a list deletes nothing; the next routine frame carries it.
45
+ - **A registry that can't be read is no longer reported as "no accounts".**
46
+ A registry file that is unreadable, cut short or not a registry used to
47
+ read as empty, and a status frame built from it listed no accounts, which
48
+ had ProhostAI delete every account on the computer. Now the frame keeps the
49
+ account it last knew and leaves the list out until the file reads again;
50
+ the first frame after start names no account at all. A registry with no
51
+ file yet is still an empty one.
52
+ - **An account this version can't read is no longer deleted for it.** A
53
+ registry entry that is damaged, or that names a runtime or billing this
54
+ version doesn't know (one a newer CLI on the same computer wrote), used to
55
+ be skipped, and the list sent without it had ProhostAI delete that account.
56
+ While such an entry is in the registry the agent sends no list. It still
57
+ names the account it runs on, checks its sign-in and follows a switch, and
58
+ every other command sees the accounts it can read, as before. After the
59
+ entry is gone the list returns within 15 minutes.
60
+ - **And it is no longer erased from the registry either.** Every write to the
61
+ registry (the default login being recorded on first use, `account add`,
62
+ `account remove`, a login `usage` discovers) used to write back only the
63
+ accounts this version understands, so one write erased a newer CLI's
64
+ account from the computer for good. Now a write changes only the entry it
65
+ is about: entries this version can't read, top-level fields it doesn't
66
+ know and a `version` that isn't its own are written back as they were, in
67
+ place. A registry this version wrote is written exactly as before.
68
+ - **One login keeps one identity beside a newer CLI's record of it.** Where
69
+ an entry this version can't read already stands for the same login (the
70
+ same runtime and label, or the same runtime and directory), nothing is
71
+ recorded beside it:
72
+ - the agent's `default` account takes that entry's key when ProhostAI
73
+ would accept it and the entry is the agent CLI's own login, and no second
74
+ default is made. Only the key is taken: the billing reported is what the
75
+ login itself says, or `subscription`. With no key to use, the agent names
76
+ no account, says why once in its log, and still runs;
77
+ - `account add <label>` is refused when such an entry uses the label, with
78
+ a message to update the CLI, and `usage` discovery skips a login such an
79
+ entry already holds;
80
+ - an entry whose runtime or label isn't text claims no login.
81
+ - **One account the server would refuse no longer costs the whole status
82
+ frame.** ProhostAI drops a status frame if any account in it has a key that
83
+ is not a UUID or a label that is empty, longer than 64 characters or not
84
+ storable text, and the agent's own account and capacity are lost with it.
85
+ Accounts made by this CLI always pass; a hand-edited registry may not.
86
+ While the registry holds such an account, or says it is at a version other
87
+ than 1, the agent sends no list, and it never names an account that would
88
+ be refused. Runs still go out on that account.
89
+ - An agent still running 0.12.1 or older keeps sending its old list for up to
90
+ 15 minutes after another agent's account appears, and any write it makes to
91
+ the registry still erases the entries it can't read. Upgrade and restart
92
+ every agent on the computer, not only the newest one.
93
+
7
94
  ## 0.12.1
8
95
 
9
96
  - **An account the 0.10.1-row upload un-reports waits for its next report.**
package/README.md CHANGED
@@ -233,7 +233,9 @@ and gets a reply that has forgotten asking. The CLI reads the `session_id` out
233
233
  of Claude Code's JSON output, records it per conversation in
234
234
  `~/.prohost/sessions.json`, and `--resume`s it next time. Sessions don't live
235
235
  forever; when one is gone the run is retried once from scratch rather than
236
- failing.
236
+ failing. A run stopped by your Claude usage limit is not retried: it fails
237
+ with Claude Code's own message, which says when the limit resets, and the
238
+ conversation keeps its session for the next run.
237
239
 
238
240
  **It lets the agent actually do things.** Runs are invoked with
239
241
  `--dangerously-skip-permissions`, so tool calls execute without stopping to ask.
@@ -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) {
@@ -177,6 +177,28 @@ export interface ClaudeStream {
177
177
  * `rate_limit_event`. Fields the stream never mentioned are absent.
178
178
  */
179
179
  observed(): RuntimeObservation;
180
+ /**
181
+ * Why the turn failed, in Claude Code's own words: the error a `result`
182
+ * event reported, or else the API error an `assistant` event carried in
183
+ * place of an answer. `undefined` when the stream said neither.
184
+ *
185
+ * Read for a run that exited non-zero, whose stdout is otherwise never
186
+ * looked at — without it such a run reports only "exited 1". Never the raw
187
+ * stream: that carries the output of every tool call, and this text is
188
+ * echoed into a workspace thread.
189
+ */
190
+ error(): string | undefined;
191
+ /**
192
+ * Claude Code's message when the subscription's usage limit stopped the
193
+ * turn — "You've hit your weekly limit · resets Oct 16 at 11am
194
+ * (America/New_York)" — or `undefined` when something else did.
195
+ *
196
+ * Recognised by the `rate_limit` error on the `assistant` event Claude Code
197
+ * writes in place of an answer, or by a `rate_limit_event` whose status is
198
+ * `rejected` with no overage to fall back on. Running the turn again cannot
199
+ * help until the limit resets, so a caller must not retry it.
200
+ */
201
+ usageLimit(): string | undefined;
180
202
  /**
181
203
  * The turn's outcome. ``stdout`` is the complete captured output, used only
182
204
  * when the stream held no `result` event — a crashed binary, a login prompt,
@@ -218,11 +240,20 @@ export declare function readClaudeStream(): ClaudeStream;
218
240
  * and the ledger has no way to know. A stale entry must not turn into a
219
241
  * permanently broken surface, so the caller retries once without `--resume`.
220
242
  *
221
- * The observed failure is exit 1, empty stdout, and
222
- * `No conversation found with session ID: <uuid>` on stderr. Matching is
223
- * broader than that string because the wording is not ours to depend on, and it
224
- * is gated by the caller on the run having actually failed — so a *successful*
225
- * run whose reply text happens to quote a session id is never mistaken for one.
243
+ * The observed failure (Claude Code 2.1.296) is exit 1, `No conversation found
244
+ * with session ID: <uuid>` on stderr, and on stdout a lone `result` event with
245
+ * that message in `errors` — no `system`/`init` event, because no session ever
246
+ * started. Matching is broader than that string because the wording is not
247
+ * ours to depend on, and it is gated by the caller on the run having actually
248
+ * failed — so a *successful* run whose reply text happens to quote a session id
249
+ * is never mistaken for one.
250
+ *
251
+ * A stream that announced its session or produced an assistant message is a
252
+ * resume that **worked**, whatever went wrong after it. That guard is what
253
+ * keeps an ordinary failed turn from matching: every stream-json event carries
254
+ * `session_id`, so "the output mentions the session id" — which this used to
255
+ * accept — is true of every resumed run, and a resumed run that then hit a
256
+ * usage limit was retried from scratch and lost its thread's session.
226
257
  */
227
258
  export declare function looksLikeResumeFailure(output: string, sessionId: string): boolean;
228
259
  /** Absolute path of the generated MCP config. */
@@ -209,6 +209,19 @@ function resultEnvelope(stdout) {
209
209
  function text(value) {
210
210
  return typeof value === 'string' && value.length > 0 ? value : undefined;
211
211
  }
212
+ function asRecord(value) {
213
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
214
+ }
215
+ /**
216
+ * The `errors` an `error_during_execution` envelope carries instead of a
217
+ * `result` — e.g. `No conversation found with session ID: <uuid>` for a
218
+ * rejected `--resume` (Claude Code 2.1.296).
219
+ */
220
+ function envelopeErrors(parsed) {
221
+ if (!Array.isArray(parsed.errors))
222
+ return undefined;
223
+ return text(parsed.errors.filter((entry) => typeof entry === 'string' && entry.length > 0).join('; '));
224
+ }
212
225
  /**
213
226
  * Turn a `result` envelope into the harness's view of the turn.
214
227
  *
@@ -229,7 +242,7 @@ function outputFromEnvelope(parsed) {
229
242
  ok: false,
230
243
  sessionId,
231
244
  denials,
232
- error: result ?? `claude reported an error (${String(parsed.subtype ?? 'unknown')})`,
245
+ error: result ?? envelopeErrors(parsed) ?? `claude reported an error (${String(parsed.subtype ?? 'unknown')})`,
233
246
  };
234
247
  }
235
248
  if (!result) {
@@ -452,6 +465,10 @@ export function readClaudeStream() {
452
465
  let lastActivity;
453
466
  let lastText;
454
467
  let result;
468
+ /** The last `assistant` event that was an API error rather than an answer. */
469
+ let apiError;
470
+ /** When a `rate_limit_event` said the limit is hit: its reset, epoch seconds. */
471
+ let limitRejected;
455
472
  let toolEvents = [];
456
473
  // `tool_use.id` → tool name, so a result can be reported under the name of
457
474
  // the call it answers. Deleted on the result: bounded by tools in flight.
@@ -534,6 +551,13 @@ export function readClaudeStream() {
534
551
  const windows = fromClaudeRateLimitInfo(event.rate_limit_info);
535
552
  if (windows.length > 0)
536
553
  observed.quota_windows = windows;
554
+ const info = asRecord(event.rate_limit_info);
555
+ // `rejected` with overage still `allowed` is a turn that carries on, on
556
+ // overage; only a rejection with nothing to fall back on stops it.
557
+ const overage = info?.overageStatus;
558
+ if (info?.status === 'rejected' && overage !== 'allowed' && overage !== 'allowed_warning') {
559
+ limitRejected = { resetsAt: typeof info.resetsAt === 'number' ? info.resetsAt : undefined };
560
+ }
537
561
  }
538
562
  // Present on the `system`/`init` event, and repeated on most others. Taken
539
563
  // from whichever arrives first — they all name the same session.
@@ -551,6 +575,15 @@ export function readClaudeStream() {
551
575
  }
552
576
  if (event.type !== 'assistant')
553
577
  return;
578
+ // Claude Code writes an API failure — a usage limit, a billing or auth
579
+ // error — as an assistant message whose text is the explanation, marked by
580
+ // `error` (stream-json) or `isApiErrorMessage` (the transcript shape).
581
+ if (typeof event.error === 'string' || event.isApiErrorMessage === true) {
582
+ apiError = {
583
+ kind: event.apiError === 'usage_limit_reached' ? 'rate_limit' : text(event.error),
584
+ text: assistantContent(event).text?.trim() || undefined,
585
+ };
586
+ }
554
587
  collectToolUses(event);
555
588
  const { text: written, tool } = assistantContent(event);
556
589
  if (written)
@@ -562,6 +595,14 @@ export function readClaudeStream() {
562
595
  else if (written)
563
596
  lastActivity = written.replace(/\s+/g, ' ').trim().slice(0, MAX_ACTIVITY_CHARS);
564
597
  };
598
+ /** A trailing event with no newline after it is still an event. */
599
+ const flushTail = () => {
600
+ const tail = buffer.trim();
601
+ if (tail) {
602
+ handle(tail);
603
+ buffer = '';
604
+ }
605
+ };
565
606
  return {
566
607
  push(chunk) {
567
608
  buffer += chunk;
@@ -594,14 +635,30 @@ export function readClaudeStream() {
594
635
  return undefined;
595
636
  return trimmed.length > MAX_SALVAGE_CHARS ? trimmed.slice(-MAX_SALVAGE_CHARS) : trimmed;
596
637
  },
597
- finish(stdout) {
598
- // A trailing event with no newline after it is still an event — a binary
599
- // that exits without flushing a final `\n` must not lose its result.
600
- const tail = buffer.trim();
601
- if (tail) {
602
- handle(tail);
603
- buffer = '';
638
+ error: () => {
639
+ flushTail();
640
+ if (result) {
641
+ const outcome = outputFromEnvelope(result);
642
+ if (!outcome.ok)
643
+ return outcome.error;
604
644
  }
645
+ return apiError?.text;
646
+ },
647
+ usageLimit: () => {
648
+ flushTail();
649
+ if (apiError?.kind === 'rate_limit')
650
+ return apiError.text ?? 'usage limit reached';
651
+ if (!limitRejected)
652
+ return undefined;
653
+ const { resetsAt } = limitRejected;
654
+ return resetsAt === undefined
655
+ ? 'usage limit reached'
656
+ : `usage limit reached · resets ${new Date(resetsAt * 1000).toISOString()}`;
657
+ },
658
+ finish(stdout) {
659
+ // A binary that exits without flushing a final `\n` must not lose its
660
+ // result.
661
+ flushTail();
605
662
  if (result)
606
663
  return outputFromEnvelope(result);
607
664
  // No `result` event: either the command isn't emitting the stream we
@@ -621,19 +678,42 @@ export function readClaudeStream() {
621
678
  * and the ledger has no way to know. A stale entry must not turn into a
622
679
  * permanently broken surface, so the caller retries once without `--resume`.
623
680
  *
624
- * The observed failure is exit 1, empty stdout, and
625
- * `No conversation found with session ID: <uuid>` on stderr. Matching is
626
- * broader than that string because the wording is not ours to depend on, and it
627
- * is gated by the caller on the run having actually failed — so a *successful*
628
- * run whose reply text happens to quote a session id is never mistaken for one.
681
+ * The observed failure (Claude Code 2.1.296) is exit 1, `No conversation found
682
+ * with session ID: <uuid>` on stderr, and on stdout a lone `result` event with
683
+ * that message in `errors` — no `system`/`init` event, because no session ever
684
+ * started. Matching is broader than that string because the wording is not
685
+ * ours to depend on, and it is gated by the caller on the run having actually
686
+ * failed — so a *successful* run whose reply text happens to quote a session id
687
+ * is never mistaken for one.
688
+ *
689
+ * A stream that announced its session or produced an assistant message is a
690
+ * resume that **worked**, whatever went wrong after it. That guard is what
691
+ * keeps an ordinary failed turn from matching: every stream-json event carries
692
+ * `session_id`, so "the output mentions the session id" — which this used to
693
+ * accept — is true of every resumed run, and a resumed run that then hit a
694
+ * usage limit was retried from scratch and lost its thread's session.
629
695
  */
630
696
  export function looksLikeResumeFailure(output, sessionId) {
631
697
  if (!sessionId)
632
698
  return false;
633
- if (output.includes(sessionId))
634
- return true;
699
+ if (sessionStarted(output))
700
+ return false;
635
701
  return /no (conversation|session)[^\n]{0,40}found|session[^\n]{0,40}not found|--resume|resume[^\n]{0,20}(failed|invalid|expired)/i.test(output);
636
702
  }
703
+ /** Did this output include the `system`/`init` or an `assistant` event of a running session? */
704
+ function sessionStarted(output) {
705
+ for (const line of output.split('\n')) {
706
+ const trimmed = line.trim();
707
+ if (!trimmed.startsWith('{'))
708
+ continue;
709
+ const event = asObject(trimmed);
710
+ if (!event)
711
+ continue;
712
+ if ((event.type === 'system' && event.subtype === 'init') || event.type === 'assistant')
713
+ return true;
714
+ }
715
+ return false;
716
+ }
637
717
  /** Absolute path of the generated MCP config. */
638
718
  export function mcpConfigPath(env = process.env) {
639
719
  return path.join(prohostHome(env), MCP_CONFIG_FILENAME);
@@ -518,6 +518,15 @@ export type CodexOutput = {
518
518
  * the model (verified against 0.144.1, same convention as Claude Code).
519
519
  */
520
520
  export declare function parseCodexOutput(stdout: string): CodexOutput;
521
+ /**
522
+ * The failure a Codex run reported about itself — the last `turn.failed` or
523
+ * `error` event's message — or `undefined` if it reported none.
524
+ *
525
+ * For a run that exited non-zero, whose stdout is otherwise never read: without
526
+ * this such a run reports only "exited 1". Only that message, never the stream
527
+ * around it, which carries command output and would be echoed into a thread.
528
+ */
529
+ export declare function codexFailureMessage(stdout: string): string | undefined;
521
530
  /**
522
531
  * Does this look like the resume having been rejected, rather than the agent
523
532
  * having failed at its actual work?
@@ -1053,6 +1053,27 @@ export function parseCodexOutput(stdout) {
1053
1053
  : 'codex returned no output',
1054
1054
  };
1055
1055
  }
1056
+ /**
1057
+ * The failure a Codex run reported about itself — the last `turn.failed` or
1058
+ * `error` event's message — or `undefined` if it reported none.
1059
+ *
1060
+ * For a run that exited non-zero, whose stdout is otherwise never read: without
1061
+ * this such a run reports only "exited 1". Only that message, never the stream
1062
+ * around it, which carries command output and would be echoed into a thread.
1063
+ */
1064
+ export function codexFailureMessage(stdout) {
1065
+ let failure;
1066
+ for (const line of stdout.split('\n')) {
1067
+ const trimmed = line.trim();
1068
+ if (!trimmed.startsWith('{'))
1069
+ continue;
1070
+ const event = asObject(trimmed);
1071
+ if (event && (event.type === 'turn.failed' || event.type === 'error')) {
1072
+ failure = failureMessage(event) ?? failure;
1073
+ }
1074
+ }
1075
+ return failure;
1076
+ }
1056
1077
  /**
1057
1078
  * Name the tool this item was refused, or `undefined` if it wasn't refused.
1058
1079
  *
package/dist/agent/run.js CHANGED
@@ -33,7 +33,7 @@ import { HarnessAccount } from './account_runtime.js';
33
33
  import { EVENT_AGENT_CONFIG_UPDATED, machineId } from './accounts.js';
34
34
  import { completeRun, describeFailure, isIdempotencyInFlight, postReply } from './api.js';
35
35
  import { MAX_SALVAGE_CHARS, claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
36
- import { APPS_FEATURE, MCP_API_KEY_ENV_VAR, PLUGINS_DISABLED_OVERRIDE, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, extraMcpEntries, isCodexExec, isolateConfiguredServers, listMcpServers, mcpServersOverride, prohostMcpEntry, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
36
+ import { APPS_FEATURE, MCP_API_KEY_ENV_VAR, PLUGINS_DISABLED_OVERRIDE, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, extraMcpEntries, isCodexExec, isolateConfiguredServers, listMcpServers, mcpServersOverride, prohostMcpEntry, codexFailureMessage, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
37
37
  import { ToolEventQueue } from './events.js';
38
38
  import { EXTRA_MCP_FILENAME, loadExtraMcpServers } from './extras.js';
39
39
  import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
@@ -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 = '';
@@ -722,6 +723,18 @@ runEnv) {
722
723
  if (mcp.error) {
723
724
  return { ok: false, reason: 'MCP isolation could not be established', detail: mcp.error, durationMs: 0 };
724
725
  }
726
+ /**
727
+ * A turn the Claude subscription's usage limit stopped, worded for the
728
+ * thread: whose limit (this machine's login) and, in Claude Code's own
729
+ * words, when it resets. The only fix is waiting, so the reason says that
730
+ * rather than "exited 1".
731
+ */
732
+ const usageLimitFailure = (said, stderr = '') => ({
733
+ ok: false,
734
+ reason: `Claude usage limit reached on ${options.machine ?? os.hostname()}`,
735
+ detail: [said, stderr.includes(said) ? '' : stderr].filter((part) => part.length > 0).join('\n'),
736
+ durationMs: elapsed(),
737
+ });
725
738
  const failure = (result) => {
726
739
  // A killed run has no reply, but it usually has most of one. Reporting the
727
740
  // tail of what the agent was writing turns "no output for 15 minutes" into
@@ -750,7 +763,20 @@ runEnv) {
750
763
  // completion `error` is capped server-side, and of the two, what the agent
751
764
  // was saying beats the last 500 characters of its logging.
752
765
  const stderrTail = result.stderr.trim().slice(salvaged ? -200 : -500);
753
- const detail = [stderrTail, salvaged ? `unfinished answer: ${salvaged}` : '']
766
+ const limit = killed ? undefined : stream?.usageLimit();
767
+ if (limit)
768
+ return usageLimitFailure(limit, stderrTail);
769
+ // What the brain itself said went wrong — a shaped brain reports it on
770
+ // stdout, which a non-zero exit otherwise leaves unread. First, because the
771
+ // thread shows only the opening of this text.
772
+ const said = killed
773
+ ? undefined
774
+ : (stream?.error() ?? (runtime.brain === 'codex' ? codexFailureMessage(result.stdout) : undefined))?.slice(0, MAX_SALVAGE_CHARS);
775
+ const detail = [
776
+ said ?? '',
777
+ said && stderrTail.includes(said) ? '' : stderrTail,
778
+ salvaged ? `unfinished answer: ${salvaged}` : '',
779
+ ]
754
780
  .filter((part) => part.length > 0)
755
781
  .join('\n');
756
782
  return {
@@ -793,6 +819,9 @@ runEnv) {
793
819
  const resumeFailed = codex ? looksLikeCodexResumeFailure : looksLikeResumeFailure;
794
820
  if (!result.ok &&
795
821
  sessionId &&
822
+ // A usage limit fails a fresh session exactly as it failed this one, and
823
+ // the retry would cost the thread its session for nothing.
824
+ !stream?.usageLimit() &&
796
825
  resumeFailed(`${result.stderr}\n${result.stdout}`, sessionId) &&
797
826
  // Skipped rather than squeezed into a token budget: a retry with no time
798
827
  // left would be killed on the spot and reported as a timeout, which is a
@@ -810,8 +839,11 @@ runEnv) {
810
839
  // transcript is real, it holds however long the agent worked, and the next
811
840
  // delivery on that surface can resume it. Only a turn that failed on its
812
841
  // own — a crash, an unparseable answer — has no continuity worth keeping.
813
- if (!result.timedOut && !result.idleTimedOut && !result.cancelled)
842
+ // A usage limit is not the turn failing on its own either: the session is
843
+ // the thread's, and the next turn after the reset should continue it.
844
+ if (!result.timedOut && !result.idleTimedOut && !result.cancelled && !stream?.usageLimit()) {
814
845
  forgetRecordedSession();
846
+ }
815
847
  return failure(result);
816
848
  }
817
849
  // A denied tool is not an error either brain reports — the turn succeeds and
@@ -851,6 +883,9 @@ runEnv) {
851
883
  toolEvents?.push(stream.drainToolEvents());
852
884
  reportDenials(parsed.denials);
853
885
  if (!parsed.ok) {
886
+ const limit = stream?.usageLimit();
887
+ if (limit)
888
+ return usageLimitFailure(limit);
854
889
  forgetRecordedSession();
855
890
  return {
856
891
  ok: false,
@@ -1098,8 +1133,9 @@ export async function runAgentHarness(options) {
1098
1133
  ? defaultQuotaProvider(runtime.brain, options.exec, () => runtime.account?.spawnEnv() ?? {})
1099
1134
  : (options.quotaProvider ?? undefined),
1100
1135
  accounts: runtime.account
1101
- ? () => runtime.account.snapshot([...accepted.values()].some((entry) => entry.executing))
1136
+ ? (known) => runtime.account.snapshot([...accepted.values()].some((entry) => entry.executing), known)
1102
1137
  : undefined,
1138
+ accountKeys: runtime.account ? () => runtime.account.keys() : undefined,
1103
1139
  // A run in flight reports fresh numbers when it ends; don't probe beside it.
1104
1140
  isBusy: () => [...accepted.values()].some((entry) => entry.executing),
1105
1141
  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.3";
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.3";
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.3';
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.3",
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",