@prohost/cli 0.6.0 → 0.8.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,91 @@
1
+ /**
2
+ * One `agent run` per agent home.
3
+ *
4
+ * Two harnesses on the same `$PROHOST_HOME` share one credential, one run
5
+ * ledger and one session store, and both receive every run — so both execute
6
+ * it. That happens easily: a daemon is installed and the operator also starts
7
+ * `agent run` in a terminal to watch it. `$PROHOST_HOME/run.lock` makes the
8
+ * second one refuse to start, with the pid it lost to.
9
+ *
10
+ * Created with `O_EXCL`, holding the owner's pid and start time. A lock whose
11
+ * pid is no longer alive was left by a crash (or `kill -9`) and is taken over.
12
+ */
13
+ import { openSync, closeSync, readFileSync, rmSync, writeSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { prohostHome } from './credentials.js';
16
+ export function runLockPath(env = process.env) {
17
+ return path.join(prohostHome(env), 'run.lock');
18
+ }
19
+ /** Whether a pid names a live process. EPERM means it exists but isn't ours. */
20
+ export function pidAlive(pid) {
21
+ if (!Number.isInteger(pid) || pid <= 0)
22
+ return false;
23
+ try {
24
+ process.kill(pid, 0);
25
+ return true;
26
+ }
27
+ catch (err) {
28
+ return err.code === 'EPERM';
29
+ }
30
+ }
31
+ /** The current holder, if the lock file exists and is readable. */
32
+ export function readRunLock(env = process.env) {
33
+ try {
34
+ const parsed = JSON.parse(readFileSync(runLockPath(env), 'utf8'));
35
+ if (typeof parsed.pid !== 'number')
36
+ return undefined;
37
+ return { pid: parsed.pid, started_at: String(parsed.started_at ?? '') };
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ /**
44
+ * Take the home's run lock, or report who holds it.
45
+ *
46
+ * @param isAlive Liveness check seam for tests.
47
+ */
48
+ export function acquireRunLock(env = process.env, options = {}) {
49
+ const file = runLockPath(env);
50
+ const pid = options.pid ?? process.pid;
51
+ const isAlive = options.isAlive ?? pidAlive;
52
+ // Two tries: the second only after removing a lock whose owner is dead.
53
+ for (let attempt = 0; attempt < 2; attempt += 1) {
54
+ let fd;
55
+ try {
56
+ fd = openSync(file, 'wx', 0o600);
57
+ }
58
+ catch (err) {
59
+ if (err.code !== 'EEXIST')
60
+ throw err;
61
+ const holder = readRunLock(env);
62
+ if (holder && holder.pid !== pid && isAlive(holder.pid))
63
+ return { ok: false, holder };
64
+ // Stale (dead owner, or unreadable half-write): clear it and retry once.
65
+ rmSync(file, { force: true });
66
+ continue;
67
+ }
68
+ const holder = { pid, started_at: new Date().toISOString() };
69
+ try {
70
+ writeSync(fd, `${JSON.stringify(holder)}\n`);
71
+ }
72
+ finally {
73
+ closeSync(fd);
74
+ }
75
+ let released = false;
76
+ return {
77
+ ok: true,
78
+ release: () => {
79
+ if (released)
80
+ return;
81
+ released = true;
82
+ // Only remove it if it is still ours — a takeover after a false
83
+ // "dead" reading must not be undone by the original owner exiting.
84
+ if (readRunLock(env)?.pid === pid)
85
+ rmSync(file, { force: true });
86
+ },
87
+ };
88
+ }
89
+ const holder = readRunLock(env);
90
+ return { ok: false, holder: holder ?? { pid: 0, started_at: '' } };
91
+ }
@@ -6,6 +6,7 @@
6
6
  * a shell script that greps it. The reply contract is stated explicitly so an
7
7
  * agent that would otherwise narrate its reasoning returns something sendable.
8
8
  */
9
+ import { NO_REPLY_TOKEN } from './contract.js';
9
10
  /**
10
11
  * Say the run's budget in a unit a reader thinks in.
11
12
  *
@@ -129,15 +130,23 @@ export function buildPrompt(run, context) {
129
130
  const name = run.agent_name ?? context.agentName;
130
131
  const canReply = Boolean(run.reply_surface !== 'none' && run.reply_path && run.reply_body_key);
131
132
  const title = run.agent_title ? ` (${run.agent_title})` : '';
133
+ // Woken as a bystander: a message landed in a thread the agent belongs to,
134
+ // addressed to somebody else. Telling it "someone is waiting on a reply"
135
+ // here is what made a paired agent post a long, unrequested summary onto a
136
+ // message that @-mentioned three people — so the opening says the opposite.
137
+ const bystander = canReply && run.reply_expected === false;
132
138
  const lines = [
133
139
  `You are "${name}"${title}, an AI teammate in a ProhostAI workspace.`,
134
- canReply && run.reply_surface === 'conversation'
135
- ? 'You were mentioned in a conversation and someone is waiting on a reply.'
136
- : run.reply_surface === 'none'
137
- ? 'You have been given work to do. There is nowhere to reply.'
138
- : canReply
139
- ? `You were mentioned on a ${run.reply_surface} and someone is waiting on a reply.`
140
- : `You have been given work on a ${run.reply_surface}. There is nowhere to post a reply.`,
140
+ bystander
141
+ ? `A new message was posted in a ${run.reply_surface} you are a member of. It was not ` +
142
+ 'addressed to you, and nobody is waiting on a reply from you.'
143
+ : canReply && run.reply_surface === 'conversation'
144
+ ? 'You were mentioned in a conversation and someone is waiting on a reply.'
145
+ : run.reply_surface === 'none'
146
+ ? 'You have been given work to do. There is nowhere to reply.'
147
+ : canReply
148
+ ? `You were mentioned on a ${run.reply_surface} and someone is waiting on a reply.`
149
+ : `You have been given work on a ${run.reply_surface}. There is nowhere to post a reply.`,
141
150
  '',
142
151
  ];
143
152
  // The operator's own configuration comes first, above everything the server
@@ -187,6 +196,12 @@ export function buildPrompt(run, context) {
187
196
  if (run.recent_messages?.length) {
188
197
  lines.push('Conversation so far (oldest first):', ...run.recent_messages.map(renderMessage), '--- end of conversation history ---', '');
189
198
  }
199
+ // The server's own work order, when it wrote one that is not simply the
200
+ // message restated (a run with no message of its own already received the
201
+ // brief as its excerpt).
202
+ if (run.trigger_brief && run.trigger_brief !== run.trigger_text) {
203
+ lines.push('Your instructions for this run, from ProhostAI:', run.trigger_brief, '');
204
+ }
190
205
  const triggerAttachments = run.trigger_attachments ?? [];
191
206
  if (run.trigger_text) {
192
207
  // The server caps this excerpt, so say so rather than let an agent assume
@@ -305,6 +320,13 @@ export function buildPrompt(run, context) {
305
320
  // Postability is a separate question from what the run is about: a task run
306
321
  // has no comment route in the external API yet, so the agent must be told its
307
322
  // output won't be posted rather than left to assume it will.
323
+ if (bystander) {
324
+ // Silence is stated as the default and given a concrete spelling, because
325
+ // "reply only if useful" without a way to NOT reply still ends in stdout
326
+ // that gets posted.
327
+ lines.push('Staying silent is the expected outcome here. Reply only if you can add', 'something clearly distinct that nobody else in the thread was asked for. If', `you decide not to reply, output ONLY the token ${NO_REPLY_TOKEN} — nothing is`, 'posted and the run is recorded as a deliberate silence. Never write a message', 'explaining that you are staying quiet.', 'If you do reply, write the message text only — no preamble, no explanation of', `your reasoning, no surrounding quotes. Anything other than ${NO_REPLY_TOKEN} is`, `posted verbatim as your reply on the ${run.reply_surface}.`);
328
+ return `${lines.join('\n')}\n`;
329
+ }
308
330
  lines.push(canReply
309
331
  ? 'Write your reply to that message. Reply with the message text only — no'
310
332
  : 'Summarize what you did, in one short message. Output the message only — no', 'preamble, no explanation of your reasoning, no surrounding quotes. Your', canReply
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import type { spawn } from 'node:child_process';
26
26
  import WebSocket from 'ws';
27
+ import type { CommandRunner } from './accounts.js';
27
28
  import type { AgentCredentials } from './credentials.js';
28
29
  import type { QuotaProvider } from './runtime_status.js';
29
30
  /**
@@ -110,7 +111,7 @@ export interface AgentRunOptions {
110
111
  * The second argument is the exact cwd the eventual Codex process receives;
111
112
  * Codex discovers project-local config from it.
112
113
  */
113
- probeImpl?: (command: string, cwd?: string) => {
114
+ probeImpl?: (command: string, cwd?: string, env?: NodeJS.ProcessEnv) => {
114
115
  status: number | null;
115
116
  stdout: string;
116
117
  };
@@ -138,6 +139,11 @@ export interface AgentRunOptions {
138
139
  * Tests inject a fake so no real `claude` / `codex` is spawned.
139
140
  */
140
141
  quotaProvider?: QuotaProvider | null;
142
+ /**
143
+ * Runs the agent CLI's sign-in check for each account (`claude auth status`,
144
+ * `codex login status`). Tests inject a fake so no real CLI is asked.
145
+ */
146
+ accountCommandRunner?: CommandRunner;
141
147
  }
142
148
  export interface AgentRunMetrics {
143
149
  connections: number;
@@ -181,7 +187,7 @@ interface PreparedMcp {
181
187
  * extras file is the operator's live grant list. Fresh reads make both a new
182
188
  * configured server and a revoked extra visible to the very next run.
183
189
  */
184
- export declare function codexMcpEntryProvider(options: AgentRunOptions, log: (line: string) => void, workdir?: string): () => PreparedMcp;
190
+ export declare function codexMcpEntryProvider(options: AgentRunOptions, log: (line: string) => void, workdir?: string, accountEnv?: () => NodeJS.ProcessEnv): () => PreparedMcp;
185
191
  /**
186
192
  * Run the harness until the abort signal fires (or ``maxConnections`` is hit).
187
193
  *
package/dist/agent/run.js CHANGED
@@ -25,12 +25,15 @@
25
25
  import os from 'node:os';
26
26
  import { spawnSync } from 'node:child_process';
27
27
  import WebSocket from 'ws';
28
+ import { HarnessAccount } from './account_runtime.js';
29
+ import { EVENT_AGENT_CONFIG_UPDATED, machineId } from './accounts.js';
28
30
  import { completeRun, describeFailure, isIdempotencyInFlight, postReply } from './api.js';
29
31
  import { MAX_SALVAGE_CHARS, claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
30
32
  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';
33
+ import { ToolEventQueue } from './events.js';
31
34
  import { EXTRA_MCP_FILENAME, loadExtraMcpServers } from './extras.js';
32
35
  import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
33
- import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, RUN_ERROR_CANCELLED_BY_USER, eventData, heartbeatPathFor, parseRunRequest, } from './contract.js';
36
+ import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, RUN_ERROR_CANCELLED_BY_USER, eventData, heartbeatPathFor, isNoReply, parseRunRequest, } from './contract.js';
34
37
  import { ensureWorkspace, streamUrlFor, workspacePath } from './credentials.js';
35
38
  import { execAgent } from './exec.js';
36
39
  import { HeartbeatLoop } from './heartbeat.js';
@@ -257,6 +260,13 @@ function buildRuntime(options, log) {
257
260
  model,
258
261
  };
259
262
  }
263
+ const account = new HarnessAccount({
264
+ runtime: brain === 'claude' ? 'claude_code' : 'codex',
265
+ env: options.env,
266
+ initialLabel: options.credentials.account,
267
+ binary: brain === 'claude' ? (options.exec.trim().split(/\s+/)[0] ?? 'claude') : codexBinary(options.exec),
268
+ run: options.accountCommandRunner,
269
+ });
260
270
  const mcpUrl = options.credentials.mcp_url;
261
271
  let announced = '';
262
272
  const announce = (lines) => {
@@ -294,7 +304,7 @@ function buildRuntime(options, log) {
294
304
  };
295
305
  }
296
306
  }
297
- : codexMcpEntryProvider(options, log, workdir);
307
+ : codexMcpEntryProvider(options, log, workdir, () => account.spawnEnv());
298
308
  const tools = mcpUrl ? `, ProhostAI tools prepared per run as ${MCP_SERVER_NAME}` : '';
299
309
  const where = workdir ? ` (workspace: ${workdir})` : '';
300
310
  log(brain === 'claude'
@@ -310,7 +320,28 @@ function buildRuntime(options, log) {
310
320
  'access, on instructions from your workspace. Use --safe-tools to gate them.'
311
321
  : `${timestamp()} ⚙ --safe-tools: tools are permission-gated; anything needing ` +
312
322
  'approval will not run (nobody is at a terminal to approve it).');
313
- return { workdir, sessions, brain, prepareMcp, skipPermissions, runtimeName, observe, model };
323
+ // The account is resolved before anything is prepared or spawned, and its
324
+ // environment rides every spawn of this run. A missing account fails the
325
+ // run closed, the same way an unprovable MCP boundary does.
326
+ const prepareWithAccount = () => {
327
+ const resolved = account.resolveForSpawn();
328
+ if (!resolved.ok)
329
+ return { mcpWired: false, error: resolved.error };
330
+ const prepared = prepareMcp();
331
+ return { ...prepared, execEnv: { ...account.spawnEnv(), ...prepared.execEnv } };
332
+ };
333
+ log(`${timestamp()} ⚙ account: ${account.label()}`);
334
+ return {
335
+ workdir,
336
+ sessions,
337
+ brain,
338
+ prepareMcp: prepareWithAccount,
339
+ skipPermissions,
340
+ runtimeName,
341
+ observe,
342
+ model,
343
+ account,
344
+ };
314
345
  }
315
346
  /**
316
347
  * The `runtime` a status frame names. A plain command is `custom` unless it is
@@ -328,11 +359,12 @@ function runtimeNameFor(brain, exec) {
328
359
  * Idle capacity source for the detected brain: the agent CLI itself, asked
329
360
  * through its own protocol so its own login answers. `plain` has none.
330
361
  */
331
- function defaultQuotaProvider(brain, exec) {
362
+ function defaultQuotaProvider(brain, exec, env) {
332
363
  if (brain === 'claude')
333
- return claudeQuotaProvider({ binary: exec.trim().split(/\s+/)[0] ?? 'claude' });
334
- if (brain === 'codex')
335
- return codexQuotaProvider({ binary: codexBinary(exec), clientVersion: CLI_VERSION });
364
+ return claudeQuotaProvider({ binary: exec.trim().split(/\s+/)[0] ?? 'claude', env });
365
+ if (brain === 'codex') {
366
+ return codexQuotaProvider({ binary: codexBinary(exec), clientVersion: CLI_VERSION, env });
367
+ }
336
368
  return undefined;
337
369
  }
338
370
  /**
@@ -342,12 +374,12 @@ function defaultQuotaProvider(brain, exec) {
342
374
  * extras file is the operator's live grant list. Fresh reads make both a new
343
375
  * configured server and a revoked extra visible to the very next run.
344
376
  */
345
- export function codexMcpEntryProvider(options, log, workdir) {
377
+ export function codexMcpEntryProvider(options, log, workdir, accountEnv) {
346
378
  let announced = '';
347
379
  return () => {
348
380
  const extras = loadExtraMcpServers({ env: options.env });
349
381
  const lines = extras.warnings.map((warning) => `${timestamp()} ! ${warning}`);
350
- const prepared = codexMcpEntries(options, extras.servers, (line) => lines.push(line), workdir);
382
+ const prepared = codexMcpEntries(options, extras.servers, (line) => lines.push(line), workdir, accountEnv?.());
351
383
  const said = lines.join('\n');
352
384
  if (said !== announced) {
353
385
  for (const line of lines)
@@ -362,12 +394,15 @@ export function codexMcpEntryProvider(options, log, workdir) {
362
394
  * verified switches that keep the operator's own servers out. Preparation is
363
395
  * fail-closed: an unprovable boundary never reaches an unsandboxed agent.
364
396
  */
365
- function codexMcpEntries(options, extraServers, log, workdir) {
397
+ function codexMcpEntries(options, extraServers, log, workdir, accountEnv) {
366
398
  const fail = (detail) => {
367
399
  log(`${timestamp()} ! ${detail}`);
368
400
  return { mcpWired: false, error: detail };
369
401
  };
370
- const probe = options.probeImpl ?? defaultProbe;
402
+ // Every probe asks the same CODEX_HOME the run will use — the MCP servers to
403
+ // isolate live in that account's config.toml, not the default one.
404
+ const baseProbe = options.probeImpl ?? defaultProbe;
405
+ const probe = (command, cwd) => accountEnv && Object.keys(accountEnv).length > 0 ? baseProbe(command, cwd, accountEnv) : baseProbe(command, cwd);
371
406
  if (codexShellSyntax(options.exec)) {
372
407
  return fail('--exec uses shell quoting, expansion, glob, comment, escape, or control syntax that the Codex ' +
373
408
  'isolation verifier cannot tokenize safely; use a plain argv-shaped Codex command');
@@ -493,10 +528,11 @@ function codexMcpEntries(options, extraServers, log, workdir) {
493
528
  * `cwd` must match the eventual run exactly, because Codex loads trusted
494
529
  * project `.codex/config.toml` layers from there.
495
530
  */
496
- function defaultProbe(command, cwd) {
531
+ function defaultProbe(command, cwd, env) {
497
532
  const result = spawnSync(command, {
498
533
  shell: true,
499
534
  cwd,
535
+ ...(env ? { env: { ...process.env, ...env } } : {}),
500
536
  encoding: 'utf8',
501
537
  // Generous for a config read, short enough that a wedged binary delays
502
538
  // a run rather than wedging the daemon.
@@ -532,7 +568,12 @@ function partialStdout(tail) {
532
568
  * tell a failed turn from a successful one. A plain command's behaviour here is
533
569
  * byte-for-byte what it was before this function existed.
534
570
  */
535
- async function runTurn(run, prompt, options, runtime, mcp, control, log) {
571
+ async function runTurn(run, prompt, options, runtime, mcp, control, log,
572
+ /**
573
+ * Where the streaming brain's tool-call events go, if anywhere. Only the
574
+ * Claude Code reader produces any; absent, they are simply never collected.
575
+ */
576
+ toolEvents) {
536
577
  // `--timeout` is a ceiling on the run, not on each attempt: the retry below
537
578
  // must not let one run take twice what the operator allowed. Both attempts
538
579
  // share one deadline, which costs the retry nothing in practice — a rejected
@@ -590,6 +631,9 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
590
631
  const activity = reader.activity();
591
632
  if (activity)
592
633
  control.report(activity);
634
+ // The heartbeat line above is unchanged for older servers; the events are
635
+ // the richer channel a newer server renders beneath it.
636
+ toolEvents?.push(reader.drainToolEvents());
593
637
  };
594
638
  /**
595
639
  * Undo an eagerly recorded Claude session after an ordinary failed turn.
@@ -746,7 +790,7 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
746
790
  const output = parseCodexOutput(result.stdout);
747
791
  // Codex's `--json` stream carries neither model nor limits; its rollout
748
792
  // file for this thread does.
749
- runtime.observe(readCodexObservation(output.sessionId, { ...process.env, ...options.env }));
793
+ runtime.observe(readCodexObservation(output.sessionId, { ...process.env, ...options.env, ...runtime.account?.spawnEnv() }));
750
794
  reportDenials(output.denials);
751
795
  if (!output.ok) {
752
796
  return {
@@ -764,6 +808,10 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
764
808
  // crash banner, a `claude` that ignored `--output-format` — is a failure, and
765
809
  // the whole-output parser is what says so in words.
766
810
  const parsed = (stream ?? readClaudeStream()).finish(result.stdout);
811
+ // `finish` handles a trailing event with no newline after it; a tool event
812
+ // in that tail is real and must not be lost to the drain timing.
813
+ if (stream)
814
+ toolEvents?.push(stream.drainToolEvents());
767
815
  reportDenials(parsed.denials);
768
816
  if (!parsed.ok) {
769
817
  forgetRecordedSession();
@@ -809,7 +857,21 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
809
857
  timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
810
858
  idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,
811
859
  });
812
- const result = await runTurn(run, prompt, options, runtime, mcp, control, log);
860
+ // Tool-call narration for the thread. A dry run makes no writes, so it gets
861
+ // no path and every event is dropped; so does a server that advertised none.
862
+ const toolEvents = new ToolEventQueue({
863
+ api,
864
+ path: options.dryRun ? undefined : run.events_path,
865
+ log,
866
+ label,
867
+ });
868
+ const result = await runTurn(run, prompt, options, runtime, mcp, control, log, toolEvents);
869
+ // Whatever the turn did, the timeline is whole BEFORE the reply is posted or
870
+ // the run is closed — a completion that beats its own last tool row would
871
+ // leave a `calling` row on a finished run. Never rejects, and after `stop`
872
+ // nothing straggling can post against a run the server has closed.
873
+ await toolEvents.flush();
874
+ toolEvents.stop();
813
875
  const seconds = (result.durationMs / 1000).toFixed(1);
814
876
  // Stopped from outside. Checked before the failure branch below, because a
815
877
  // stopped run *is* a failed command — reporting it as one would put "timed
@@ -865,6 +927,23 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
865
927
  log(` [dry-run] nothing was sent and the run was left open`);
866
928
  return 'skipped';
867
929
  }
930
+ // The agent decided not to reply — the usual answer when it was woken as a
931
+ // bystander (`reply_expected: false`). A finished run, not a failure: nothing
932
+ // is posted, and the completion says so, so the server records a deliberate
933
+ // silence and clears the thinking indicator as one. Checked on every run, not
934
+ // only bystander ones: the literal token must never reach a thread.
935
+ if (isNoReply(reply)) {
936
+ log(`${timestamp()} ✓ ${label} chose not to reply (${seconds}s)`);
937
+ const completed = await completeWithRetry(api, run.completion_path, { ok: true, silent: true }, log, sleep, runtime.model());
938
+ if (!completed.ok) {
939
+ log(` ! could not mark the run complete (${describeFailure(completed)})`);
940
+ return 'unreported';
941
+ }
942
+ if (result.sessionId) {
943
+ runtime.sessions.remember(sessionKeyFor(run), result.sessionId);
944
+ }
945
+ return 'succeeded';
946
+ }
868
947
  // A run with no reply surface is a legitimate outcome, not an error: it was
869
948
  // started by a trigger rather than by someone addressing the agent, so there
870
949
  // is nowhere to post. Complete it as succeeded so any thinking indicator
@@ -934,6 +1013,9 @@ export async function runAgentHarness(options) {
934
1013
  const url = options.url ?? streamUrlFor(options.credentials, DEFAULT_WS_URL);
935
1014
  const metrics = newMetrics();
936
1015
  const runtime = buildRuntime(options, log);
1016
+ // Keys this computer's accounts server-side; the label above is display
1017
+ // only. Only a runtime that reports accounts needs one.
1018
+ const machineIdValue = runtime.account ? machineId({ ...process.env, ...options.env }) : undefined;
937
1019
  // A credential file written by 0.1.0 stores a socket URL on a host that only
938
1020
  // ever answers 403. Upgrading the CLI would not have fixed those installs on
939
1021
  // its own — the stored value wins over the constant — so say what happened
@@ -960,8 +1042,11 @@ export async function runAgentHarness(options) {
960
1042
  const status = new RuntimeStatusReporter({
961
1043
  runtime: runtime.runtimeName,
962
1044
  provider: options.quotaProvider === undefined
963
- ? defaultQuotaProvider(runtime.brain, options.exec)
1045
+ ? defaultQuotaProvider(runtime.brain, options.exec, () => runtime.account?.spawnEnv() ?? {})
964
1046
  : (options.quotaProvider ?? undefined),
1047
+ accounts: runtime.account
1048
+ ? () => runtime.account.snapshot([...accepted.values()].some((entry) => entry.executing))
1049
+ : undefined,
965
1050
  // A run in flight reports fresh numbers when it ends; don't probe beside it.
966
1051
  isBusy: () => [...accepted.values()].some((entry) => entry.executing),
967
1052
  log,
@@ -992,9 +1077,29 @@ export async function runAgentHarness(options) {
992
1077
  status.observe({
993
1078
  runtime_version: version,
994
1079
  model: modelFromExec(options.exec) ??
995
- (runtime.brain === 'codex' ? codexConfiguredModel({ ...process.env, ...options.env }) : undefined),
1080
+ (runtime.brain === 'codex'
1081
+ ? codexConfiguredModel({ ...process.env, ...options.env, ...runtime.account?.spawnEnv() })
1082
+ : undefined),
996
1083
  });
997
1084
  };
1085
+ /**
1086
+ * An operator picked another account in ProhostAI. Applied from the next
1087
+ * run: the run in flight (if any) finishes on the account it started on.
1088
+ */
1089
+ const applyAccountPick = (key, via, requestedAt) => {
1090
+ if (!runtime.account)
1091
+ return false;
1092
+ const outcome = runtime.account.apply(key, requestedAt);
1093
+ if (!outcome.applied) {
1094
+ log(`${timestamp()} ! account change from ProhostAI ignored (${via}): ${outcome.reason}`);
1095
+ return false;
1096
+ }
1097
+ if (outcome.changed) {
1098
+ log(`${timestamp()} ⚙ account: ${outcome.record.label} (picked in ProhostAI) — used from the next run`);
1099
+ status.accountsChanged();
1100
+ }
1101
+ return true;
1102
+ };
998
1103
  let queue = Promise.resolve();
999
1104
  let fatal;
1000
1105
  let attempt = 0;
@@ -1151,6 +1256,7 @@ export async function runAgentHarness(options) {
1151
1256
  type: 'auth',
1152
1257
  api_key: options.credentials.api_key,
1153
1258
  machine,
1259
+ ...(machineIdValue ? { machine_id: machineIdValue } : {}),
1154
1260
  client_version: CLI_VERSION,
1155
1261
  ...(options.credentials.subscription_id
1156
1262
  ? { subscription_id: options.credentials.subscription_id }
@@ -1186,6 +1292,17 @@ export async function runAgentHarness(options) {
1186
1292
  `${EVENT_AGENT_RUN_REQUESTED} (exec: ${options.exec})`);
1187
1293
  // Only a server that says it understands the frame gets one; an
1188
1294
  // older server may treat an unknown frame type as a protocol error.
1295
+ // A pick made while this agent was offline: the server repeats it on
1296
+ // every connect until a status frame reports the agent on it.
1297
+ // Its `requested_at` is the newest pick's stamp — a floor for any
1298
+ // live pick still in flight from before this connect.
1299
+ const pending = f.agent_config;
1300
+ if (pending && typeof pending === 'object') {
1301
+ if (pending.account_key !== undefined) {
1302
+ applyAccountPick(pending.account_key, 'on connect', pending.requested_at);
1303
+ }
1304
+ runtime.account?.noteRequestedAt(pending.requested_at);
1305
+ }
1189
1306
  if (Array.isArray(f.features) && f.features.includes(RUNTIME_STATUS_FEATURE)) {
1190
1307
  primeStatus();
1191
1308
  status.attach((frame) => ws.send(JSON.stringify(frame)));
@@ -1220,6 +1337,21 @@ export async function runAgentHarness(options) {
1220
1337
  /* best-effort */
1221
1338
  }
1222
1339
  };
1340
+ if (frame.event === EVENT_AGENT_CONFIG_UPDATED) {
1341
+ // It changes which subscription this machine spends, so it is held to
1342
+ // the same signature rule as a run.
1343
+ if (!verifySignature(options.credentials.webhook_secret, frame.payload, frame.signature) &&
1344
+ !options.allowUnverified) {
1345
+ metrics.signatureRejections += 1;
1346
+ log(`${timestamp()} ✗ rejected ${EVENT_AGENT_CONFIG_UPDATED} — signature did not verify`);
1347
+ ack(ACK_REJECTED);
1348
+ return;
1349
+ }
1350
+ const data = eventData(frame.payload);
1351
+ const applied = applyAccountPick(data.account_key, 'live', data.requested_at);
1352
+ ack(applied ? ACK_ACCEPTED : ACK_IGNORED);
1353
+ return;
1354
+ }
1223
1355
  if (frame.event !== EVENT_AGENT_RUN_REQUESTED) {
1224
1356
  if (frame.event === EVENT_MENTION_CREATED || frame.event === EVENT_MESSAGE_TEAM_CHAT) {
1225
1357
  // "runs are triggered by mentions" was wrong twice over: the gate
@@ -1332,6 +1464,10 @@ export async function runAgentHarness(options) {
1332
1464
  // whole of `handleRun`, including the ten minutes of completion
1333
1465
  // retries at the end of it.
1334
1466
  release(run.run_id);
1467
+ // An account picked mid-run was held back from the status frame
1468
+ // until this run (on the old account) was done; report it now.
1469
+ if (runtime.account?.changedSinceSpawn())
1470
+ status.accountsChanged();
1335
1471
  // Both terminal outcomes reached the completion endpoint; only then
1336
1472
  // is the run settled and safe to skip on redelivery.
1337
1473
  if (outcome === 'succeeded')
@@ -25,6 +25,7 @@
25
25
  * shapes belong to other tools and grow without asking us.
26
26
  */
27
27
  import { spawn as nodeSpawn } from 'node:child_process';
28
+ import type { AccountBlock } from './accounts.js';
28
29
  /** Feature name the server lists in `auth_ok.features` when it accepts the frame. */
29
30
  export declare const RUNTIME_STATUS_FEATURE = "runtime_status";
30
31
  /** Routine report cadence while connected. */
@@ -39,13 +40,20 @@ export declare const MIN_FRAME_SPACING_MS = 31000;
39
40
  export declare const QUOTA_REFRESH_AFTER_MS: number;
40
41
  /** Ceiling on one idle quota probe. */
41
42
  export declare const PROVIDER_TIMEOUT_MS = 10000;
42
- export type QuotaKind = 'session' | 'weekly';
43
+ export type QuotaKind = 'session' | 'weekly' | 'weekly_model';
43
44
  export interface QuotaWindow {
44
45
  kind: QuotaKind;
45
46
  /** 0–100. */
46
47
  used_percent: number;
47
- /** ISO-8601. */
48
- resets_at: string;
48
+ /**
49
+ * ISO-8601, or `null` for a window whose usage is known but which has not
50
+ * started — Claude's 5-hour window after five idle hours. Reported as such
51
+ * rather than dropped: "0%, starts on next use" is an answer, a missing
52
+ * window is not.
53
+ */
54
+ resets_at: string | null;
55
+ /** The model a `weekly_model` window is scoped to, e.g. `Fable`. */
56
+ model_label?: string;
49
57
  }
50
58
  /** What one source managed to learn. Every field is optional; absent means "didn't learn it". */
51
59
  export interface RuntimeObservation {
@@ -68,7 +76,17 @@ export declare function parseVersion(stdout: string): string | undefined;
68
76
  * The field is marked internal upstream, so an unexpected shape yields nothing.
69
77
  */
70
78
  export declare function fromClaudeRateLimitInfo(info: unknown): QuotaWindow[];
71
- /** Windows from a `get_usage` control response (0–100, ISO strings). */
79
+ /**
80
+ * Windows from a `get_usage` control response (0–100, ISO strings).
81
+ *
82
+ * A window with a usage but a `null` reset is kept, with `resets_at: null` —
83
+ * that is how an idle account's 5-hour window reads, and dropping it is what
84
+ * left paired agents showing a weekly window and nothing else (B3).
85
+ *
86
+ * Per-model weekly windows come from `rate_limits.limits[]` rows of kind
87
+ * `weekly_scoped` (classified on `kind`, as the schema asks, never on a
88
+ * label), falling back to `model_scoped[]`.
89
+ */
72
90
  export declare function fromClaudeGetUsage(usage: unknown): QuotaWindow[];
73
91
  /** `get_usage.subscription_type` → display label; `undefined` for anything else. */
74
92
  export declare function claudePlanLabel(subscriptionType: unknown): string | undefined;
@@ -129,6 +147,8 @@ export declare function claudeQuotaProvider(options: {
129
147
  binary: string;
130
148
  spawnImpl?: SpawnFn;
131
149
  timeoutMs?: number;
150
+ /** Account environment, read at each probe so a switch applies to the next one. */
151
+ env?: () => NodeJS.ProcessEnv;
132
152
  }): QuotaProvider;
133
153
  /** Codex's `account/rateLimits/read` over `codex app-server` stdio JSON-RPC. */
134
154
  export declare function codexQuotaProvider(options: {
@@ -136,12 +156,42 @@ export declare function codexQuotaProvider(options: {
136
156
  clientVersion: string;
137
157
  spawnImpl?: SpawnFn;
138
158
  timeoutMs?: number;
159
+ /** Account environment, read at each probe so a switch applies to the next one. */
160
+ env?: () => NodeJS.ProcessEnv;
139
161
  }): QuotaProvider;
162
+ /**
163
+ * Fold new windows into the ones already known, one KIND at a time.
164
+ *
165
+ * Sources see different slices: a run's `rate_limit_event` carries only the
166
+ * 5-hour and weekly windows (and omits one whose reset has passed), while the
167
+ * idle `get_usage` probe also carries the per-model weekly ones. Replacing the
168
+ * whole list with whatever arrived last is how a stream event used to erase
169
+ * the windows it simply didn't mention. A kind the observation names replaces
170
+ * every window of that kind; a kind it doesn't name is kept.
171
+ */
172
+ export declare function mergeWindows(known: QuotaWindow[] | undefined, observed: QuotaWindow[]): QuotaWindow[];
173
+ /** What the harness knows about accounts on this machine, for the frame. */
174
+ export interface AccountsSnapshot {
175
+ /** The account this agent runs on. */
176
+ current?: AccountBlock;
177
+ /**
178
+ * Every account on the machine, identity and sign-in state only. Omitted
179
+ * when the list would be partial: the server treats it as authoritative
180
+ * and would forget whatever it doesn't name.
181
+ */
182
+ all?: AccountBlock[];
183
+ }
140
184
  export interface RuntimeStatusReporterOptions {
141
185
  /** `claude_code` | `codex` | `goose` | `custom`. */
142
186
  runtime: string;
143
187
  /** Idle quota source; omit for a runtime that has none. */
144
188
  provider?: QuotaProvider;
189
+ /**
190
+ * Accounts on this machine and which one this agent uses. Re-read on the
191
+ * same cadence as the idle quota probe (it asks each login whether it is
192
+ * signed in), and straight away after {@link RuntimeStatusReporter.accountsChanged}.
193
+ */
194
+ accounts?: () => Promise<AccountsSnapshot | undefined>;
145
195
  /** A run is executing — it will bring fresh numbers, so don't probe beside it. */
146
196
  isBusy?: () => boolean;
147
197
  now?: () => number;
@@ -170,9 +220,25 @@ export declare class RuntimeStatusReporter {
170
220
  private cancelPending;
171
221
  private cancelTick;
172
222
  private refreshing;
223
+ private accountGeneration;
224
+ private accounts;
225
+ private accountsObservedAt;
173
226
  constructor(options: RuntimeStatusReporterOptions);
174
227
  /** Merge what a source learned. Absent fields keep their previous value. */
175
228
  observe(observation: RuntimeObservation | undefined): void;
229
+ /**
230
+ * Record the accounts snapshot. Moving to another account drops what was
231
+ * known about the previous one's capacity and plan — they describe a
232
+ * different subscription — and asks for fresh numbers straight away.
233
+ */
234
+ setAccounts(snapshot: AccountsSnapshot | undefined, options?: {
235
+ quiet?: boolean;
236
+ }): void;
237
+ /**
238
+ * The account choice changed (an operator picked one in ProhostAI): re-read
239
+ * accounts and capacity on the next tick rather than in fifteen minutes.
240
+ */
241
+ accountsChanged(): void;
176
242
  /** The model to report on a run's completion, when nothing more specific is known. */
177
243
  model(): string | undefined;
178
244
  /** Start reporting on a socket that advertised the feature. */