@prohost/cli 0.5.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';
@@ -36,6 +39,7 @@ import { execAgent } from './exec.js';
36
39
  import { HeartbeatLoop } from './heartbeat.js';
37
40
  import { buildPrompt, replyFromStdout } from './prompt.js';
38
41
  import { RunStore } from './runstore.js';
42
+ import { RUNTIME_STATUS_FEATURE, RuntimeStatusReporter, claudeQuotaProvider, codexConfiguredModel, codexQuotaProvider, modelFromExec, parseVersion, readCodexObservation, } from './runtime_status.js';
39
43
  import { SessionStore, sessionKeyFor } from './sessionstore.js';
40
44
  import { verifySignature } from './signature.js';
41
45
  import { isWebhookEvent } from '../forward.js';
@@ -133,8 +137,8 @@ const COMPLETION_RETRY_DELAYS_MS = [1_000, 3_000, 9_000, 30_000, 60_000, 120_000
133
137
  * sweeper reaps it. A 409 (`already_resolved`) counts as done. A 4xx other
134
138
  * than 429 is a decision, not a blip, so it isn't retried.
135
139
  */
136
- async function completeWithRetry(api, path, outcome, log, sleep) {
137
- let result = await completeRun(api, path, outcome);
140
+ async function completeWithRetry(api, path, outcome, log, sleep, model) {
141
+ let result = await completeRun(api, path, outcome, model);
138
142
  for (const delay of COMPLETION_RETRY_DELAYS_MS) {
139
143
  if (result.ok)
140
144
  return result;
@@ -143,7 +147,7 @@ async function completeWithRetry(api, path, outcome, log, sleep) {
143
147
  return result;
144
148
  log(` … completion failed (${describeFailure(result)}); retrying in ${delay / 1000}s`);
145
149
  await sleep(delay);
146
- result = await completeRun(api, path, outcome);
150
+ result = await completeRun(api, path, outcome, model);
147
151
  }
148
152
  return result;
149
153
  }
@@ -240,9 +244,29 @@ function buildRuntime(options, log) {
240
244
  ? 'codex'
241
245
  : 'plain';
242
246
  const skipPermissions = options.safeTools !== true;
247
+ const runtimeName = runtimeNameFor(brain, options.exec);
248
+ // Replaced by the harness once its status reporter exists.
249
+ const observe = () => { };
250
+ const model = () => undefined;
243
251
  if (brain === 'plain') {
244
- return { workdir, sessions, brain, skipPermissions, prepareMcp: () => ({ mcpWired: false }) };
252
+ return {
253
+ workdir,
254
+ sessions,
255
+ brain,
256
+ skipPermissions,
257
+ prepareMcp: () => ({ mcpWired: false }),
258
+ runtimeName,
259
+ observe,
260
+ model,
261
+ };
245
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
+ });
246
270
  const mcpUrl = options.credentials.mcp_url;
247
271
  let announced = '';
248
272
  const announce = (lines) => {
@@ -280,7 +304,7 @@ function buildRuntime(options, log) {
280
304
  };
281
305
  }
282
306
  }
283
- : codexMcpEntryProvider(options, log, workdir);
307
+ : codexMcpEntryProvider(options, log, workdir, () => account.spawnEnv());
284
308
  const tools = mcpUrl ? `, ProhostAI tools prepared per run as ${MCP_SERVER_NAME}` : '';
285
309
  const where = workdir ? ` (workspace: ${workdir})` : '';
286
310
  log(brain === 'claude'
@@ -296,7 +320,52 @@ function buildRuntime(options, log) {
296
320
  'access, on instructions from your workspace. Use --safe-tools to gate them.'
297
321
  : `${timestamp()} ⚙ --safe-tools: tools are permission-gated; anything needing ` +
298
322
  'approval will not run (nobody is at a terminal to approve it).');
299
- return { workdir, sessions, brain, prepareMcp, skipPermissions };
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
+ };
345
+ }
346
+ /**
347
+ * The `runtime` a status frame names. A plain command is `custom` unless it is
348
+ * recognisably goose — the one other agent CLI people pair today.
349
+ */
350
+ function runtimeNameFor(brain, exec) {
351
+ if (brain === 'claude')
352
+ return 'claude_code';
353
+ if (brain === 'codex')
354
+ return 'codex';
355
+ const base = (exec.trim().split(/\s+/)[0] ?? '').split('/').pop() ?? '';
356
+ return base === 'goose' ? 'goose' : 'custom';
357
+ }
358
+ /**
359
+ * Idle capacity source for the detected brain: the agent CLI itself, asked
360
+ * through its own protocol so its own login answers. `plain` has none.
361
+ */
362
+ function defaultQuotaProvider(brain, exec, env) {
363
+ if (brain === 'claude')
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
+ }
368
+ return undefined;
300
369
  }
