@prohost/cli 0.6.0 → 0.7.0

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.
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Brain accounts — which Claude Code or Codex login a paired agent runs on.
3
+ *
4
+ * A machine can hold several logins ("work", "personal"), each in its own
5
+ * config directory: `CLAUDE_CONFIG_DIR` for Claude Code, `CODEX_HOME` for
6
+ * Codex. Every agent home on the machine uses exactly one of them, named in its
7
+ * `agent.json` as `account`, and several agents may share one (and so share its
8
+ * quota). The implicit `default` account is today's `~/.claude` / `~/.codex`,
9
+ * so an agent paired before accounts existed keeps running exactly as it did.
10
+ *
11
+ * **This module never reads a credential.** Signing in is done by the operator
12
+ * running the agent CLI's own login command with the account's directory; we
13
+ * only ever ask the unmodified CLI whether it is signed in (`claude auth status`
14
+ * → `loggedIn`, `codex login status` → its exit code) and keep nothing else
15
+ * from the answer — not the email, org or account id either one prints.
16
+ *
17
+ * **Accounts never switch on their own.** An exhausted subscription makes the
18
+ * agent rest until its window resets; only an operator (here, or through the
19
+ * picker in ProhostAI, which arrives as `agent.config_updated`) moves an agent
20
+ * to another account.
21
+ *
22
+ * The registry lives in a machine-wide root shared by every agent home —
23
+ * `~/.prohost-accounts`, or `$PROHOST_ACCOUNTS_ROOT` — so `account add` once
24
+ * serves every agent on the machine.
25
+ */
26
+ import { execFile } from 'node:child_process';
27
+ import { randomUUID } from 'node:crypto';
28
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
29
+ import os from 'node:os';
30
+ import path from 'node:path';
31
+ import { loadCredentials, saveCredentials } from './credentials.js';
32
+ /** Server → CLI: an operator picked another account for this agent in ProhostAI. */
33
+ export const EVENT_AGENT_CONFIG_UPDATED = 'agent.config_updated';
34
+ /** Label of the implicit account that is the agent CLI's own default config dir. */
35
+ export const DEFAULT_ACCOUNT_LABEL = 'default';
36
+ /** Most accounts reported to the server in one frame (the server caps it too). */
37
+ export const MAX_REPORTED_ACCOUNTS = 16;
38
+ /** How long one `auth status` / `login status` probe may take. */
39
+ const AUTH_PROBE_TIMEOUT_MS = 10_000;
40
+ /** Labels are filenames and UI text: short, and nothing a shell or a path could misread. */
41
+ const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
42
+ export class AccountError extends Error {
43
+ }
44
+ export function isValidLabel(label) {
45
+ return LABEL_PATTERN.test(label);
46
+ }
47
+ /** `claude` → `claude_code`; anything else that is not `codex` is `undefined`. */
48
+ export function parseAccountRuntime(value) {
49
+ if (value === 'claude' || value === 'claude_code' || value === 'claude-code')
50
+ return 'claude_code';
51
+ if (value === 'codex')
52
+ return 'codex';
53
+ return undefined;
54
+ }
55
+ /** Machine-wide root shared by every agent home. */
56
+ export function accountsRoot(env = process.env) {
57
+ return env.PROHOST_ACCOUNTS_ROOT || path.join(env.HOME || os.homedir(), '.prohost-accounts');
58
+ }
59
+ function registryPath(env) {
60
+ return path.join(accountsRoot(env), 'accounts.json');
61
+ }
62
+ /** Every account on this machine. An unreadable registry is an empty one. */
63
+ export function loadAccounts(env = process.env) {
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(readFileSync(registryPath(env), 'utf8'));
67
+ }
68
+ catch {
69
+ return [];
70
+ }
71
+ const list = parsed?.accounts;
72
+ if (!Array.isArray(list))
73
+ return [];
74
+ return list.filter((a) => !!a &&
75
+ typeof a.key === 'string' &&
76
+ typeof a.label === 'string' &&
77
+ (a.runtime === 'claude_code' || a.runtime === 'codex') &&
78
+ (a.billing === 'subscription' || a.billing === 'api_key') &&
79
+ (a.dir === null || typeof a.dir === 'string'));
80
+ }
81
+ /**
82
+ * Run ``fn`` holding the registry lock, so two processes adding accounts (or
83
+ * two agents creating their implicit default at once) can't each write an
84
+ * array missing the other's entry. A lock older than {@link REGISTRY_LOCK_STALE_MS}
85
+ * was left by a crash and is broken.
86
+ */
87
+ function withRegistryLock(env, fn) {
88
+ const root = accountsRoot(env);
89
+ mkdirSync(root, { recursive: true, mode: 0o700 });
90
+ const lock = path.join(root, 'accounts.lock');
91
+ const deadline = Date.now() + REGISTRY_LOCK_WAIT_MS;
92
+ for (;;) {
93
+ try {
94
+ closeSync(openSync(lock, 'wx', 0o600));
95
+ break;
96
+ }
97
+ catch (err) {
98
+ if (err.code !== 'EEXIST')
99
+ throw err;
100
+ let age = 0;
101
+ try {
102
+ age = Date.now() - statSync(lock).mtimeMs;
103
+ }
104
+ catch {
105
+ continue; // released between our open and stat
106
+ }
107
+ if (age > REGISTRY_LOCK_STALE_MS) {
108
+ rmSync(lock, { force: true });
109
+ continue;
110
+ }
111
+ if (Date.now() > deadline)
112
+ throw new AccountError(`The account registry is locked (${lock}); try again.`);
113
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
114
+ }
115
+ }
116
+ try {
117
+ return fn();
118
+ }
119
+ finally {
120
+ rmSync(lock, { force: true });
121
+ }
122
+ }
123
+ const REGISTRY_LOCK_WAIT_MS = 5_000;
124
+ const REGISTRY_LOCK_STALE_MS = 30_000;
125
+ function saveAccounts(accounts, env) {
126
+ const root = accountsRoot(env);
127
+ mkdirSync(root, { recursive: true, mode: 0o700 });
128
+ chmodSync(root, 0o700);
129
+ const file = registryPath(env);
130
+ // Write-then-rename: two agents creating the implicit default at once must
131
+ // not leave a half-written registry for the next reader.
132
+ const temp = `${file}.${process.pid}.tmp`;
133
+ writeFileSync(temp, `${JSON.stringify({ version: 1, accounts }, null, 2)}\n`, {
134
+ mode: 0o600,
135
+ });
136
+ renameSync(temp, file);
137
+ }
138
+ 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
+ function readMachineId(env) {
140
+ try {
141
+ const id = JSON.parse(readFileSync(path.join(accountsRoot(env), 'machine.json'), 'utf8')).id;
142
+ return typeof id === 'string' && UUID_RE.test(id) ? id : undefined;
143
+ }
144
+ catch {
145
+ return undefined;
146
+ }
147
+ }
148
+ /**
149
+ * This computer's id: a random UUID made once and kept in the machine-wide
150
+ * accounts root, so every agent home on the machine sends the same one. It is
151
+ * what ProhostAI keys a machine's accounts by — the label (hostname by
152
+ * default) is display only, since two computers can share one. Nothing about
153
+ * the computer goes into it. `undefined` when it can't be stored, in which
154
+ * case the agent reports no accounts rather than risk another machine's.
155
+ */
156
+ export function machineId(env = process.env) {
157
+ const existing = readMachineId(env);
158
+ if (existing)
159
+ return existing;
160
+ try {
161
+ return withRegistryLock(env, () => {
162
+ const raced = readMachineId(env);
163
+ if (raced)
164
+ return raced;
165
+ const id = randomUUID();
166
+ const file = path.join(accountsRoot(env), 'machine.json');
167
+ const temp = `${file}.${process.pid}.tmp`;
168
+ writeFileSync(temp, `${JSON.stringify({ id }, null, 2)}\n`, { mode: 0o600 });
169
+ renameSync(temp, file);
170
+ return id;
171
+ });
172
+ }
173
+ catch {
174
+ return undefined;
175
+ }
176
+ }
177
+ export function findAccount(label, runtime, env = process.env) {
178
+ return loadAccounts(env).find((a) => a.label === label && a.runtime === runtime);
179
+ }
180
+ /**
181
+ * The implicit default account for a runtime, created on first use so it has
182
+ * a stable key the server can recognise across restarts.
183
+ */
184
+ export function ensureDefaultAccount(runtime, env = process.env) {
185
+ const existing = findAccount(DEFAULT_ACCOUNT_LABEL, runtime, env);
186
+ if (existing)
187
+ return existing;
188
+ return withRegistryLock(env, () => {
189
+ // Re-read under the lock: another agent may have just created it.
190
+ const again = findAccount(DEFAULT_ACCOUNT_LABEL, runtime, env);
191
+ if (again)
192
+ return again;
193
+ const record = {
194
+ key: randomUUID(),
195
+ label: DEFAULT_ACCOUNT_LABEL,
196
+ runtime,
197
+ billing: 'subscription',
198
+ dir: null,
199
+ created_at: new Date().toISOString(),
200
+ };
201
+ saveAccounts([...loadAccounts(env), record], env);
202
+ return record;
203
+ });
204
+ }
205
+ /** Create an account and its config directory. Signing in is the operator's next step. */
206
+ export function addAccount(options, env = process.env) {
207
+ const { label, runtime } = options;
208
+ if (!isValidLabel(label)) {
209
+ throw new AccountError(`"${label}" is not a usable label — up to 32 letters, digits, "-" or "_", starting with a letter or digit.`);
210
+ }
211
+ if (label === DEFAULT_ACCOUNT_LABEL) {
212
+ throw new AccountError(`"${DEFAULT_ACCOUNT_LABEL}" is the agent CLI's own login (~/.claude or ~/.codex) and always exists.`);
213
+ }
214
+ return withRegistryLock(env, () => addAccountLocked(label, runtime, options.billing, env));
215
+ }
216
+ function addAccountLocked(label, runtime, billing, env) {
217
+ if (findAccount(label, runtime, env)) {
218
+ throw new AccountError(`A ${runtimeName(runtime)} account named "${label}" already exists on this machine.`);
219
+ }
220
+ // The server keeps exactly the accounts a machine reports, and a frame
221
+ // carries at most MAX_REPORTED_ACCOUNTS (room left for both defaults).
222
+ if (loadAccounts(env).filter((a) => a.label !== DEFAULT_ACCOUNT_LABEL).length >= MAX_REPORTED_ACCOUNTS - 2) {
223
+ throw new AccountError(`This machine already has ${MAX_REPORTED_ACCOUNTS - 2} accounts, the most ProhostAI can show. Remove one first.`);
224
+ }
225
+ const dir = path.join(accountsRoot(env), 'accounts', runtime, label);
226
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
227
+ chmodSync(dir, 0o700);
228
+ const record = {
229
+ key: randomUUID(),
230
+ label,
231
+ runtime,
232
+ billing: billing ?? 'subscription',
233
+ dir,
234
+ created_at: new Date().toISOString(),
235
+ };
236
+ saveAccounts([...loadAccounts(env), record], env);
237
+ return record;
238
+ }
239
+ /**
240
+ * Forget an account and delete its config directory (the login inside it goes
241
+ * with it). The implicit default can't be removed — it is the CLI's own login.
242
+ */
243
+ export function removeAccount(label, runtime, env = process.env) {
244
+ if (label === DEFAULT_ACCOUNT_LABEL) {
245
+ throw new AccountError(`"${DEFAULT_ACCOUNT_LABEL}" is the agent CLI's own login and can't be removed here.`);
246
+ }
247
+ const record = withRegistryLock(env, () => {
248
+ const accounts = loadAccounts(env);
249
+ const found = accounts.find((a) => a.label === label && a.runtime === runtime);
250
+ if (!found)
251
+ throw new AccountError(`No ${runtimeName(runtime)} account named "${label}" on this machine.`);
252
+ saveAccounts(accounts.filter((a) => a !== found), env);
253
+ return found;
254
+ });
255
+ // Only ever a directory this module created under the root.
256
+ if (record.dir && record.dir.startsWith(path.join(accountsRoot(env), 'accounts') + path.sep)) {
257
+ rmSync(record.dir, { recursive: true, force: true });
258
+ }
259
+ return record;
260
+ }
261
+ export function runtimeName(runtime) {
262
+ return runtime === 'claude_code' ? 'Claude Code' : 'Codex';
263
+ }
264
+ /** Environment a spawn needs to run as this account. Empty for the default. */
265
+ export function accountEnv(record) {
266
+ if (!record?.dir)
267
+ return {};
268
+ return record.runtime === 'claude_code' ? { CLAUDE_CONFIG_DIR: record.dir } : { CODEX_HOME: record.dir };
269
+ }
270
+ /** POSIX single-quote a path for a shell command (pasted, or run as a hook). */
271
+ export function shellQuote(value) {
272
+ return /^[A-Za-z0-9_./~-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
273
+ }
274
+ /** The exact command that signs this account in, for the operator to run. */
275
+ export function signInCommand(record) {
276
+ if (record.runtime === 'claude_code') {
277
+ return record.dir ? `CLAUDE_CONFIG_DIR=${shellQuote(record.dir)} claude auth login` : 'claude auth login';
278
+ }
279
+ return record.dir ? `CODEX_HOME=${shellQuote(record.dir)} codex login` : 'codex login';
280
+ }
281
+ export const defaultCommandRunner = (command, args, env) => new Promise((resolve) => {
282
+ try {
283
+ execFile(command, args, { env: { ...process.env, ...env }, timeout: AUTH_PROBE_TIMEOUT_MS, encoding: 'utf8' }, (error, stdout) => {
284
+ const code = error === null ? 0 : typeof error.code === 'number' ? error.code : null;
285
+ resolve({ code, stdout: typeof stdout === 'string' ? stdout : '' });
286
+ });
287
+ }
288
+ catch {
289
+ resolve({ code: null, stdout: '' });
290
+ }
291
+ });
292
+ /**
293
+ * Ask the agent CLI whether this account is signed in.
294
+ *
295
+ * Claude Code: `claude auth status --json` — only `loggedIn` and `authMethod`
296
+ * are read (the same JSON carries the email and org, which are dropped here).
297
+ * Codex: `codex login status` — exit 0 means signed in; its one line says
298
+ * whether that is ChatGPT or an API key. A CLI that can't be run, or answers
299
+ * something we don't recognise, is `unknown` — never `expired` on a guess.
300
+ */
301
+ export async function checkAuth(record, options = {}) {
302
+ const run = options.run ?? defaultCommandRunner;
303
+ const env = accountEnv(record);
304
+ if (record.runtime === 'claude_code') {
305
+ const { stdout } = await run(options.binary ?? 'claude', ['auth', 'status', '--json'], env);
306
+ let parsed;
307
+ try {
308
+ const json = JSON.parse(stdout.trim());
309
+ parsed = { loggedIn: json.loggedIn, authMethod: json.authMethod };
310
+ }
311
+ catch {
312
+ return { state: 'unknown' };
313
+ }
314
+ if (parsed.loggedIn === false)
315
+ return { state: 'expired' };
316
+ if (parsed.loggedIn !== true)
317
+ return { state: 'unknown' };
318
+ const method = typeof parsed.authMethod === 'string' ? parsed.authMethod : '';
319
+ return {
320
+ state: 'ok',
321
+ billing: method === 'claude.ai' ? 'subscription' : method ? 'api_key' : undefined,
322
+ };
323
+ }
324
+ const { code, stdout } = await run(options.binary ?? 'codex', ['login', 'status'], env);
325
+ if (code === 0)
326
+ return { state: 'ok', billing: /api key/i.test(stdout) ? 'api_key' : 'subscription' };
327
+ // Only Codex saying so is a sign-out; any other failure (network, a
328
+ // provider error, a broken install) is unknown, not a guessed expiry.
329
+ if (/not logged in/i.test(stdout))
330
+ return { state: 'expired' };
331
+ return { state: 'unknown' };
332
+ }
333
+ /**
334
+ * Poll until the operator has finished signing in, or until the deadline.
335
+ * Resolves the last check either way.
336
+ */
337
+ export async function waitForSignIn(record, options) {
338
+ const check = options.check ?? ((r) => checkAuth(r));
339
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
340
+ const now = options.now ?? Date.now;
341
+ const deadline = now() + options.timeoutMs;
342
+ let last = await check(record);
343
+ while (last.state !== 'ok' && now() < deadline) {
344
+ await sleep(options.intervalMs ?? 2_000);
345
+ last = await check(record);
346
+ }
347
+ return last;
348
+ }
349
+ /**
350
+ * Wire block for one account. `billing` is what the login itself reported
351
+ * when it said; the value given at `account add` is only the fallback.
352
+ */
353
+ export function accountBlock(record, state, billing) {
354
+ return {
355
+ key: record.key,
356
+ label: record.label,
357
+ runtime: record.runtime,
358
+ billing: billing ?? record.billing,
359
+ auth_state: state,
360
+ };
361
+ }
362
+ /**
363
+ * The account an agent home runs on: `agent.json`'s `account`, or the default.
364
+ *
365
+ * `undefined` when the home names an account that is not on this machine —
366
+ * the caller refuses to run rather than quietly using another subscription.
367
+ */
368
+ export function resolveAgentAccount(label, runtime, env = process.env) {
369
+ if (!label || label === DEFAULT_ACCOUNT_LABEL)
370
+ return ensureDefaultAccount(runtime, env);
371
+ return findAccount(label, runtime, env);
372
+ }
373
+ /**
374
+ * Apply an account picked in ProhostAI (`agent.config_updated` or
375
+ * `auth_ok.agent_config`): find it on this machine and persist it to the agent
376
+ * home's `agent.json`, so it is used from the next run and survives a restart.
377
+ *
378
+ * An unknown key, or one belonging to another runtime, changes nothing.
379
+ */
380
+ export function applyAccountKey(key, runtime, env = process.env) {
381
+ if (typeof key !== 'string' || !key)
382
+ return { applied: false, reason: 'no account_key in the update' };
383
+ const record = loadAccounts(env).find((a) => a.key === key);
384
+ if (!record)
385
+ return { applied: false, reason: `account ${key.slice(0, 8)}… is not on this machine` };
386
+ if (record.runtime !== runtime) {
387
+ return {
388
+ applied: false,
389
+ reason: `account "${record.label}" is a ${runtimeName(record.runtime)} login; this agent runs ${runtimeName(runtime)}`,
390
+ };
391
+ }
392
+ const credentials = loadCredentials(env);
393
+ const current = credentials.account ?? DEFAULT_ACCOUNT_LABEL;
394
+ if (current === record.label)
395
+ return { applied: true, record, changed: false };
396
+ saveCredentials({ ...credentials, account: record.label }, env);
397
+ return { applied: true, record, changed: true };
398
+ }
399
+ /** Persist an account choice made on this machine (`--account`). Validates it exists. */
400
+ export function setAgentAccount(label, runtime, env = process.env) {
401
+ if (label !== DEFAULT_ACCOUNT_LABEL) {
402
+ const matches = loadAccounts(env).filter((a) => a.label === label && (!runtime || a.runtime === runtime));
403
+ if (matches.length === 0) {
404
+ throw new AccountError(`No account named "${label}" on this machine. Add it first: prohost agent account add ${label} --runtime claude|codex`);
405
+ }
406
+ }
407
+ const credentials = loadCredentials(env);
408
+ saveCredentials({ ...credentials, account: label }, env);
409
+ }
410
+ /** Whether the registry file exists at all (for `agent list`'s empty state). */
411
+ export function hasRegistry(env = process.env) {
412
+ return existsSync(registryPath(env));
413
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `prohost agent account …`, `prohost agent list` and
3
+ * `prohost agent workspace init` — flag plumbing only; the work lives in
4
+ * `accounts.ts`, `list.ts` and `workspace.ts`.
5
+ */
6
+ import type { AccountRecord, AuthCheck } from './accounts.js';
7
+ type Flags = Record<string, string | boolean>;
8
+ export interface CommandIO {
9
+ out: (line: string) => void;
10
+ err: (line: string) => void;
11
+ env?: NodeJS.ProcessEnv;
12
+ /** Sign-in check seam for tests. */
13
+ check?: (record: AccountRecord) => Promise<AuthCheck>;
14
+ sleep?: (ms: number) => Promise<void>;
15
+ }
16
+ /** `prohost agent account add|list|remove`. Returns the exit code. */
17
+ export declare function accountCommand(args: string[], flags: Flags, io?: CommandIO): Promise<number>;
18
+ /** `prohost agent list`. */
19
+ export declare function listCommand(io?: CommandIO): Promise<number>;
20
+ /** `prohost agent workspace init --developer --repo <checkout> [--workdir <dir>]`. */
21
+ export declare function workspaceCommand(args: string[], flags: Flags, io?: CommandIO): number;
22
+ export {};
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `prohost agent account …`, `prohost agent list` and
3
+ * `prohost agent workspace init` — flag plumbing only; the work lives in
4
+ * `accounts.ts`, `list.ts` and `workspace.ts`.
5
+ */
6
+ import process from 'node:process';
7
+ import { AccountError, addAccount, checkAuth, loadAccounts, parseAccountRuntime, removeAccount, runtimeName, signInCommand, waitForSignIn, } from './accounts.js';
8
+ import { ensureWorkspace } from './credentials.js';
9
+ import { findAgentHomes, renderAgentList } from './list.js';
10
+ import { WorkspaceInitError, initDeveloperWorkspace } from './workspace.js';
11
+ /** How long `account add` waits for the sign-in to land, by default. */
12
+ const DEFAULT_SIGN_IN_WAIT_SECONDS = 600;
13
+ function flagString(flags, name) {
14
+ const value = flags[name];
15
+ return typeof value === 'string' ? value : undefined;
16
+ }
17
+ const defaultIO = {
18
+ out: (line) => process.stdout.write(`${line}\n`),
19
+ err: (line) => process.stderr.write(`${line}\n`),
20
+ };
21
+ function parseBilling(value) {
22
+ if (value === undefined)
23
+ return undefined;
24
+ if (value === 'subscription' || value === 'api_key' || value === 'api-key') {
25
+ return value === 'subscription' ? 'subscription' : 'api_key';
26
+ }
27
+ return null;
28
+ }
29
+ /** `prohost agent account add|list|remove`. Returns the exit code. */
30
+ export async function accountCommand(args, flags, io = defaultIO) {
31
+ const [action, label] = args;
32
+ const env = io.env ?? process.env;
33
+ const runtimeFlag = flagString(flags, 'runtime');
34
+ const runtime = parseAccountRuntime(runtimeFlag);
35
+ if (runtimeFlag !== undefined && !runtime) {
36
+ io.err('--runtime must be claude or codex.');
37
+ return 1;
38
+ }
39
+ if (action === 'list') {
40
+ const accounts = loadAccounts(env).filter((a) => !runtime || a.runtime === runtime);
41
+ if (accounts.length === 0) {
42
+ io.out('No accounts beyond the default logins. Add one: prohost agent account add <label> --runtime claude|codex');
43
+ return 0;
44
+ }
45
+ const checks = await Promise.all(accounts.map((a) => (io.check ?? ((r) => checkAuth(r)))(a)));
46
+ accounts.forEach((a, i) => {
47
+ const state = checks[i].state === 'ok' ? 'signed in' : checks[i].state === 'expired' ? 'signed out' : 'unknown';
48
+ io.out(`${a.label} · ${runtimeName(a.runtime)} · ${a.billing} · ${state}${a.dir ? ` · ${a.dir}` : ''}`);
49
+ });
50
+ return 0;
51
+ }
52
+ if (action === 'add') {
53
+ if (!label) {
54
+ io.err('Usage: prohost agent account add <label> --runtime claude|codex [--billing subscription|api_key]');
55
+ return 1;
56
+ }
57
+ if (!runtime) {
58
+ io.err('Missing --runtime claude|codex — an account is one Claude Code or one Codex login.');
59
+ return 1;
60
+ }
61
+ const billing = parseBilling(flags.billing);
62
+ if (billing === null) {
63
+ io.err('--billing must be subscription or api_key.');
64
+ return 1;
65
+ }
66
+ let record;
67
+ try {
68
+ record = addAccount({ label, runtime, billing }, env);
69
+ }
70
+ catch (err) {
71
+ if (err instanceof AccountError) {
72
+ io.err(`✗ ${err.message}`);
73
+ return 1;
74
+ }
75
+ throw err;
76
+ }
77
+ io.out(`✓ Added ${runtimeName(runtime)} account "${label}" (${record.billing}).`);
78
+ io.out('');
79
+ io.out('Sign it in — run this in another terminal and finish the login in your browser:');
80
+ io.out(` ${signInCommand(record)}`);
81
+ io.out('');
82
+ io.out('ProhostAI never sees or stores this login; the agent CLI keeps it in that directory.');
83
+ if (flags['no-wait'] === true)
84
+ return 0;
85
+ const waitSeconds = Number(flagString(flags, 'wait-timeout') ?? DEFAULT_SIGN_IN_WAIT_SECONDS);
86
+ if (!Number.isFinite(waitSeconds) || waitSeconds < 0) {
87
+ io.err('--wait-timeout must be a number of seconds.');
88
+ return 1;
89
+ }
90
+ io.out(`Waiting for the sign-in (up to ${Math.round(waitSeconds / 60)} min; Ctrl-C to stop waiting)…`);
91
+ const result = await waitForSignIn(record, {
92
+ timeoutMs: waitSeconds * 1000,
93
+ check: io.check,
94
+ sleep: io.sleep,
95
+ });
96
+ if (result.state === 'ok') {
97
+ io.out(`✓ "${label}" is signed in.`);
98
+ io.out(`Use it: prohost agent install-daemon --exec '…' --account ${label} (or pick it in ProhostAI)`);
99
+ return 0;
100
+ }
101
+ io.err(`! "${label}" is not signed in yet. Run the command above; the account is saved either way.`);
102
+ return 1;
103
+ }
104
+ if (action === 'remove') {
105
+ if (!label) {
106
+ io.err('Usage: prohost agent account remove <label> [--runtime claude|codex]');
107
+ return 1;
108
+ }
109
+ const matches = loadAccounts(env).filter((a) => a.label === label && (!runtime || a.runtime === runtime));
110
+ if (matches.length > 1) {
111
+ io.err(`Both a Claude Code and a Codex account are named "${label}" — pass --runtime claude|codex.`);
112
+ return 1;
113
+ }
114
+ const target = matches[0];
115
+ const users = findAgentHomes({ env }).filter((a) => a.account === label);
116
+ if (target && users.length > 0 && flags.force !== true) {
117
+ io.err(`✗ "${label}" is used by ${users.map((u) => u.name ?? u.home).join(', ')}. ` +
118
+ 'Move them to another account first, or pass --force.');
119
+ return 1;
120
+ }
121
+ try {
122
+ removeAccount(label, target?.runtime ?? runtime ?? 'claude_code', env);
123
+ }
124
+ catch (err) {
125
+ if (err instanceof AccountError) {
126
+ io.err(`✗ ${err.message}`);
127
+ return 1;
128
+ }
129
+ throw err;
130
+ }
131
+ io.out(`✓ Removed "${label}" and its login directory.`);
132
+ return 0;
133
+ }
134
+ io.err('Expected: prohost agent account add|list|remove');
135
+ return 1;
136
+ }
137
+ /** `prohost agent list`. */
138
+ export async function listCommand(io = defaultIO) {
139
+ for (const line of await renderAgentList({ env: io.env }))
140
+ io.out(line);
141
+ return 0;
142
+ }
143
+ /** `prohost agent workspace init --developer --repo <checkout> [--workdir <dir>]`. */
144
+ export function workspaceCommand(args, flags, io = defaultIO) {
145
+ if (args[0] !== 'init' || flags.developer !== true) {
146
+ io.err('Usage: prohost agent workspace init --developer --repo <backend-service checkout> [--workdir <dir>]');
147
+ return 1;
148
+ }
149
+ const repo = flagString(flags, 'repo');
150
+ if (!repo) {
151
+ io.err('Missing --repo <path to a ProhostAI backend-service checkout>.');
152
+ return 1;
153
+ }
154
+ const workdir = flagString(flags, 'workdir') ?? ensureWorkspace(io.env);
155
+ try {
156
+ const result = initDeveloperWorkspace({ workdir, repo });
157
+ io.out(`✓ Developer workspace ready in ${workdir}`);
158
+ io.out(` skill ${result.skillPath}`);
159
+ io.out(` hook ${result.settingsPath}${result.hookAdded ? '' : ' (already registered)'}`);
160
+ io.out('The agent reads both when `agent run` starts it in this directory.');
161
+ return 0;
162
+ }
163
+ catch (err) {
164
+ if (err instanceof WorkspaceInitError) {
165
+ io.err(`✗ ${err.message}`);
166
+ return 1;
167
+ }
168
+ throw err;
169
+ }
170
+ }
@@ -9,6 +9,7 @@
9
9
  * The paths come from the run event itself (`reply_instructions.path` /
10
10
  * `completion.path`), which is why these take a path rather than building one.
11
11
  */
12
+ import type { ToolEvent } from './contract.js';
12
13
  export interface ApiOptions {
13
14
  baseUrl: string;
14
15
  apiKey: string;
@@ -139,3 +140,13 @@ export declare function completeRun(options: ApiOptions, path: string, outcome:
139
140
  ok: false;
140
141
  error: string;
141
142
  }, model?: string): Promise<ApiResult>;
143
+ /**
144
+ * Report a batch of tool events for a run in flight.
145
+ *
146
+ * No idempotency key: the server keys rows on `tool_id` and never moves one
147
+ * backwards, so a repeated batch is harmless by construction. Cheap-call
148
+ * deadline — a batch is a few small rows, and the run is not waiting on the
149
+ * answer. The batch is capped here as well as server-side so a caller that
150
+ * over-fills one gets it clipped rather than rejected whole.
151
+ */
152
+ export declare function postToolEvents(options: ApiOptions, path: string, events: ToolEvent[]): Promise<ApiResult>;
package/dist/agent/api.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * `completion.path`), which is why these take a path rather than building one.
11
11
  */
12
12
  import { USER_AGENT } from '../version.js';
13
- import { MAX_ERROR_CHARS, MAX_MODEL_CHARS, MAX_PROGRESS_CHARS, RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from './contract.js';
13
+ import { MAX_ERROR_CHARS, MAX_MODEL_CHARS, MAX_PROGRESS_CHARS, MAX_TOOL_EVENTS_PER_BATCH, RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from './contract.js';
14
14
  /** Deadline for the cheap calls — a heartbeat answers in milliseconds. */
15
15
  export const DEFAULT_TIMEOUT_MS = 30_000;
16
16
  /**
@@ -185,3 +185,15 @@ export async function completeRun(options, path, outcome, model) {
185
185
  return { ...result, ok: true };
186
186
  return result;
187
187
  }
188
+ /**
189
+ * Report a batch of tool events for a run in flight.
190
+ *
191
+ * No idempotency key: the server keys rows on `tool_id` and never moves one
192
+ * backwards, so a repeated batch is harmless by construction. Cheap-call
193
+ * deadline — a batch is a few small rows, and the run is not waiting on the
194
+ * answer. The batch is capped here as well as server-side so a caller that
195
+ * over-fills one gets it clipped rather than rejected whole.
196
+ */
197
+ export async function postToolEvents(options, path, events) {
198
+ return post(options, path, { events: events.slice(0, MAX_TOOL_EVENTS_PER_BATCH) });
199
+ }