@prohost/cli 0.15.0 → 0.16.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 +68 -0
- package/README.md +265 -1
- package/dist/agent/accounts.d.ts +11 -0
- package/dist/agent/accounts.js +12 -1
- package/dist/agent/api.d.ts +6 -0
- package/dist/agent/api.js +5 -0
- package/dist/agent/claude.d.ts +6 -0
- package/dist/agent/claude.js +2 -2
- package/dist/agent/daemon.d.ts +33 -0
- package/dist/agent/daemon.js +56 -0
- package/dist/agent/prompt.d.ts +12 -0
- package/dist/agent/prompt.js +5 -0
- package/dist/agent/run.d.ts +110 -1
- package/dist/agent/run.js +186 -19
- package/dist/codehost/host.d.ts +25 -0
- package/dist/codehost/host.js +16 -1
- package/dist/codehost/host_stats.d.ts +49 -2
- package/dist/codehost/host_stats.js +153 -2
- package/dist/codehost/relay.d.ts +10 -0
- package/dist/codehost/relay.js +17 -1
- package/dist/host/api.d.ts +82 -0
- package/dist/host/api.js +122 -0
- package/dist/host/capacity.d.ts +41 -0
- package/dist/host/capacity.js +48 -0
- package/dist/host/command.d.ts +59 -0
- package/dist/host/command.js +584 -0
- package/dist/host/computer.d.ts +120 -0
- package/dist/host/computer.js +291 -0
- package/dist/host/events.d.ts +24 -0
- package/dist/host/events.js +47 -0
- package/dist/host/install.d.ts +152 -0
- package/dist/host/install.js +755 -0
- package/dist/host/ledger.d.ts +52 -0
- package/dist/host/ledger.js +131 -0
- package/dist/host/legacy.d.ts +38 -0
- package/dist/host/legacy.js +115 -0
- package/dist/host/logins.d.ts +109 -0
- package/dist/host/logins.js +190 -0
- package/dist/host/migrate.d.ts +165 -0
- package/dist/host/migrate.js +364 -0
- package/dist/host/pair.d.ts +62 -0
- package/dist/host/pair.js +219 -0
- package/dist/host/peak_memory.d.ts +44 -0
- package/dist/host/peak_memory.js +139 -0
- package/dist/host/scheduler.d.ts +86 -0
- package/dist/host/scheduler.js +186 -0
- package/dist/host/service.d.ts +114 -0
- package/dist/host/service.js +1027 -0
- package/dist/host/session_move.d.ts +48 -0
- package/dist/host/session_move.js +106 -0
- package/dist/host/skills.d.ts +29 -0
- package/dist/host/skills.js +135 -0
- package/dist/host/status.d.ts +91 -0
- package/dist/host/status.js +130 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +47 -3
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/agent/run.d.ts
CHANGED
|
@@ -30,9 +30,12 @@ import type { spawn } from 'node:child_process';
|
|
|
30
30
|
import WebSocket from 'ws';
|
|
31
31
|
import { HarnessAccount } from './account_runtime.js';
|
|
32
32
|
import type { CommandRunner } from './accounts.js';
|
|
33
|
+
import type { ApiOptions, ApiResult } from './api.js';
|
|
34
|
+
import type { AgentRunRequest } from './contract.js';
|
|
33
35
|
import type { AgentCredentials } from './credentials.js';
|
|
34
36
|
import type { RunCapabilities } from './capabilities.js';
|
|
35
37
|
import type { QuotaProvider } from './runtime_status.js';
|
|
38
|
+
import { SessionStore } from './sessionstore.js';
|
|
36
39
|
import type { CodeHostLike } from '../codehost/host.js';
|
|
37
40
|
import type { AccountEnvResult, CodeAgent } from '../codehost/terminals.js';
|
|
38
41
|
/**
|
|
@@ -196,6 +199,39 @@ export interface AgentRunOptions {
|
|
|
196
199
|
* `null` never hosts. Tests inject a fake.
|
|
197
200
|
*/
|
|
198
201
|
codeHost?: CodeHostLike | null;
|
|
202
|
+
/**
|
|
203
|
+
* `prohost host` only. Where this run's strict Claude MCP config is written,
|
|
204
|
+
* instead of `$PROHOST_HOME/mcp.json`: the key in it is the run's own token.
|
|
205
|
+
*/
|
|
206
|
+
mcpConfigFile?: string;
|
|
207
|
+
/** `prohost host` only: said near the top of the prompt (see `PromptContext.resumeNote`). */
|
|
208
|
+
resumeNote?: string;
|
|
209
|
+
/**
|
|
210
|
+
* `prohost host` only: a turn stopped by the login's usage limit is handed
|
|
211
|
+
* back to the caller (`usage_limited`) instead of being completed, so the
|
|
212
|
+
* run can resume on another login of the same kind.
|
|
213
|
+
*/
|
|
214
|
+
deferUsageLimit?: boolean;
|
|
215
|
+
/**
|
|
216
|
+
* `prohost host` only: variables for this run's command alone (a dedicated
|
|
217
|
+
* install's Claude token), read at each run so a rotation applies. A Claude
|
|
218
|
+
* Code harness also gives them to its sign-in and usage probes.
|
|
219
|
+
*/
|
|
220
|
+
extraEnv?: NodeJS.ProcessEnv | (() => NodeJS.ProcessEnv);
|
|
221
|
+
/** `prohost host` only: skip the per-harness start-up lines (the host says them once). */
|
|
222
|
+
quietStartup?: boolean;
|
|
223
|
+
/**
|
|
224
|
+
* `prohost host` only: the server's send rule applies to this run's replies.
|
|
225
|
+
* The agent is asked to end a reply with {@link confidenceTrailer}; it is
|
|
226
|
+
* stripped, and `confidence` / `reply_class` go with the send.
|
|
227
|
+
*/
|
|
228
|
+
replyConfidence?: boolean;
|
|
229
|
+
/**
|
|
230
|
+
* `prohost host` only: the employee's one session ledger, shared by its
|
|
231
|
+
* runs. Each run loading its own would let two overlapping conversations
|
|
232
|
+
* overwrite each other's entry when they persist.
|
|
233
|
+
*/
|
|
234
|
+
sessions?: SessionStore;
|
|
199
235
|
}
|
|
200
236
|
export interface AgentRunMetrics {
|
|
201
237
|
connections: number;
|
|
@@ -218,6 +254,53 @@ export interface AgentRunMetrics {
|
|
|
218
254
|
*/
|
|
219
255
|
fatal?: string;
|
|
220
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* What one run amounted to.
|
|
259
|
+
*
|
|
260
|
+
* * ``succeeded`` / ``failed`` — the server accepted a terminal state. Settled.
|
|
261
|
+
* * ``cancelled`` — somebody pressed stop; the run was reported ``failed`` with
|
|
262
|
+
* the marker error the server words as a cancellation. Settled.
|
|
263
|
+
* * ``resolved`` — the server had already closed the run out (it was reaped, or
|
|
264
|
+
* finished elsewhere). Nothing was posted and nothing was reported, because
|
|
265
|
+
* there is no longer anything to report to. Settled all the same.
|
|
266
|
+
* * ``unreported`` — the work is done but the completion never landed. The run
|
|
267
|
+
* stays open in the ledger so a redelivery can report it without re-running
|
|
268
|
+
* the agent.
|
|
269
|
+
* * ``skipped`` — a dry run. No writes were made, so it is neither a success
|
|
270
|
+
* nor a failure and must not be counted as either.
|
|
271
|
+
*/
|
|
272
|
+
type RunOutcome = 'succeeded' | 'failed' | 'cancelled' | 'resolved' | 'unreported' | 'skipped';
|
|
273
|
+
/**
|
|
274
|
+
* A send the server answered without sending, deliberately: `held_as_suggestion`
|
|
275
|
+
* (saved as a draft for the team — the send rule) or `ai_muted` (the thread is
|
|
276
|
+
* muted; nothing saved, nothing to retry). Either way the run is done.
|
|
277
|
+
*/
|
|
278
|
+
export declare function isHeldAsSuggestion(result: ApiResult): boolean;
|
|
279
|
+
/**
|
|
280
|
+
* A reply without its confidence trailer (`[[confidence=0.9 reply_class=general]]`,
|
|
281
|
+
* see `replyConfidence`), and what the last one said. Every trailer is removed,
|
|
282
|
+
* wherever the model put it and however it dressed it, even one that can't be
|
|
283
|
+
* read: none may reach a guest. A line left with nothing else on it goes too.
|
|
284
|
+
*/
|
|
285
|
+
export declare function splitConfidence(reply: string): {
|
|
286
|
+
text: string;
|
|
287
|
+
confidence?: number;
|
|
288
|
+
replyClass?: string;
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* Why a run stopped early, when it stopped because something outside said so —
|
|
292
|
+
* or, for `expired`, because a venue run's grant is about to end.
|
|
293
|
+
*/
|
|
294
|
+
type Disposition = 'cancelled' | 'resolved' | 'expired';
|
|
295
|
+
/** What a run in flight can see, and say, about being stopped. */
|
|
296
|
+
interface RunControl {
|
|
297
|
+
/** Fires when the run must stop — cancelled, or closed server-side. */
|
|
298
|
+
signal: AbortSignal;
|
|
299
|
+
/** Why it was stopped, once it was. */
|
|
300
|
+
disposition(): Disposition | undefined;
|
|
301
|
+
/** Publish what the agent is doing, for the next heartbeat to carry. */
|
|
302
|
+
report(activity: string): void;
|
|
303
|
+
}
|
|
221
304
|
interface PreparedMcp {
|
|
222
305
|
/** Claude Code: path of the strict generated MCP config. */
|
|
223
306
|
mcpConfigPath?: string;
|
|
@@ -258,4 +341,30 @@ export declare function codexMcpEntryProvider(options: AgentRunOptions, log: (li
|
|
|
258
341
|
*/
|
|
259
342
|
export declare function codeThreadAccountEnv(account: HarnessAccount | undefined, agent: CodeAgent): AccountEnvResult;
|
|
260
343
|
export declare function runAgentHarness(options: AgentRunOptions): Promise<AgentRunMetrics>;
|
|
261
|
-
|
|
344
|
+
/** What one run executed for `prohost host` came to. */
|
|
345
|
+
export interface HostedRunResult {
|
|
346
|
+
/**
|
|
347
|
+
* As `agent run` settles a run, plus `usage_limited`: the login's usage limit
|
|
348
|
+
* stopped the turn and nothing was reported (see `deferUsageLimit`).
|
|
349
|
+
*/
|
|
350
|
+
outcome: RunOutcome | 'usage_limited';
|
|
351
|
+
/** When the limit lifts (ISO), for a `usage_limited` turn whose brain said. */
|
|
352
|
+
retryAfter?: string;
|
|
353
|
+
/** The model the run used, as far as the harness learnt. */
|
|
354
|
+
model?: string;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Execute one run that `prohost host` has already claimed, the way
|
|
358
|
+
* `agent run` executes one it accepted: the same prompt, Claude Code / Codex
|
|
359
|
+
* shaping, session continuity, tool events, reply and completion.
|
|
360
|
+
*
|
|
361
|
+
* `options.credentials.api_key` is the run's own token, and `options.env`
|
|
362
|
+
* points `PROHOST_HOME` at the employee's home (its sessions and workspace).
|
|
363
|
+
* Heartbeats, cancellation and the queue belong to the caller, which hands
|
|
364
|
+
* the outcome of each to `control`.
|
|
365
|
+
*/
|
|
366
|
+
export declare function executeHostedRun(run: AgentRunRequest, options: AgentRunOptions, control: RunControl, isolation: {
|
|
367
|
+
lane: string;
|
|
368
|
+
parallel: boolean;
|
|
369
|
+
}, api: ApiOptions): Promise<HostedRunResult>;
|
|
370
|
+
export type { RunControl as HostedRunControl, RunOutcome as HostedRunOutcome };
|
package/dist/agent/run.js
CHANGED
|
@@ -33,6 +33,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
33
33
|
import WebSocket from 'ws';
|
|
34
34
|
import { HarnessAccount } from './account_runtime.js';
|
|
35
35
|
import { EVENT_AGENT_CONFIG_UPDATED, accountEnv, machineId } from './accounts.js';
|
|
36
|
+
import { defaultCommandRunner } from './accounts.js';
|
|
36
37
|
import { completeRun, describeFailure, isIdempotencyInFlight, postReply } from './api.js';
|
|
37
38
|
import { MAX_SALVAGE_CHARS, claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
|
|
38
39
|
import { APPS_FEATURE, MCP_API_KEY_ENV_VAR, PLUGINS_DISABLED_OVERRIDE, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, extraMcpEntries, isCodexExec, isolateConfiguredServers, listMcpServers, mcpServersOverride, prohostMcpEntry, codexFailureMessage, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
|
|
@@ -208,6 +209,17 @@ const REPLAY_IN_FLIGHT_DELAY_MS = 5_000;
|
|
|
208
209
|
* answer, and the run is reported failed exactly once.
|
|
209
210
|
*/
|
|
210
211
|
async function postReplyWithReplay(api, send, label, log, sleep) {
|
|
212
|
+
const result = await postReplyOnce(api, send, label, log, sleep);
|
|
213
|
+
// The send rule kept it as a suggestion for the team: handled, not failed.
|
|
214
|
+
if (isHeldAsSuggestion(result)) {
|
|
215
|
+
log(/ai_muted/.test(result.error ?? '')
|
|
216
|
+
? `${timestamp()} ◦ ${label} reply was not sent: the thread is muted for AI`
|
|
217
|
+
: `${timestamp()} ◦ ${label} reply was held as a suggestion for the team (the employee's send rule)`);
|
|
218
|
+
return { ...result, ok: true };
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
async function postReplyOnce(api, send, label, log, sleep) {
|
|
211
223
|
let posted = await send();
|
|
212
224
|
// A real answer — success or a decision — is final. Only "no answer at all"
|
|
213
225
|
// earns a replay.
|
|
@@ -258,6 +270,77 @@ function resolveWorkdir(options, log) {
|
|
|
258
270
|
return undefined;
|
|
259
271
|
}
|
|
260
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* A send the server answered without sending, deliberately: `held_as_suggestion`
|
|
275
|
+
* (saved as a draft for the team — the send rule) or `ai_muted` (the thread is
|
|
276
|
+
* muted; nothing saved, nothing to retry). Either way the run is done.
|
|
277
|
+
*/
|
|
278
|
+
export function isHeldAsSuggestion(result) {
|
|
279
|
+
return result.status === 409 && /held_as_suggestion|ai_muted/.test(result.error ?? '');
|
|
280
|
+
}
|
|
281
|
+
/** The run's extra variables, resolved now. */
|
|
282
|
+
function extraEnvOf(options) {
|
|
283
|
+
return typeof options.extraEnv === 'function' ? options.extraEnv() : (options.extraEnv ?? {});
|
|
284
|
+
}
|
|
285
|
+
/** A line that is the confidence trailer, however the model dressed it (backticks, bold, commas, quotes). */
|
|
286
|
+
/** A trailer token anywhere, with any decoration hugging it. */
|
|
287
|
+
const TRAILER_TOKEN = /[`*_]*\[\[\s*confidence\b[^\]]*\]\][`*_]*\.?/gi;
|
|
288
|
+
/** The confidence a trailer states: a plain decimal from 0 to 1, read whole — anything else is no confidence. */
|
|
289
|
+
function trailerConfidence(token) {
|
|
290
|
+
const raw = /confidence\s*[=:]\s*["']?([^\s,"'\]]+)/i.exec(token)?.[1];
|
|
291
|
+
if (!raw || !/^(?:0|1|0?\.\d+|1\.0+|0\.\d+)$/.test(raw))
|
|
292
|
+
return undefined;
|
|
293
|
+
const value = Number(raw);
|
|
294
|
+
return value >= 0 && value <= 1 ? value : undefined;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* A reply without its confidence trailer (`[[confidence=0.9 reply_class=general]]`,
|
|
298
|
+
* see `replyConfidence`), and what the last one said. Every trailer is removed,
|
|
299
|
+
* wherever the model put it and however it dressed it, even one that can't be
|
|
300
|
+
* read: none may reach a guest. A line left with nothing else on it goes too.
|
|
301
|
+
*/
|
|
302
|
+
export function splitConfidence(reply) {
|
|
303
|
+
const tokens = [...reply.matchAll(TRAILER_TOKEN)];
|
|
304
|
+
if (tokens.length === 0)
|
|
305
|
+
return { text: reply };
|
|
306
|
+
const last = tokens[tokens.length - 1][0];
|
|
307
|
+
// Whole tokens first (one may wrap onto the next line), marked; then a line
|
|
308
|
+
// left with nothing but a mark and decoration goes.
|
|
309
|
+
const MARK = '\u0000';
|
|
310
|
+
const text = reply
|
|
311
|
+
.replace(TRAILER_TOKEN, MARK)
|
|
312
|
+
.split('\n')
|
|
313
|
+
.flatMap((line) => {
|
|
314
|
+
if (!line.includes(MARK))
|
|
315
|
+
return [line];
|
|
316
|
+
const cleaned = line.split(MARK).join('').replace(/[ \t]+$/, '');
|
|
317
|
+
return /^[\s`*_>.]*$/.test(cleaned) ? [] : [cleaned];
|
|
318
|
+
})
|
|
319
|
+
.join('\n')
|
|
320
|
+
.replace(/\s+$/, '');
|
|
321
|
+
const confidence = trailerConfidence(last);
|
|
322
|
+
const replyClass = /reply_class\s*[=:]\s*["']?([A-Za-z_]{1,40})/i.exec(last)?.[1]?.toLowerCase();
|
|
323
|
+
if (confidence === undefined)
|
|
324
|
+
return { text };
|
|
325
|
+
return { text, confidence, ...(replyClass ? { replyClass } : {}) };
|
|
326
|
+
}
|
|
327
|
+
/** The send extras for one answer: the run's own, plus what its trailer said. */
|
|
328
|
+
function shapeAnswer(answer, extra, on) {
|
|
329
|
+
if (!on)
|
|
330
|
+
return { text: answer, extra };
|
|
331
|
+
const shaped = splitConfidence(answer);
|
|
332
|
+
return {
|
|
333
|
+
text: shaped.text,
|
|
334
|
+
extra: shaped.confidence !== undefined
|
|
335
|
+
? { ...extra, confidence: shaped.confidence, ...(shaped.replyClass ? { reply_class: shaped.replyClass } : {}) }
|
|
336
|
+
: extra,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* A Codex turn its usage limit stopped. Codex says so in its own words on a
|
|
341
|
+
* failed turn; only `prohost host` acts on it (`deferUsageLimit`).
|
|
342
|
+
*/
|
|
343
|
+
const CODEX_USAGE_LIMIT = /usage limit|usage_limit_reached|hit your limit/i;
|
|
261
344
|
/**
|
|
262
345
|
* The lane a run waits on: runs sharing one never overlap.
|
|
263
346
|
*
|
|
@@ -278,7 +361,7 @@ function laneFor(run) {
|
|
|
278
361
|
*/
|
|
279
362
|
function buildRuntime(options, log) {
|
|
280
363
|
const workdir = resolveWorkdir(options, log);
|
|
281
|
-
const sessions = SessionStore.load(options.env, log);
|
|
364
|
+
const sessions = options.sessions ?? SessionStore.load(options.env, log);
|
|
282
365
|
const brain = isClaudeExec(options.exec)
|
|
283
366
|
? 'claude'
|
|
284
367
|
: isCodexExec(options.exec)
|
|
@@ -307,11 +390,15 @@ function buildRuntime(options, log) {
|
|
|
307
390
|
env: options.env,
|
|
308
391
|
initialLabel: options.credentials.account,
|
|
309
392
|
binary: brain === 'claude' ? (options.exec.trim().split(/\s+/)[0] ?? 'claude') : codexBinary(options.exec),
|
|
310
|
-
run
|
|
393
|
+
// A Claude harness's sign-in check sees the run's extra variables too (the Claude token).
|
|
394
|
+
run: brain === 'claude' && options.extraEnv
|
|
395
|
+
? (command, args, env) => (options.accountCommandRunner ?? defaultCommandRunner)(command, args, { ...env, ...extraEnvOf(options) })
|
|
396
|
+
: options.accountCommandRunner,
|
|
311
397
|
warn: (message) => log(`${timestamp()} ! ${message}`),
|
|
312
398
|
});
|
|
313
399
|
const mcpUrl = options.credentials.mcp_url;
|
|
314
400
|
let announced = '';
|
|
401
|
+
const quiet = options.quietStartup === true;
|
|
315
402
|
const announce = (lines) => {
|
|
316
403
|
const message = lines.join('\n');
|
|
317
404
|
if (message === announced)
|
|
@@ -320,7 +407,7 @@ function buildRuntime(options, log) {
|
|
|
320
407
|
log(line);
|
|
321
408
|
announced = message;
|
|
322
409
|
};
|
|
323
|
-
if (!mcpUrl) {
|
|
410
|
+
if (!mcpUrl && !quiet) {
|
|
324
411
|
log(`${timestamp()} ! this pairing carries no MCP endpoint; the agent will run without ProhostAI tools`);
|
|
325
412
|
}
|
|
326
413
|
const prepareMcp = brain === 'claude'
|
|
@@ -336,6 +423,7 @@ function buildRuntime(options, log) {
|
|
|
336
423
|
apiKey: options.credentials.api_key,
|
|
337
424
|
extraServers: extras.servers,
|
|
338
425
|
env: options.env,
|
|
426
|
+
...(options.mcpConfigFile ? { file: options.mcpConfigFile } : {}),
|
|
339
427
|
});
|
|
340
428
|
return { mcpConfigPath, mcpWired: Boolean(mcpUrl) };
|
|
341
429
|
}
|
|
@@ -350,19 +438,21 @@ function buildRuntime(options, log) {
|
|
|
350
438
|
: codexMcpEntryProvider(options, log, workdir, () => account.spawnEnv());
|
|
351
439
|
const tools = mcpUrl ? `, ProhostAI tools prepared per run as ${MCP_SERVER_NAME}` : '';
|
|
352
440
|
const where = workdir ? ` (workspace: ${workdir})` : '';
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
441
|
+
if (!quiet)
|
|
442
|
+
log(brain === 'claude'
|
|
443
|
+
? `${timestamp()} ⚙ Claude Code detected — resuming a session per conversation${tools}${where}`
|
|
444
|
+
: `${timestamp()} ⚙ Codex detected — resuming a thread per conversation${tools}${where}`);
|
|
356
445
|
// Said out loud every start-up, not buried in --help. This is the line that
|
|
357
446
|
// tells an operator their machine will run tool calls, unprompted, on text
|
|
358
447
|
// written by someone else in their workspace.
|
|
359
448
|
const bypassFlag = brain === 'claude' ? '--dangerously-skip-permissions' : '--yolo';
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
449
|
+
if (!quiet)
|
|
450
|
+
log(skipPermissions
|
|
451
|
+
? `${timestamp()} ⚠ tools run WITHOUT permission prompts ` +
|
|
452
|
+
`(${bypassFlag}). The agent acts as you, with your local ` +
|
|
453
|
+
'access, on instructions from your workspace. Use --safe-tools to gate them.'
|
|
454
|
+
: `${timestamp()} ⚙ --safe-tools: tools are permission-gated; anything needing ` +
|
|
455
|
+
'approval will not run (nobody is at a terminal to approve it).');
|
|
366
456
|
// The account is resolved before anything is prepared or spawned, and its
|
|
367
457
|
// environment rides every spawn of this run. A missing account fails the
|
|
368
458
|
// run closed, the same way an unprovable MCP boundary does.
|
|
@@ -377,7 +467,8 @@ function buildRuntime(options, log) {
|
|
|
377
467
|
accountLabel: resolved.record.label,
|
|
378
468
|
};
|
|
379
469
|
};
|
|
380
|
-
|
|
470
|
+
if (!quiet)
|
|
471
|
+
log(`${timestamp()} ⚙ account: ${account.label()}`);
|
|
381
472
|
return {
|
|
382
473
|
workdir,
|
|
383
474
|
sessions,
|
|
@@ -884,6 +975,16 @@ onEarlyAnswer) {
|
|
|
884
975
|
// the ledger can't know. One retry without it, because the alternative is a
|
|
885
976
|
// surface that fails every run forever over an optimisation.
|
|
886
977
|
const resumeFailed = codex ? looksLikeCodexResumeFailure : looksLikeResumeFailure;
|
|
978
|
+
/**
|
|
979
|
+
* A Codex turn its usage limit stopped may already have acted. For
|
|
980
|
+
* `prohost host` (which moves the thread to another login) its thread is
|
|
981
|
+
* remembered, so the next login continues it rather than starting over.
|
|
982
|
+
*/
|
|
983
|
+
const keepCodexThreadOnLimit = (threadId, said) => {
|
|
984
|
+
if (!options.deferUsageLimit || !sessionKey || !threadId || !CODEX_USAGE_LIMIT.test(said))
|
|
985
|
+
return;
|
|
986
|
+
runtime.sessions.remember(sessionKey, threadId);
|
|
987
|
+
};
|
|
887
988
|
if (!result.ok &&
|
|
888
989
|
sessionId &&
|
|
889
990
|
// A usage limit fails a fresh session exactly as it failed this one, and
|
|
@@ -911,6 +1012,8 @@ onEarlyAnswer) {
|
|
|
911
1012
|
if (!result.timedOut && !result.idleTimedOut && !result.cancelled && !stream?.usageLimit()) {
|
|
912
1013
|
forgetRecordedSession();
|
|
913
1014
|
}
|
|
1015
|
+
if (codex)
|
|
1016
|
+
keepCodexThreadOnLimit(parseCodexOutput(result.stdout).sessionId, `${codexFailureMessage(result.stdout) ?? ''}\n${result.stderr}`);
|
|
914
1017
|
return failure(result);
|
|
915
1018
|
}
|
|
916
1019
|
// A denied tool is not an error either brain reports — the turn succeeds and
|
|
@@ -929,6 +1032,7 @@ onEarlyAnswer) {
|
|
|
929
1032
|
runtime.observe(readCodexObservation(output.sessionId, { ...process.env, ...options.env, ...runtime.account?.spawnEnv() }));
|
|
930
1033
|
reportDenials(output.denials);
|
|
931
1034
|
if (!output.ok) {
|
|
1035
|
+
keepCodexThreadOnLimit(output.sessionId, output.error ?? '');
|
|
932
1036
|
return {
|
|
933
1037
|
ok: false,
|
|
934
1038
|
reason: 'the agent did not produce a usable reply',
|
|
@@ -977,7 +1081,9 @@ onEarlyAnswer) {
|
|
|
977
1081
|
*/
|
|
978
1082
|
async function handleRun(run, options, runtime, api, log, sleep, control, isolation,
|
|
979
1083
|
/** Prepared by the caller, which also hands the run's account back. */
|
|
980
|
-
mcp
|
|
1084
|
+
mcp,
|
|
1085
|
+
/** Filled for a turn handed back as `usage_limited` (see `deferUsageLimit`). */
|
|
1086
|
+
limited) {
|
|
981
1087
|
const label = `run ${run.run_id.slice(0, 12)}`;
|
|
982
1088
|
const surface = run.conversation_id ?? run.kind ?? 'no conversation';
|
|
983
1089
|
// Answered with the run's own token, by a session with no tools and none of
|
|
@@ -1020,6 +1126,8 @@ mcp) {
|
|
|
1020
1126
|
parallel: isolation.parallel,
|
|
1021
1127
|
checkouts: checkouts?.checkouts,
|
|
1022
1128
|
checkoutFailures: checkouts?.failures,
|
|
1129
|
+
...(options.resumeNote ? { resumeNote: options.resumeNote } : {}),
|
|
1130
|
+
...(options.replyConfidence && run.reply_surface === 'conversation' ? { askConfidence: true } : {}),
|
|
1023
1131
|
});
|
|
1024
1132
|
// Tool-call narration for the thread. A dry run makes no writes, so it gets
|
|
1025
1133
|
// no path and every event is dropped; so does a server that advertised none.
|
|
@@ -1029,7 +1137,7 @@ mcp) {
|
|
|
1029
1137
|
log,
|
|
1030
1138
|
label,
|
|
1031
1139
|
});
|
|
1032
|
-
const runEnv = { [PAIRED_RUN_ENV_VAR]: '1' };
|
|
1140
|
+
const runEnv = { ...extraEnvOf(options), [PAIRED_RUN_ENV_VAR]: '1' };
|
|
1033
1141
|
if (checkouts && checkouts.checkouts.length > 0)
|
|
1034
1142
|
runEnv[RUN_REPOS_ENV_VAR] = checkouts.dir;
|
|
1035
1143
|
// The last moment before the agent starts: a venue run that waited in the
|
|
@@ -1081,7 +1189,10 @@ mcp) {
|
|
|
1081
1189
|
const { answer, key } = early[0];
|
|
1082
1190
|
const posted = await postReplyWithReplay(api, () => stoppedNow()
|
|
1083
1191
|
? Promise.resolve({ ok: false, status: -1, error: 'the run was stopped' })
|
|
1084
|
-
:
|
|
1192
|
+
: (() => {
|
|
1193
|
+
const shaped = shapeAnswer(answer, run.reply_body_extra, options.replyConfidence);
|
|
1194
|
+
return postReply(api, earlyPath, earlyBodyKey, shaped.text, key, shaped.extra);
|
|
1195
|
+
})(), label, log, sleep);
|
|
1085
1196
|
if (!posted.ok) {
|
|
1086
1197
|
if (!stoppedNow())
|
|
1087
1198
|
log(`${timestamp()} ! ${label} could not post an answer yet (${describeFailure(posted)})`);
|
|
@@ -1159,6 +1270,18 @@ mcp) {
|
|
|
1159
1270
|
}
|
|
1160
1271
|
return 'cancelled';
|
|
1161
1272
|
}
|
|
1273
|
+
if (!result.ok && options.deferUsageLimit && !options.dryRun && !venue) {
|
|
1274
|
+
const usageLimit = result.usageLimit ??
|
|
1275
|
+
(runtime.brain === 'codex' && CODEX_USAGE_LIMIT.test(`${result.reason}\n${result.detail ?? ''}`) ? {} : undefined);
|
|
1276
|
+
if (usageLimit) {
|
|
1277
|
+
// Not completed: the caller moves the conversation to another login of
|
|
1278
|
+
// the same kind, or completes it with this limit itself.
|
|
1279
|
+
log(`${timestamp()} ⏸ ${label} hit this login's usage limit after ${seconds}s`);
|
|
1280
|
+
if (limited && usageLimit.retryAfter)
|
|
1281
|
+
limited.retryAfter = usageLimit.retryAfter;
|
|
1282
|
+
return 'usage_limited';
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1162
1285
|
if (!result.ok) {
|
|
1163
1286
|
const { reason } = result;
|
|
1164
1287
|
// A venue run's detail is stderr and the agent's unfinished answer — the
|
|
@@ -1183,7 +1306,9 @@ mcp) {
|
|
|
1183
1306
|
}
|
|
1184
1307
|
return 'failed';
|
|
1185
1308
|
}
|
|
1186
|
-
const
|
|
1309
|
+
const shaped = shapeAnswer(result.reply, run.reply_body_extra, options.replyConfidence);
|
|
1310
|
+
const reply = shaped.text;
|
|
1311
|
+
const sendExtra = shaped.extra;
|
|
1187
1312
|
if (options.dryRun) {
|
|
1188
1313
|
const destination = run.reply_surface === 'none' ? '(no reply surface)' : `${run.reply_surface} ${run.reply_path}`;
|
|
1189
1314
|
log(`${timestamp()} ◦ ${label} dry-run — would post to ${destination}:`);
|
|
@@ -1249,7 +1374,7 @@ mcp) {
|
|
|
1249
1374
|
}
|
|
1250
1375
|
const replyPath = run.reply_path;
|
|
1251
1376
|
const replyBodyKey = run.reply_body_key;
|
|
1252
|
-
const posted = await postReplyWithReplay(api, () => postReply(api, replyPath, replyBodyKey, reply, run.run_id,
|
|
1377
|
+
const posted = await postReplyWithReplay(api, () => postReply(api, replyPath, replyBodyKey, reply, run.run_id, sendExtra), label, log, sleep);
|
|
1253
1378
|
if (!posted.ok) {
|
|
1254
1379
|
const failure = describeFailure(posted);
|
|
1255
1380
|
// The server's reason can quote the thread (a reply-race refusal previews
|
|
@@ -1400,7 +1525,10 @@ export async function runAgentHarness(options) {
|
|
|
1400
1525
|
const status = new RuntimeStatusReporter({
|
|
1401
1526
|
runtime: runtime.runtimeName,
|
|
1402
1527
|
provider: options.quotaProvider === undefined
|
|
1403
|
-
? defaultQuotaProvider(runtime.brain, options.exec, () =>
|
|
1528
|
+
? defaultQuotaProvider(runtime.brain, options.exec, () => ({
|
|
1529
|
+
...runtime.account?.spawnEnv(),
|
|
1530
|
+
...(runtime.brain === 'claude' ? extraEnvOf(options) : {}),
|
|
1531
|
+
}))
|
|
1404
1532
|
: (options.quotaProvider ?? undefined),
|
|
1405
1533
|
accounts: runtime.account
|
|
1406
1534
|
? (known) => runtime.account.snapshot([...accepted.values()].some((entry) => entry.executing), known)
|
|
@@ -2179,3 +2307,42 @@ export async function runAgentHarness(options) {
|
|
|
2179
2307
|
});
|
|
2180
2308
|
}
|
|
2181
2309
|
}
|
|
2310
|
+
/**
|
|
2311
|
+
* Execute one run that `prohost host` has already claimed, the way
|
|
2312
|
+
* `agent run` executes one it accepted: the same prompt, Claude Code / Codex
|
|
2313
|
+
* shaping, session continuity, tool events, reply and completion.
|
|
2314
|
+
*
|
|
2315
|
+
* `options.credentials.api_key` is the run's own token, and `options.env`
|
|
2316
|
+
* points `PROHOST_HOME` at the employee's home (its sessions and workspace).
|
|
2317
|
+
* Heartbeats, cancellation and the queue belong to the caller, which hands
|
|
2318
|
+
* the outcome of each to `control`.
|
|
2319
|
+
*/
|
|
2320
|
+
export async function executeHostedRun(run, options, control, isolation, api) {
|
|
2321
|
+
const log = options.onLine ?? ((line) => process.stdout.write(`${line}\n`));
|
|
2322
|
+
const sleep = options.sleepImpl ?? defaultSleep;
|
|
2323
|
+
const runtime = buildRuntime(options, log);
|
|
2324
|
+
const worktrees = new WorktreeManager({ repos: options.repos ?? [], env: options.env, log });
|
|
2325
|
+
let mcp;
|
|
2326
|
+
try {
|
|
2327
|
+
mcp = runtime.prepareMcp();
|
|
2328
|
+
}
|
|
2329
|
+
catch (err) {
|
|
2330
|
+
mcp = { mcpWired: false, error: `could not prepare the run (${String(err)})` };
|
|
2331
|
+
}
|
|
2332
|
+
const limited = {};
|
|
2333
|
+
try {
|
|
2334
|
+
const outcome = await handleRun(run, options, runtime, api, log, sleep, control, { lane: isolation.lane, parallel: isolation.parallel, worktrees }, mcp, limited).catch((err) => {
|
|
2335
|
+
log(`${timestamp()} ✗ run ${run.run_id} crashed the run handler: ${String(err)}`);
|
|
2336
|
+
return 'failed';
|
|
2337
|
+
});
|
|
2338
|
+
return {
|
|
2339
|
+
outcome,
|
|
2340
|
+
...(outcome === 'usage_limited' && limited.retryAfter ? { retryAfter: limited.retryAfter } : {}),
|
|
2341
|
+
model: runtime.model(),
|
|
2342
|
+
};
|
|
2343
|
+
}
|
|
2344
|
+
finally {
|
|
2345
|
+
if (mcp.accountLabel !== undefined)
|
|
2346
|
+
runtime.account?.finishSpawn(mcp.accountLabel);
|
|
2347
|
+
}
|
|
2348
|
+
}
|
package/dist/codehost/host.d.ts
CHANGED
|
@@ -93,6 +93,18 @@ export interface CodeHostLike {
|
|
|
93
93
|
* (`agents.ts`), re-checked on every status frame: what the server offers for this Mac.
|
|
94
94
|
*/
|
|
95
95
|
agentCapabilities?(): string[];
|
|
96
|
+
/** Terminal sessions open on this machine now (`prohost host` counts them against capacity). */
|
|
97
|
+
liveTerminals?(): Array<{
|
|
98
|
+
terminalId: string;
|
|
99
|
+
codeThreadId: string;
|
|
100
|
+
agent: string;
|
|
101
|
+
}>;
|
|
102
|
+
/**
|
|
103
|
+
* Report a start as failed without starting anything (the computer is
|
|
104
|
+
* paused), the way a failed start is reported: the thread never waits in
|
|
105
|
+
* `starting`. Resolves whether the report landed.
|
|
106
|
+
*/
|
|
107
|
+
failStart?(data: Record<string, unknown>, reason: string): Promise<boolean>;
|
|
96
108
|
}
|
|
97
109
|
/** `code_thread.approval_decided` data, or `null`. */
|
|
98
110
|
export declare function parseApprovalDecision(data: Record<string, unknown>): ApprovalRequest | null;
|
|
@@ -138,6 +150,13 @@ export interface CodeHostOptions {
|
|
|
138
150
|
onCodeHostChanged?: () => void;
|
|
139
151
|
/** The selected account's environment (see `CodeTerminalsOptions.accountEnv`). */
|
|
140
152
|
accountEnv?: (agent: CodeAgent) => AccountEnvResult;
|
|
153
|
+
/** See `CodeHostRelayOptions.fallbackApiKey`. */
|
|
154
|
+
fallbackApiKey?: string;
|
|
155
|
+
/**
|
|
156
|
+
* The computer is paused (`prohost host pause`): no new viewer may dial in.
|
|
157
|
+
* Terminals already open, replies and approvals carry on.
|
|
158
|
+
*/
|
|
159
|
+
paused?: () => boolean;
|
|
141
160
|
tmux?: TmuxRunner;
|
|
142
161
|
spawnPty?: () => PtySpawn | undefined;
|
|
143
162
|
events?: CodeThreadEventSink;
|
|
@@ -190,6 +209,11 @@ export declare class CodeHost implements CodeHostLike {
|
|
|
190
209
|
constructor(options: CodeHostOptions);
|
|
191
210
|
settings(): CodeHostSettings;
|
|
192
211
|
remoteTerminalAllowed(): boolean;
|
|
212
|
+
liveTerminals(): Array<{
|
|
213
|
+
terminalId: string;
|
|
214
|
+
codeThreadId: string;
|
|
215
|
+
agent: string;
|
|
216
|
+
}>;
|
|
193
217
|
instanceCapability(): string;
|
|
194
218
|
/**
|
|
195
219
|
* Only the agents a start would accept: installed here, and runnable on this harness's login
|
|
@@ -272,6 +296,7 @@ export declare class CodeHost implements CodeHostLike {
|
|
|
272
296
|
* Throws {@link UnreportedOutcomeError} when the outcome never reached the
|
|
273
297
|
* server, so the harness asks for the delivery again rather than ack it.
|
|
274
298
|
*/
|
|
299
|
+
failStart(data: Record<string, unknown>, reason: string): Promise<boolean>;
|
|
275
300
|
handleStart(data: Record<string, unknown>): Promise<boolean>;
|
|
276
301
|
handleInput(data: Record<string, unknown>, deliveryId?: string): Promise<boolean>;
|
|
277
302
|
handleApproval(data: Record<string, unknown>, deliveryId?: string): Promise<boolean>;
|
package/dist/codehost/host.js
CHANGED
|
@@ -24,7 +24,7 @@ import { LocalTerminalServer } from './local_server.js';
|
|
|
24
24
|
import { loadNodePty } from './pty.js';
|
|
25
25
|
import { CodeHostRelay } from './relay.js';
|
|
26
26
|
import { codeHostDir, codeHostInstanceId, ensurePrivateDir, loadSettings } from './settings.js';
|
|
27
|
-
import { CodeTerminals, execTmux } from './terminals.js';
|
|
27
|
+
import { CodeTerminals, execTmux, isDefinitive } from './terminals.js';
|
|
28
28
|
import { APPROVAL_DECISIONS, isValidId, socketNameFor, TMUX_CONF } from './tmux.js';
|
|
29
29
|
export const EVENT_CODE_THREAD_START_REQUESTED = 'code_thread.start_requested';
|
|
30
30
|
export const EVENT_CODE_THREAD_INPUT_REQUESTED = 'code_thread.input_requested';
|
|
@@ -204,6 +204,11 @@ export class CodeHost {
|
|
|
204
204
|
remoteTerminalAllowed() {
|
|
205
205
|
return this.settings().allowRemoteTerminal;
|
|
206
206
|
}
|
|
207
|
+
liveTerminals() {
|
|
208
|
+
return this.terminals
|
|
209
|
+
.list()
|
|
210
|
+
.map((record) => ({ terminalId: record.terminalId, codeThreadId: record.codeThreadId, agent: record.agent }));
|
|
211
|
+
}
|
|
207
212
|
instanceCapability() {
|
|
208
213
|
return `${HOST_INSTANCE_CAPABILITY_PREFIX}${this.instanceId}`;
|
|
209
214
|
}
|
|
@@ -243,6 +248,8 @@ export class CodeHost {
|
|
|
243
248
|
this.relay = new CodeHostRelay({
|
|
244
249
|
baseUrl: this.options.baseUrl,
|
|
245
250
|
apiKey: this.options.apiKey,
|
|
251
|
+
...(this.options.fallbackApiKey ? { fallbackApiKey: this.options.fallbackApiKey } : {}),
|
|
252
|
+
...(this.options.paused ? { dialsPaused: this.options.paused } : {}),
|
|
246
253
|
machineId: this.options.machineId,
|
|
247
254
|
instanceId: this.instanceId,
|
|
248
255
|
machine: this.options.machine,
|
|
@@ -527,6 +534,14 @@ export class CodeHost {
|
|
|
527
534
|
* Throws {@link UnreportedOutcomeError} when the outcome never reached the
|
|
528
535
|
* server, so the harness asks for the delivery again rather than ack it.
|
|
529
536
|
*/
|
|
537
|
+
async failStart(data, reason) {
|
|
538
|
+
const request = parseStartRequest(data);
|
|
539
|
+
if (!request)
|
|
540
|
+
return true;
|
|
541
|
+
const status = await this.events.post(request.codeThreadId, event('failed', { reason }, Date.now()));
|
|
542
|
+
// Retryable answers (401 while the token renews, 408, 429, 5xx) leave the delivery to be retried.
|
|
543
|
+
return isDefinitive(status);
|
|
544
|
+
}
|
|
530
545
|
async handleStart(data) {
|
|
531
546
|
const request = parseStartRequest(data);
|
|
532
547
|
if (!request)
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* number comes from a stock tool or Node's `os` module; anything that can't be
|
|
7
7
|
* read is `null`, never 0. macOS sources: `vm_stat` (memory in use),
|
|
8
8
|
* `sysctl kern.memorystatus_vm_pressure_level` (pressure), `pmset -g batt`
|
|
9
|
-
* (battery), `sysctl machdep.cpu.brand_string` (spec).
|
|
10
|
-
* `
|
|
9
|
+
* (battery), `sysctl machdep.cpu.brand_string` (spec). Linux: `/proc/meminfo`
|
|
10
|
+
* (memory in use), `/proc/pressure/memory` (pressure, PSI), and
|
|
11
|
+
* `/sys/class/power_supply` (battery). Windows: `typeperf` performance counters
|
|
12
|
+
* (memory in use, commit charge → pressure) and PowerShell's `Win32_Battery`.
|
|
11
13
|
*/
|
|
12
14
|
import os from 'node:os';
|
|
13
15
|
export type MemoryPressure = 'normal' | 'elevated' | 'critical';
|
|
@@ -31,6 +33,47 @@ export declare function parsePmset(text: string): {
|
|
|
31
33
|
batteryPct: number | null;
|
|
32
34
|
onBattery: boolean | null;
|
|
33
35
|
};
|
|
36
|
+
/**
|
|
37
|
+
* Linux PSI (`/proc/pressure/memory`): the share of the last 10 s some / all
|
|
38
|
+
* runnable tasks stalled on memory. `full` stalls mean the machine is
|
|
39
|
+
* thrashing; sustained `some` stalls mean reclaim is getting in the way.
|
|
40
|
+
*/
|
|
41
|
+
export declare function pressureFromPsi(text: string): MemoryPressure | null;
|
|
42
|
+
/** Bytes in use per `/proc/meminfo`: MemTotal − MemAvailable (what `free` calls used). */
|
|
43
|
+
export declare function usedFromMeminfo(text: string): number | undefined;
|
|
44
|
+
/** `/sys/class/power_supply/BAT*`: its `capacity` and `status`, and whether any AC supply is online. */
|
|
45
|
+
export declare function linuxBattery(supplies: Array<{
|
|
46
|
+
name: string;
|
|
47
|
+
type?: string;
|
|
48
|
+
capacity?: string;
|
|
49
|
+
status?: string;
|
|
50
|
+
online?: string;
|
|
51
|
+
}>): {
|
|
52
|
+
batteryPct: number | null;
|
|
53
|
+
onBattery: boolean | null;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* One `typeperf -sc 1` sample of `\Memory\Available MBytes` and
|
|
57
|
+
* `\Memory\% Committed Bytes In Use`: the last CSV row, quoted values.
|
|
58
|
+
*/
|
|
59
|
+
export declare function parseTypeperf(text: string): {
|
|
60
|
+
availableMb?: number;
|
|
61
|
+
committedPct?: number;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Windows has no single pressure level, so it is read from what the memory
|
|
65
|
+
* manager is short of: physical memory left, and the commit charge against
|
|
66
|
+
* its limit (past which allocations fail outright).
|
|
67
|
+
*/
|
|
68
|
+
export declare function windowsPressure(sample: {
|
|
69
|
+
availableMb?: number;
|
|
70
|
+
committedPct?: number;
|
|
71
|
+
}, totalBytes: number | undefined): MemoryPressure | null;
|
|
72
|
+
/** PowerShell `Win32_Battery` as `<BatteryStatus>,<EstimatedChargeRemaining>`; 1 = discharging. */
|
|
73
|
+
export declare function parseWindowsBattery(text: string): {
|
|
74
|
+
batteryPct: number | null;
|
|
75
|
+
onBattery: boolean | null;
|
|
76
|
+
};
|
|
34
77
|
/** e.g. `Apple M3 Max · 64 GB`. */
|
|
35
78
|
export declare function specFrom(brand: string | undefined, totalBytes: number | undefined): string | null;
|
|
36
79
|
interface CpuSample {
|
|
@@ -51,6 +94,10 @@ export interface HostStatsSamplerOptions {
|
|
|
51
94
|
busy: number;
|
|
52
95
|
total: number;
|
|
53
96
|
} | undefined;
|
|
97
|
+
/** Reads a small system file (`/proc`, `/sys`); `undefined` when it can't. */
|
|
98
|
+
readFile?: (file: string) => string | undefined;
|
|
99
|
+
/** Lists a directory (`/sys/class/power_supply`); empty when it can't. */
|
|
100
|
+
listDir?: (dir: string) => string[];
|
|
54
101
|
}
|
|
55
102
|
/** Samples on demand. Keeps the previous CPU sample so each call reports the interval since the last. */
|
|
56
103
|
export declare class HostStatsSampler {
|