@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/CHANGELOG.md +101 -0
- package/README.md +61 -0
- package/dist/agent/account_runtime.d.ts +75 -0
- package/dist/agent/account_runtime.js +130 -0
- package/dist/agent/accounts.d.ts +162 -0
- package/dist/agent/accounts.js +413 -0
- package/dist/agent/agent_commands.d.ts +22 -0
- package/dist/agent/agent_commands.js +170 -0
- package/dist/agent/api.d.ts +15 -1
- package/dist/agent/api.js +19 -2
- package/dist/agent/claude.d.ts +42 -0
- package/dist/agent/claude.js +218 -0
- package/dist/agent/command.d.ts +18 -1
- package/dist/agent/command.js +88 -4
- package/dist/agent/contract.d.ts +43 -0
- package/dist/agent/contract.js +13 -0
- package/dist/agent/credentials.d.ts +5 -0
- package/dist/agent/events.d.ts +104 -0
- package/dist/agent/events.js +175 -0
- package/dist/agent/list.d.ts +41 -0
- package/dist/agent/list.js +126 -0
- package/dist/agent/lock.d.ts +37 -0
- package/dist/agent/lock.js +91 -0
- package/dist/agent/run.d.ts +15 -2
- package/dist/agent/run.js +227 -19
- package/dist/agent/runtime_status.d.ts +257 -0
- package/dist/agent/runtime_status.js +681 -0
- package/dist/agent/workspace.d.ts +40 -0
- package/dist/agent/workspace.js +97 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +27 -5
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|