@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.
- package/CHANGELOG.md +96 -0
- package/README.md +51 -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 +12 -0
- package/dist/agent/api.js +14 -2
- package/dist/agent/claude.d.ts +35 -0
- package/dist/agent/claude.js +205 -0
- package/dist/agent/command.d.ts +18 -1
- package/dist/agent/command.js +88 -4
- package/dist/agent/contract.d.ts +79 -0
- package/dist/agent/contract.js +51 -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/prompt.js +29 -7
- package/dist/agent/run.d.ts +8 -2
- package/dist/agent/run.js +153 -17
- package/dist/agent/runtime_status.d.ts +70 -4
- package/dist/agent/runtime_status.js +145 -20
- 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
|
@@ -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
|
+
}
|
package/dist/agent/prompt.js
CHANGED
|
@@ -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
|
-
|
|
135
|
-
?
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
package/dist/agent/run.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
-
/**
|
|
48
|
-
|
|
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
|
-
/**
|
|
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. */
|