@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.
package/dist/agent/run.js CHANGED
@@ -25,9 +25,12 @@
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
36
  import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, RUN_ERROR_CANCELLED_BY_USER, eventData, heartbeatPathFor, parseRunRequest, } from './contract.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
@@ -934,6 +996,9 @@ export async function runAgentHarness(options) {
934
996
  const url = options.url ?? streamUrlFor(options.credentials, DEFAULT_WS_URL);
935
997
  const metrics = newMetrics();
936
998
  const runtime = buildRuntime(options, log);
999
+ // Keys this computer's accounts server-side; the label above is display
1000
+ // only. Only a runtime that reports accounts needs one.
1001
+ const machineIdValue = runtime.account ? machineId({ ...process.env, ...options.env }) : undefined;
937
1002
  // A credential file written by 0.1.0 stores a socket URL on a host that only
938
1003
  // ever answers 403. Upgrading the CLI would not have fixed those installs on
939
1004
  // its own — the stored value wins over the constant — so say what happened
@@ -960,8 +1025,11 @@ export async function runAgentHarness(options) {
960
1025
  const status = new RuntimeStatusReporter({
961
1026
  runtime: runtime.runtimeName,
962
1027
  provider: options.quotaProvider === undefined
963
- ? defaultQuotaProvider(runtime.brain, options.exec)
1028
+ ? defaultQuotaProvider(runtime.brain, options.exec, () => runtime.account?.spawnEnv() ?? {})
964
1029
  : (options.quotaProvider ?? undefined),
1030
+ accounts: runtime.account
1031
+ ? () => runtime.account.snapshot([...accepted.values()].some((entry) => entry.executing))
1032
+ : undefined,
965
1033
  // A run in flight reports fresh numbers when it ends; don't probe beside it.
966
1034
  isBusy: () => [...accepted.values()].some((entry) => entry.executing),
967
1035
  log,
@@ -992,9 +1060,29 @@ export async function runAgentHarness(options) {
992
1060
  status.observe({
993
1061
  runtime_version: version,
994
1062
  model: modelFromExec(options.exec) ??
995
- (runtime.brain === 'codex' ? codexConfiguredModel({ ...process.env, ...options.env }) : undefined),
1063
+ (runtime.brain === 'codex'
1064
+ ? codexConfiguredModel({ ...process.env, ...options.env, ...runtime.account?.spawnEnv() })
1065
+ : undefined),
996
1066
  });
997
1067
  };
1068
+ /**
1069
+ * An operator picked another account in ProhostAI. Applied from the next
1070
+ * run: the run in flight (if any) finishes on the account it started on.
1071
+ */
1072
+ const applyAccountPick = (key, via, requestedAt) => {
1073
+ if (!runtime.account)
1074
+ return false;
1075
+ const outcome = runtime.account.apply(key, requestedAt);
1076
+ if (!outcome.applied) {
1077
+ log(`${timestamp()} ! account change from ProhostAI ignored (${via}): ${outcome.reason}`);
1078
+ return false;
1079
+ }
1080
+ if (outcome.changed) {
1081
+ log(`${timestamp()} ⚙ account: ${outcome.record.label} (picked in ProhostAI) — used from the next run`);
1082
+ status.accountsChanged();
1083
+ }
1084
+ return true;
1085
+ };
998
1086
  let queue = Promise.resolve();
999
1087
  let fatal;
1000
1088
  let attempt = 0;
@@ -1151,6 +1239,7 @@ export async function runAgentHarness(options) {
1151
1239
  type: 'auth',
1152
1240
  api_key: options.credentials.api_key,
1153
1241
  machine,
1242
+ ...(machineIdValue ? { machine_id: machineIdValue } : {}),
1154
1243
  client_version: CLI_VERSION,
1155
1244
  ...(options.credentials.subscription_id
1156
1245
  ? { subscription_id: options.credentials.subscription_id }
@@ -1186,6 +1275,17 @@ export async function runAgentHarness(options) {
1186
1275
  `${EVENT_AGENT_RUN_REQUESTED} (exec: ${options.exec})`);
1187
1276
  // Only a server that says it understands the frame gets one; an
1188
1277
  // older server may treat an unknown frame type as a protocol error.
1278
+ // A pick made while this agent was offline: the server repeats it on
1279
+ // every connect until a status frame reports the agent on it.
1280
+ // Its `requested_at` is the newest pick's stamp — a floor for any
1281
+ // live pick still in flight from before this connect.
1282
+ const pending = f.agent_config;
1283
+ if (pending && typeof pending === 'object') {
1284
+ if (pending.account_key !== undefined) {
1285
+ applyAccountPick(pending.account_key, 'on connect', pending.requested_at);
1286
+ }
1287
+ runtime.account?.noteRequestedAt(pending.requested_at);
1288
+ }
1189
1289
  if (Array.isArray(f.features) && f.features.includes(RUNTIME_STATUS_FEATURE)) {
1190
1290
  primeStatus();
1191
1291
  status.attach((frame) => ws.send(JSON.stringify(frame)));
@@ -1220,6 +1320,21 @@ export async function runAgentHarness(options) {
1220
1320
  /* best-effort */
1221
1321
  }
1222
1322
  };
1323
+ if (frame.event === EVENT_AGENT_CONFIG_UPDATED) {
1324
+ // It changes which subscription this machine spends, so it is held to
1325
+ // the same signature rule as a run.
1326
+ if (!verifySignature(options.credentials.webhook_secret, frame.payload, frame.signature) &&
1327
+ !options.allowUnverified) {
1328
+ metrics.signatureRejections += 1;
1329
+ log(`${timestamp()} ✗ rejected ${EVENT_AGENT_CONFIG_UPDATED} — signature did not verify`);
1330
+ ack(ACK_REJECTED);
1331
+ return;
1332
+ }
1333
+ const data = eventData(frame.payload);
1334
+ const applied = applyAccountPick(data.account_key, 'live', data.requested_at);
1335
+ ack(applied ? ACK_ACCEPTED : ACK_IGNORED);
1336
+ return;
1337
+ }
1223
1338
  if (frame.event !== EVENT_AGENT_RUN_REQUESTED) {
1224
1339
  if (frame.event === EVENT_MENTION_CREATED || frame.event === EVENT_MESSAGE_TEAM_CHAT) {
1225
1340
  // "runs are triggered by mentions" was wrong twice over: the gate
@@ -1332,6 +1447,10 @@ export async function runAgentHarness(options) {
1332
1447
  // whole of `handleRun`, including the ten minutes of completion
1333
1448
  // retries at the end of it.
1334
1449
  release(run.run_id);
1450
+ // An account picked mid-run was held back from the status frame
1451
+ // until this run (on the old account) was done; report it now.
1452
+ if (runtime.account?.changedSinceSpawn())
1453
+ status.accountsChanged();
1335
1454
  // Both terminal outcomes reached the completion endpoint; only then
1336
1455
  // is the run settled and safe to skip on redelivery.
1337
1456
  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. */