301
370
  /**
302
371
  * Prepare a fresh Codex MCP override immediately before each invocation.
@@ -305,12 +374,12 @@ function buildRuntime(options, log) {
305
374
  * extras file is the operator's live grant list. Fresh reads make both a new
306
375
  * configured server and a revoked extra visible to the very next run.
307
376
  */
308
- export function codexMcpEntryProvider(options, log, workdir) {
377
+ export function codexMcpEntryProvider(options, log, workdir, accountEnv) {
309
378
  let announced = '';
310
379
  return () => {
311
380
  const extras = loadExtraMcpServers({ env: options.env });
312
381
  const lines = extras.warnings.map((warning) => `${timestamp()} ! ${warning}`);
313
- const prepared = codexMcpEntries(options, extras.servers, (line) => lines.push(line), workdir);
382
+ const prepared = codexMcpEntries(options, extras.servers, (line) => lines.push(line), workdir, accountEnv?.());
314
383
  const said = lines.join('\n');
315
384
  if (said !== announced) {
316
385
  for (const line of lines)
@@ -325,12 +394,15 @@ export function codexMcpEntryProvider(options, log, workdir) {
325
394
  * verified switches that keep the operator's own servers out. Preparation is
326
395
  * fail-closed: an unprovable boundary never reaches an unsandboxed agent.
327
396
  */
328
- function codexMcpEntries(options, extraServers, log, workdir) {
397
+ function codexMcpEntries(options, extraServers, log, workdir, accountEnv) {
329
398
  const fail = (detail) => {
330
399
  log(`${timestamp()} ! ${detail}`);
331
400
  return { mcpWired: false, error: detail };
332
401
  };
333
- 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);
334
406
  if (codexShellSyntax(options.exec)) {
335
407
  return fail('--exec uses shell quoting, expansion, glob, comment, escape, or control syntax that the Codex ' +
336
408
  'isolation verifier cannot tokenize safely; use a plain argv-shaped Codex command');
@@ -456,10 +528,11 @@ function codexMcpEntries(options, extraServers, log, workdir) {
456
528
  * `cwd` must match the eventual run exactly, because Codex loads trusted
457
529
  * project `.codex/config.toml` layers from there.
458
530
  */
459
- function defaultProbe(command, cwd) {
531
+ function defaultProbe(command, cwd, env) {
460
532
  const result = spawnSync(command, {
461
533
  shell: true,
462
534
  cwd,
535
+ ...(env ? { env: { ...process.env, ...env } } : {}),
463
536
  encoding: 'utf8',
464
537
  // Generous for a config read, short enough that a wedged binary delays
465
538
  // a run rather than wedging the daemon.
@@ -495,7 +568,12 @@ function partialStdout(tail) {
495
568
  * tell a failed turn from a successful one. A plain command's behaviour here is
496
569
  * byte-for-byte what it was before this function existed.
497
570
  */
498
- 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) {
499
577
  // `--timeout` is a ceiling on the run, not on each attempt: the retry below
500
578
  // must not let one run take twice what the operator allowed. Both attempts
501
579
  // share one deadline, which costs the retry nothing in practice — a rejected
@@ -553,6 +631,9 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
553
631
  const activity = reader.activity();
554
632
  if (activity)
555
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());
556
637
  };
557
638
  /**
558
639
  * Undo an eagerly recorded Claude session after an ordinary failed turn.
@@ -574,7 +655,7 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
574
655
  const reader = runtime.brain === 'claude' ? readClaudeStream() : undefined;
575
656
  stream = reader;
576
657
  tail = '';
577
- return execAgent({
658
+ const running = execAgent({
578
659
  command,
579
660
  prompt,
580
661
  timeoutMs: capped ? Math.max(1, remainingMs()) : 0,
@@ -594,6 +675,9 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
594
675
  }
595
676
  : undefined,
596
677
  });
678
+ // Whatever the stream announced — model, version, capacity — is true of
679
+ // this machine whether or not the turn succeeded.
680
+ return reader ? running.finally(() => runtime.observe(reader.observed())) : running;
597
681
  };
598
682
  // Wall clock for the whole turn, retry included — what the operator waited,
599
683
  // rather than what the last attempt took.
@@ -704,6 +788,9 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
704
788
  };
705
789
  if (codex) {
706
790
  const output = parseCodexOutput(result.stdout);
791
+ // Codex's `--json` stream carries neither model nor limits; its rollout
792
+ // file for this thread does.
793
+ runtime.observe(readCodexObservation(output.sessionId, { ...process.env, ...options.env, ...runtime.account?.spawnEnv() }));
707
794
  reportDenials(output.denials);
708
795
  if (!output.ok) {
709
796
  return {
@@ -721,6 +808,10 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
721
808
  // crash banner, a `claude` that ignored `--output-format` — is a failure, and
722
809
  // the whole-output parser is what says so in words.
723
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());
724
815
  reportDenials(parsed.denials);
725
816
  if (!parsed.ok) {
726
817
  forgetRecordedSession();
@@ -766,7 +857,21 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
766
857
  timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
767
858
  idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,
768
859
  });
769
- 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();
770
875
  const seconds = (result.durationMs / 1000).toFixed(1);
771
876
  // Stopped from outside. Checked before the failure branch below, because a
772
877
  // stopped run *is* a failed command — reporting it as one would put "timed
@@ -787,7 +892,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
787
892
  }
788
893
  // The exact marker string, alone: the server words the thread notice from
789
894
  // it, and anything wrapped around it reads as a crash instead of a stop.
790
- const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep);
895
+ const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep, runtime.model());
791
896
  if (!closed.ok) {
792
897
  log(` ! could not report the cancellation (${describeFailure(closed)})` +
793
898
  `${closed.status !== 0 && closed.error ? ` ${closed.error}` : ''}`);
@@ -805,7 +910,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
805
910
  log(` [dry-run] would report the run as failed`);
806
911
  return 'skipped';
807
912
  }
808
- const completed = await completeWithRetry(api, run.completion_path, { ok: false, error: detail ? `${reason}: ${detail}` : reason }, log, sleep);
913
+ const completed = await completeWithRetry(api, run.completion_path, { ok: false, error: detail ? `${reason}: ${detail}` : reason }, log, sleep, runtime.model());
809
914
  if (!completed.ok) {
810
915
  log(` ! could not report failure (${describeFailure(completed)})` +
811
916
  `${completed.status !== 0 && completed.error ? ` ${completed.error}` : ''}`);
@@ -833,7 +938,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
833
938
  log(`${timestamp()} ✓ ${label} finished in ${seconds}s (${why})`);
834
939
  for (const line of reply.split('\n'))
835
940
  log(` │ ${line}`);
836
- const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep);
941
+ const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep, runtime.model());
837
942
  if (!completed.ok) {
838
943
  log(` ! could not mark the run complete (${describeFailure(completed)})`);
839
944
  return 'unreported';
@@ -856,7 +961,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
856
961
  // used to be expressed by simply not recording until the reply landed.
857
962
  if (result.sessionRecorded)
858
963
  runtime.sessions.forget(sessionKeyFor(run));
859
- const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed: ${failure}` }, log, sleep);
964
+ const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed: ${failure}` }, log, sleep, runtime.model());
860
965
  return reported.ok ? 'failed' : 'unreported';
861
966
  }
862
967
  // Advance continuity only after the answer is durable on its reply surface.
@@ -865,7 +970,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
865
970
  if (result.sessionId) {
866
971
  runtime.sessions.remember(sessionKeyFor(run), result.sessionId);
867
972
  }
868
- const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep);
973
+ const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep, runtime.model());
869
974
  if (!completed.ok) {
870
975
  // The reply is already in the conversation, so this is a bookkeeping
871
976
  // failure, not a lost answer — say so precisely. Returning false lets the
@@ -891,6 +996,9 @@ export async function runAgentHarness(options) {
891
996
  const url = options.url ?? streamUrlFor(options.credentials, DEFAULT_WS_URL);
892
997
  const metrics = newMetrics();
893
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;
894
1002
  // A credential file written by 0.1.0 stores a socket URL on a host that only
895
1003
  // ever answers 403. Upgrading the CLI would not have fixed those installs on
896
1004
  // its own — the stored value wins over the constant — so say what happened
@@ -914,6 +1022,67 @@ export async function runAgentHarness(options) {
914
1022
  /** Accepted, not yet reported — the executing run and everything queued. */
915
1023
  const accepted = new Map();
916
1024
  const heartbeats = new HeartbeatLoop({ api, log, intervalMs: options.heartbeatIntervalMs });
1025
+ const status = new RuntimeStatusReporter({
1026
+ runtime: runtime.runtimeName,
1027
+ provider: options.quotaProvider === undefined
1028
+ ? defaultQuotaProvider(runtime.brain, options.exec, () => runtime.account?.spawnEnv() ?? {})
1029
+ : (options.quotaProvider ?? undefined),
1030
+ accounts: runtime.account
1031
+ ? () => runtime.account.snapshot([...accepted.values()].some((entry) => entry.executing))
1032
+ : undefined,
1033
+ // A run in flight reports fresh numbers when it ends; don't probe beside it.
1034
+ isBusy: () => [...accepted.values()].some((entry) => entry.executing),
1035
+ log,
1036
+ });
1037
+ runtime.observe = (observation) => status.observe(observation);
1038
+ runtime.model = () => (runtime.brain === 'plain' ? undefined : status.model());
1039
+ let statusPrimed = false;
1040
+ /**
1041
+ * First-connect facts that need no run: the CLI's version and any model the
1042
+ * exec string or Codex config pins. Done lazily — only once a server has
1043
+ * asked for status — so nothing is probed for a server that can't use it.
1044
+ */
1045
+ const primeStatus = () => {
1046
+ if (statusPrimed || runtime.brain === 'plain')
1047
+ return;
1048
+ statusPrimed = true;
1049
+ const binary = runtime.brain === 'codex'
1050
+ ? codexBinary(options.exec)
1051
+ : (options.exec.trim().split(/\s+/)[0] ?? 'claude');
1052
+ const probe = options.probeImpl ?? defaultProbe;
1053
+ let version;
1054
+ try {
1055
+ version = parseVersion(probe(`${binary} --version`, runtime.workdir).stdout);
1056
+ }
1057
+ catch {
1058
+ version = undefined;
1059
+ }
1060
+ status.observe({
1061
+ runtime_version: version,
1062
+ model: modelFromExec(options.exec) ??
1063
+ (runtime.brain === 'codex'
1064
+ ? codexConfiguredModel({ ...process.env, ...options.env, ...runtime.account?.spawnEnv() })
1065
+ : undefined),
1066
+ });
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
+ };
917
1086
  let queue = Promise.resolve();
918
1087
  let fatal;
919
1088
  let attempt = 0;
@@ -1001,6 +1170,7 @@ export async function runAgentHarness(options) {
1001
1170
  // any cancellation the heartbeat loop started and deliberately did not wait
1002
1171
  // for, so the harness never exits with a run half-closed.
1003
1172
  await queue;
1173
+ status.detach();
1004
1174
  // Stopped first, so no further tick can start work after the wait below has
1005
1175
  // taken its snapshot of `background`.
1006
1176
  heartbeats.stop();
@@ -1069,6 +1239,7 @@ export async function runAgentHarness(options) {
1069
1239
  type: 'auth',
1070
1240
  api_key: options.credentials.api_key,
1071
1241
  machine,
1242
+ ...(machineIdValue ? { machine_id: machineIdValue } : {}),
1072
1243
  client_version: CLI_VERSION,
1073
1244
  ...(options.credentials.subscription_id
1074
1245
  ? { subscription_id: options.credentials.subscription_id }
@@ -1102,6 +1273,23 @@ export async function runAgentHarness(options) {
1102
1273
  // read mention code instead of the run dispatcher.
1103
1274
  log(`${timestamp()} ● connected as "${name}" — waiting for ` +
1104
1275
  `${EVENT_AGENT_RUN_REQUESTED} (exec: ${options.exec})`);
1276
+ // Only a server that says it understands the frame gets one; an
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
+ }
1289
+ if (Array.isArray(f.features) && f.features.includes(RUNTIME_STATUS_FEATURE)) {
1290
+ primeStatus();
1291
+ status.attach((frame) => ws.send(JSON.stringify(frame)));
1292
+ }
1105
1293
  return;
1106
1294
  }
1107
1295
  if (f.type === 'auth_error') {
@@ -1132,6 +1320,21 @@ export async function runAgentHarness(options) {
1132
1320
  /* best-effort */
1133
1321
  }
1134
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
+ }
1135
1338
  if (frame.event !== EVENT_AGENT_RUN_REQUESTED) {
1136
1339
  if (frame.event === EVENT_MENTION_CREATED || frame.event === EVENT_MESSAGE_TEAM_CHAT) {
1137
1340
  // "runs are triggered by mentions" was wrong twice over: the gate
@@ -1244,6 +1447,10 @@ export async function runAgentHarness(options) {
1244
1447
  // whole of `handleRun`, including the ten minutes of completion
1245
1448
  // retries at the end of it.
1246
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();
1247
1454
  // Both terminal outcomes reached the completion endpoint; only then
1248
1455
  // is the run settled and safe to skip on redelivery.
1249
1456
  if (outcome === 'succeeded')
@@ -1265,6 +1472,7 @@ export async function runAgentHarness(options) {
1265
1472
  });
1266
1473
  ws.on('close', () => {
1267
1474
  liveness.stop();
1475
+ status.detach();
1268
1476
  if (options.signal)
1269
1477
  options.signal.removeEventListener('abort', close);
1270
1478
  resolve(authed);