@prohost/cli 0.15.0 → 0.16.1
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 +86 -0
- package/README.md +266 -1
- package/dist/agent/accounts.d.ts +11 -0
- package/dist/agent/accounts.js +12 -1
- package/dist/agent/api.d.ts +12 -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 +116 -1
- package/dist/agent/run.js +217 -29
- 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 +597 -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 +154 -0
- package/dist/host/install.js +784 -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/prompt.d.ts
CHANGED
|
@@ -99,6 +99,18 @@ export interface PromptContext {
|
|
|
99
99
|
source: string;
|
|
100
100
|
reason: string;
|
|
101
101
|
}>;
|
|
102
|
+
/**
|
|
103
|
+
* Said right after the opening line: `prohost host` resumed this run on
|
|
104
|
+
* another AI login after the first one hit its usage limit, so the turn may
|
|
105
|
+
* be half done.
|
|
106
|
+
*/
|
|
107
|
+
resumeNote?: string;
|
|
108
|
+
/**
|
|
109
|
+
* Ask the agent to end a customer-visible reply with its confidence line
|
|
110
|
+
* (`[[confidence=0.9 reply_class=general]]`): the server sends it only when
|
|
111
|
+
* confident enough and its class needs no approval, else saves a draft.
|
|
112
|
+
*/
|
|
113
|
+
askConfidence?: boolean;
|
|
102
114
|
}
|
|
103
115
|
/** Build the stdin prompt for one run. */
|
|
104
116
|
export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;
|
package/dist/agent/prompt.js
CHANGED
|
@@ -175,6 +175,11 @@ export function buildPrompt(run, context) {
|
|
|
175
175
|
: `You have been given work on a ${run.reply_surface}. There is nowhere to post a reply.`,
|
|
176
176
|
'',
|
|
177
177
|
];
|
|
178
|
+
if (context.resumeNote)
|
|
179
|
+
lines.push(context.resumeNote, '');
|
|
180
|
+
if (context.askConfidence) {
|
|
181
|
+
lines.push('End your reply with one last line, exactly: [[confidence=<0 to 1> reply_class=<class>]] — how sure you are', 'the reply is right and safe to send as is, and its class: general, pricing_action, billing_action,', 'legal_contractual or bug_in_our_code. That line is removed before sending; below the bar, or for a class', 'that needs approval, your reply is saved as a draft for the team instead of sent.', '');
|
|
182
|
+
}
|
|
178
183
|
// The operator's own configuration comes first, above everything the server
|
|
179
184
|
// generated. It is who this agent *is* — persona, house rules, escalation
|
|
180
185
|
// policy — and an agent that meets its standing instructions only after four
|
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,59 @@ 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 sign-in checker that adds `extra` only when it runs `binary` (this
|
|
281
|
+
* harness's Claude): the account snapshot checks other runtimes' logins too,
|
|
282
|
+
* and a Codex check must never see the Claude token.
|
|
283
|
+
*/
|
|
284
|
+
export declare function scopedCommandRunner(binary: string, base: CommandRunner | undefined, extra: () => NodeJS.ProcessEnv): CommandRunner;
|
|
285
|
+
/**
|
|
286
|
+
* A reply without its confidence trailer (`[[confidence=0.9 reply_class=general]]`,
|
|
287
|
+
* see `replyConfidence`), and what the last one said. Every trailer is removed,
|
|
288
|
+
* wherever the model put it and however it dressed it, even one that can't be
|
|
289
|
+
* read: none may reach a guest. A line left with nothing else on it goes too.
|
|
290
|
+
*/
|
|
291
|
+
export declare function splitConfidence(reply: string): {
|
|
292
|
+
text: string;
|
|
293
|
+
confidence?: number;
|
|
294
|
+
replyClass?: string;
|
|
295
|
+
};
|
|
296
|
+
/**
|
|
297
|
+
* Why a run stopped early, when it stopped because something outside said so —
|
|
298
|
+
* or, for `expired`, because a venue run's grant is about to end.
|
|
299
|
+
*/
|
|
300
|
+
type Disposition = 'cancelled' | 'resolved' | 'expired';
|
|
301
|
+
/** What a run in flight can see, and say, about being stopped. */
|
|
302
|
+
interface RunControl {
|
|
303
|
+
/** Fires when the run must stop — cancelled, or closed server-side. */
|
|
304
|
+
signal: AbortSignal;
|
|
305
|
+
/** Why it was stopped, once it was. */
|
|
306
|
+
disposition(): Disposition | undefined;
|
|
307
|
+
/** Publish what the agent is doing, for the next heartbeat to carry. */
|
|
308
|
+
report(activity: string): void;
|
|
309
|
+
}
|
|
221
310
|
interface PreparedMcp {
|
|
222
311
|
/** Claude Code: path of the strict generated MCP config. */
|
|
223
312
|
mcpConfigPath?: string;
|
|
@@ -258,4 +347,30 @@ export declare function codexMcpEntryProvider(options: AgentRunOptions, log: (li
|
|
|
258
347
|
*/
|
|
259
348
|
export declare function codeThreadAccountEnv(account: HarnessAccount | undefined, agent: CodeAgent): AccountEnvResult;
|
|
260
349
|
export declare function runAgentHarness(options: AgentRunOptions): Promise<AgentRunMetrics>;
|
|
261
|
-
|
|
350
|
+
/** What one run executed for `prohost host` came to. */
|
|
351
|
+
export interface HostedRunResult {
|
|
352
|
+
/**
|
|
353
|
+
* As `agent run` settles a run, plus `usage_limited`: the login's usage limit
|
|
354
|
+
* stopped the turn and nothing was reported (see `deferUsageLimit`).
|
|
355
|
+
*/
|
|
356
|
+
outcome: RunOutcome | 'usage_limited';
|
|
357
|
+
/** When the limit lifts (ISO), for a `usage_limited` turn whose brain said. */
|
|
358
|
+
retryAfter?: string;
|
|
359
|
+
/** The model the run used, as far as the harness learnt. */
|
|
360
|
+
model?: string;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Execute one run that `prohost host` has already claimed, the way
|
|
364
|
+
* `agent run` executes one it accepted: the same prompt, Claude Code / Codex
|
|
365
|
+
* shaping, session continuity, tool events, reply and completion.
|
|
366
|
+
*
|
|
367
|
+
* `options.credentials.api_key` is the run's own token, and `options.env`
|
|
368
|
+
* points `PROHOST_HOME` at the employee's home (its sessions and workspace).
|
|
369
|
+
* Heartbeats, cancellation and the queue belong to the caller, which hands
|
|
370
|
+
* the outcome of each to `control`.
|
|
371
|
+
*/
|
|
372
|
+
export declare function executeHostedRun(run: AgentRunRequest, options: AgentRunOptions, control: RunControl, isolation: {
|
|
373
|
+
lane: string;
|
|
374
|
+
parallel: boolean;
|
|
375
|
+
}, api: ApiOptions): Promise<HostedRunResult>;
|
|
376
|
+
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,18 @@ 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
|
+
const muted = /ai_muted/.test(result.error ?? '');
|
|
216
|
+
log(muted
|
|
217
|
+
? `${timestamp()} ◦ ${label} reply was not sent: the thread is muted for AI`
|
|
218
|
+
: `${timestamp()} ◦ ${label} reply was held as a suggestion for the team (the employee's send rule)`);
|
|
219
|
+
return { ...result, ok: true, withheld: muted ? 'muted' : 'suggestion' };
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
async function postReplyOnce(api, send, label, log, sleep) {
|
|
211
224
|
let posted = await send();
|
|
212
225
|
// A real answer — success or a decision — is final. Only "no answer at all"
|
|
213
226
|
// earns a replay.
|
|
@@ -258,6 +271,85 @@ function resolveWorkdir(options, log) {
|
|
|
258
271
|
return undefined;
|
|
259
272
|
}
|
|
260
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* A send the server answered without sending, deliberately: `held_as_suggestion`
|
|
276
|
+
* (saved as a draft for the team — the send rule) or `ai_muted` (the thread is
|
|
277
|
+
* muted; nothing saved, nothing to retry). Either way the run is done.
|
|
278
|
+
*/
|
|
279
|
+
export function isHeldAsSuggestion(result) {
|
|
280
|
+
return result.status === 409 && /held_as_suggestion|ai_muted/.test(result.error ?? '');
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* A sign-in checker that adds `extra` only when it runs `binary` (this
|
|
284
|
+
* harness's Claude): the account snapshot checks other runtimes' logins too,
|
|
285
|
+
* and a Codex check must never see the Claude token.
|
|
286
|
+
*/
|
|
287
|
+
export function scopedCommandRunner(binary, base, extra) {
|
|
288
|
+
return (command, args, env) => (base ?? defaultCommandRunner)(command, args, command === binary ? { ...env, ...extra() } : env);
|
|
289
|
+
}
|
|
290
|
+
/** The run's extra variables, resolved now. */
|
|
291
|
+
function extraEnvOf(options) {
|
|
292
|
+
return typeof options.extraEnv === 'function' ? options.extraEnv() : (options.extraEnv ?? {});
|
|
293
|
+
}
|
|
294
|
+
/** A line that is the confidence trailer, however the model dressed it (backticks, bold, commas, quotes). */
|
|
295
|
+
/** A trailer token anywhere, with any decoration hugging it. */
|
|
296
|
+
const TRAILER_TOKEN = /[`*_]*\[\[\s*confidence\b[^\]]*\]\][`*_]*\.?/gi;
|
|
297
|
+
/** The confidence a trailer states: a plain decimal from 0 to 1, read whole — anything else is no confidence. */
|
|
298
|
+
function trailerConfidence(token) {
|
|
299
|
+
const raw = /confidence\s*[=:]\s*["']?([^\s,"'\]]+)/i.exec(token)?.[1];
|
|
300
|
+
if (!raw || !/^(?:0|1|0?\.\d+|1\.0+|0\.\d+)$/.test(raw))
|
|
301
|
+
return undefined;
|
|
302
|
+
const value = Number(raw);
|
|
303
|
+
return value >= 0 && value <= 1 ? value : undefined;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* A reply without its confidence trailer (`[[confidence=0.9 reply_class=general]]`,
|
|
307
|
+
* see `replyConfidence`), and what the last one said. Every trailer is removed,
|
|
308
|
+
* wherever the model put it and however it dressed it, even one that can't be
|
|
309
|
+
* read: none may reach a guest. A line left with nothing else on it goes too.
|
|
310
|
+
*/
|
|
311
|
+
export function splitConfidence(reply) {
|
|
312
|
+
const tokens = [...reply.matchAll(TRAILER_TOKEN)];
|
|
313
|
+
if (tokens.length === 0)
|
|
314
|
+
return { text: reply };
|
|
315
|
+
const last = tokens[tokens.length - 1][0];
|
|
316
|
+
// Whole tokens first (one may wrap onto the next line), marked; then a line
|
|
317
|
+
// left with nothing but a mark and decoration goes.
|
|
318
|
+
const MARK = '\u0000';
|
|
319
|
+
const text = reply
|
|
320
|
+
.replace(TRAILER_TOKEN, MARK)
|
|
321
|
+
.split('\n')
|
|
322
|
+
.flatMap((line) => {
|
|
323
|
+
if (!line.includes(MARK))
|
|
324
|
+
return [line];
|
|
325
|
+
const cleaned = line.split(MARK).join('').replace(/[ \t]+$/, '');
|
|
326
|
+
return /^[\s`*_>.]*$/.test(cleaned) ? [] : [cleaned];
|
|
327
|
+
})
|
|
328
|
+
.join('\n')
|
|
329
|
+
.replace(/\s+$/, '');
|
|
330
|
+
const confidence = trailerConfidence(last);
|
|
331
|
+
const replyClass = /reply_class\s*[=:]\s*["']?([A-Za-z_]{1,40})/i.exec(last)?.[1]?.toLowerCase();
|
|
332
|
+
if (confidence === undefined)
|
|
333
|
+
return { text };
|
|
334
|
+
return { text, confidence, ...(replyClass ? { replyClass } : {}) };
|
|
335
|
+
}
|
|
336
|
+
/** The send extras for one answer: the run's own, plus what its trailer said. */
|
|
337
|
+
function shapeAnswer(answer, extra, on) {
|
|
338
|
+
if (!on)
|
|
339
|
+
return { text: answer, extra };
|
|
340
|
+
const shaped = splitConfidence(answer);
|
|
341
|
+
return {
|
|
342
|
+
text: shaped.text,
|
|
343
|
+
extra: shaped.confidence !== undefined
|
|
344
|
+
? { ...extra, confidence: shaped.confidence, ...(shaped.replyClass ? { reply_class: shaped.replyClass } : {}) }
|
|
345
|
+
: extra,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* A Codex turn its usage limit stopped. Codex says so in its own words on a
|
|
350
|
+
* failed turn; only `prohost host` acts on it (`deferUsageLimit`).
|
|
351
|
+
*/
|
|
352
|
+
const CODEX_USAGE_LIMIT = /usage limit|usage_limit_reached|hit your limit/i;
|
|
261
353
|
/**
|
|
262
354
|
* The lane a run waits on: runs sharing one never overlap.
|
|
263
355
|
*
|
|
@@ -278,7 +370,7 @@ function laneFor(run) {
|
|
|
278
370
|
*/
|
|
279
371
|
function buildRuntime(options, log) {
|
|
280
372
|
const workdir = resolveWorkdir(options, log);
|
|
281
|
-
const sessions = SessionStore.load(options.env, log);
|
|
373
|
+
const sessions = options.sessions ?? SessionStore.load(options.env, log);
|
|
282
374
|
const brain = isClaudeExec(options.exec)
|
|
283
375
|
? 'claude'
|
|
284
376
|
: isCodexExec(options.exec)
|
|
@@ -307,11 +399,15 @@ function buildRuntime(options, log) {
|
|
|
307
399
|
env: options.env,
|
|
308
400
|
initialLabel: options.credentials.account,
|
|
309
401
|
binary: brain === 'claude' ? (options.exec.trim().split(/\s+/)[0] ?? 'claude') : codexBinary(options.exec),
|
|
310
|
-
run
|
|
402
|
+
// A Claude harness's sign-in check sees the run's extra variables too (the Claude token).
|
|
403
|
+
run: brain === 'claude' && options.extraEnv
|
|
404
|
+
? scopedCommandRunner(options.exec.trim().split(/\s+/)[0] ?? 'claude', options.accountCommandRunner, () => extraEnvOf(options))
|
|
405
|
+
: options.accountCommandRunner,
|
|
311
406
|
warn: (message) => log(`${timestamp()} ! ${message}`),
|
|
312
407
|
});
|
|
313
408
|
const mcpUrl = options.credentials.mcp_url;
|
|
314
409
|
let announced = '';
|
|
410
|
+
const quiet = options.quietStartup === true;
|
|
315
411
|
const announce = (lines) => {
|
|
316
412
|
const message = lines.join('\n');
|
|
317
413
|
if (message === announced)
|
|
@@ -320,7 +416,7 @@ function buildRuntime(options, log) {
|
|
|
320
416
|
log(line);
|
|
321
417
|
announced = message;
|
|
322
418
|
};
|
|
323
|
-
if (!mcpUrl) {
|
|
419
|
+
if (!mcpUrl && !quiet) {
|
|
324
420
|
log(`${timestamp()} ! this pairing carries no MCP endpoint; the agent will run without ProhostAI tools`);
|
|
325
421
|
}
|
|
326
422
|
const prepareMcp = brain === 'claude'
|
|
@@ -336,6 +432,7 @@ function buildRuntime(options, log) {
|
|
|
336
432
|
apiKey: options.credentials.api_key,
|
|
337
433
|
extraServers: extras.servers,
|
|
338
434
|
env: options.env,
|
|
435
|
+
...(options.mcpConfigFile ? { file: options.mcpConfigFile } : {}),
|
|
339
436
|
});
|
|
340
437
|
return { mcpConfigPath, mcpWired: Boolean(mcpUrl) };
|
|
341
438
|
}
|
|
@@ -350,19 +447,21 @@ function buildRuntime(options, log) {
|
|
|
350
447
|
: codexMcpEntryProvider(options, log, workdir, () => account.spawnEnv());
|
|
351
448
|
const tools = mcpUrl ? `, ProhostAI tools prepared per run as ${MCP_SERVER_NAME}` : '';
|
|
352
449
|
const where = workdir ? ` (workspace: ${workdir})` : '';
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
450
|
+
if (!quiet)
|
|
451
|
+
log(brain === 'claude'
|
|
452
|
+
? `${timestamp()} ⚙ Claude Code detected — resuming a session per conversation${tools}${where}`
|
|
453
|
+
: `${timestamp()} ⚙ Codex detected — resuming a thread per conversation${tools}${where}`);
|
|
356
454
|
// Said out loud every start-up, not buried in --help. This is the line that
|
|
357
455
|
// tells an operator their machine will run tool calls, unprompted, on text
|
|
358
456
|
// written by someone else in their workspace.
|
|
359
457
|
const bypassFlag = brain === 'claude' ? '--dangerously-skip-permissions' : '--yolo';
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
458
|
+
if (!quiet)
|
|
459
|
+
log(skipPermissions
|
|
460
|
+
? `${timestamp()} ⚠ tools run WITHOUT permission prompts ` +
|
|
461
|
+
`(${bypassFlag}). The agent acts as you, with your local ` +
|
|
462
|
+
'access, on instructions from your workspace. Use --safe-tools to gate them.'
|
|
463
|
+
: `${timestamp()} ⚙ --safe-tools: tools are permission-gated; anything needing ` +
|
|
464
|
+
'approval will not run (nobody is at a terminal to approve it).');
|
|
366
465
|
// The account is resolved before anything is prepared or spawned, and its
|
|
367
466
|
// environment rides every spawn of this run. A missing account fails the
|
|
368
467
|
// run closed, the same way an unprovable MCP boundary does.
|
|
@@ -377,7 +476,8 @@ function buildRuntime(options, log) {
|
|
|
377
476
|
accountLabel: resolved.record.label,
|
|
378
477
|
};
|
|
379
478
|
};
|
|
380
|
-
|
|
479
|
+
if (!quiet)
|
|
480
|
+
log(`${timestamp()} ⚙ account: ${account.label()}`);
|
|
381
481
|
return {
|
|
382
482
|
workdir,
|
|
383
483
|
sessions,
|
|
@@ -884,6 +984,16 @@ onEarlyAnswer) {
|
|
|
884
984
|
// the ledger can't know. One retry without it, because the alternative is a
|
|
885
985
|
// surface that fails every run forever over an optimisation.
|
|
886
986
|
const resumeFailed = codex ? looksLikeCodexResumeFailure : looksLikeResumeFailure;
|
|
987
|
+
/**
|
|
988
|
+
* A Codex turn its usage limit stopped may already have acted. For
|
|
989
|
+
* `prohost host` (which moves the thread to another login) its thread is
|
|
990
|
+
* remembered, so the next login continues it rather than starting over.
|
|
991
|
+
*/
|
|
992
|
+
const keepCodexThreadOnLimit = (threadId, said) => {
|
|
993
|
+
if (!options.deferUsageLimit || !sessionKey || !threadId || !CODEX_USAGE_LIMIT.test(said))
|
|
994
|
+
return;
|
|
995
|
+
runtime.sessions.remember(sessionKey, threadId);
|
|
996
|
+
};
|
|
887
997
|
if (!result.ok &&
|
|
888
998
|
sessionId &&
|
|
889
999
|
// A usage limit fails a fresh session exactly as it failed this one, and
|
|
@@ -911,6 +1021,8 @@ onEarlyAnswer) {
|
|
|
911
1021
|
if (!result.timedOut && !result.idleTimedOut && !result.cancelled && !stream?.usageLimit()) {
|
|
912
1022
|
forgetRecordedSession();
|
|
913
1023
|
}
|
|
1024
|
+
if (codex)
|
|
1025
|
+
keepCodexThreadOnLimit(parseCodexOutput(result.stdout).sessionId, `${codexFailureMessage(result.stdout) ?? ''}\n${result.stderr}`);
|
|
914
1026
|
return failure(result);
|
|
915
1027
|
}
|
|
916
1028
|
// A denied tool is not an error either brain reports — the turn succeeds and
|
|
@@ -929,6 +1041,7 @@ onEarlyAnswer) {
|
|
|
929
1041
|
runtime.observe(readCodexObservation(output.sessionId, { ...process.env, ...options.env, ...runtime.account?.spawnEnv() }));
|
|
930
1042
|
reportDenials(output.denials);
|
|
931
1043
|
if (!output.ok) {
|
|
1044
|
+
keepCodexThreadOnLimit(output.sessionId, output.error ?? '');
|
|
932
1045
|
return {
|
|
933
1046
|
ok: false,
|
|
934
1047
|
reason: 'the agent did not produce a usable reply',
|
|
@@ -977,7 +1090,9 @@ onEarlyAnswer) {
|
|
|
977
1090
|
*/
|
|
978
1091
|
async function handleRun(run, options, runtime, api, log, sleep, control, isolation,
|
|
979
1092
|
/** Prepared by the caller, which also hands the run's account back. */
|
|
980
|
-
mcp
|
|
1093
|
+
mcp,
|
|
1094
|
+
/** Filled for a turn handed back as `usage_limited` (see `deferUsageLimit`). */
|
|
1095
|
+
limited) {
|
|
981
1096
|
const label = `run ${run.run_id.slice(0, 12)}`;
|
|
982
1097
|
const surface = run.conversation_id ?? run.kind ?? 'no conversation';
|
|
983
1098
|
// Answered with the run's own token, by a session with no tools and none of
|
|
@@ -1020,6 +1135,8 @@ mcp) {
|
|
|
1020
1135
|
parallel: isolation.parallel,
|
|
1021
1136
|
checkouts: checkouts?.checkouts,
|
|
1022
1137
|
checkoutFailures: checkouts?.failures,
|
|
1138
|
+
...(options.resumeNote ? { resumeNote: options.resumeNote } : {}),
|
|
1139
|
+
...(options.replyConfidence && run.reply_surface === 'conversation' ? { askConfidence: true } : {}),
|
|
1023
1140
|
});
|
|
1024
1141
|
// Tool-call narration for the thread. A dry run makes no writes, so it gets
|
|
1025
1142
|
// no path and every event is dropped; so does a server that advertised none.
|
|
@@ -1029,7 +1146,7 @@ mcp) {
|
|
|
1029
1146
|
log,
|
|
1030
1147
|
label,
|
|
1031
1148
|
});
|
|
1032
|
-
const runEnv = { [PAIRED_RUN_ENV_VAR]: '1' };
|
|
1149
|
+
const runEnv = { ...extraEnvOf(options), [PAIRED_RUN_ENV_VAR]: '1' };
|
|
1033
1150
|
if (checkouts && checkouts.checkouts.length > 0)
|
|
1034
1151
|
runEnv[RUN_REPOS_ENV_VAR] = checkouts.dir;
|
|
1035
1152
|
// The last moment before the agent starts: a venue run that waited in the
|
|
@@ -1065,6 +1182,8 @@ mcp) {
|
|
|
1065
1182
|
let earlyCount = 0;
|
|
1066
1183
|
let earlyPosted = 0;
|
|
1067
1184
|
let earlyPosts = Promise.resolve();
|
|
1185
|
+
/** An answer of this run was dropped because the thread is muted: its session must not continue. */
|
|
1186
|
+
let muted = false;
|
|
1068
1187
|
const earlyPath = run.reply_path;
|
|
1069
1188
|
const earlyBodyKey = run.reply_body_key;
|
|
1070
1189
|
const stoppedNow = () => control.signal.aborted || control.disposition() !== undefined;
|
|
@@ -1081,7 +1200,10 @@ mcp) {
|
|
|
1081
1200
|
const { answer, key } = early[0];
|
|
1082
1201
|
const posted = await postReplyWithReplay(api, () => stoppedNow()
|
|
1083
1202
|
? Promise.resolve({ ok: false, status: -1, error: 'the run was stopped' })
|
|
1084
|
-
:
|
|
1203
|
+
: (() => {
|
|
1204
|
+
const shaped = shapeAnswer(answer, run.reply_body_extra, options.replyConfidence);
|
|
1205
|
+
return postReply(api, earlyPath, earlyBodyKey, shaped.text, key, shaped.extra);
|
|
1206
|
+
})(), label, log, sleep);
|
|
1085
1207
|
if (!posted.ok) {
|
|
1086
1208
|
if (!stoppedNow())
|
|
1087
1209
|
log(`${timestamp()} ! ${label} could not post an answer yet (${describeFailure(posted)})`);
|
|
@@ -1089,6 +1211,8 @@ mcp) {
|
|
|
1089
1211
|
}
|
|
1090
1212
|
early.shift();
|
|
1091
1213
|
earlyPosted += 1;
|
|
1214
|
+
if (posted.withheld === 'muted')
|
|
1215
|
+
muted = true;
|
|
1092
1216
|
log(`${timestamp()} ✓ ${label} posted an answer; a background task is still running`);
|
|
1093
1217
|
}
|
|
1094
1218
|
};
|
|
@@ -1113,6 +1237,17 @@ mcp) {
|
|
|
1113
1237
|
// A venue session is never continued: the next turn there starts fresh from
|
|
1114
1238
|
// the thread the server sends, so nothing of this one is remembered.
|
|
1115
1239
|
const sessionId = venue ? undefined : result.ok ? result.sessionId : undefined;
|
|
1240
|
+
/**
|
|
1241
|
+
* Continue the conversation's session from this turn — unless an answer of
|
|
1242
|
+
* it was dropped because the thread is muted (any runtime): then the session
|
|
1243
|
+
* holds a turn nobody saw, so it is forgotten, eager record or resumed one alike.
|
|
1244
|
+
*/
|
|
1245
|
+
const settleSession = (id) => {
|
|
1246
|
+
if (muted)
|
|
1247
|
+
runtime.sessions.forget(sessionKeyFor(run));
|
|
1248
|
+
else if (id)
|
|
1249
|
+
runtime.sessions.remember(sessionKeyFor(run), id);
|
|
1250
|
+
};
|
|
1116
1251
|
// Whatever the turn did, the timeline is whole BEFORE the reply is posted or
|
|
1117
1252
|
// the run is closed — a completion that beats its own last tool row would
|
|
1118
1253
|
// leave a `calling` row on a finished run. Never rejects, and after `stop`
|
|
@@ -1159,6 +1294,18 @@ mcp) {
|
|
|
1159
1294
|
}
|
|
1160
1295
|
return 'cancelled';
|
|
1161
1296
|
}
|
|
1297
|
+
if (!result.ok && options.deferUsageLimit && !options.dryRun && !venue) {
|
|
1298
|
+
const usageLimit = result.usageLimit ??
|
|
1299
|
+
(runtime.brain === 'codex' && CODEX_USAGE_LIMIT.test(`${result.reason}\n${result.detail ?? ''}`) ? {} : undefined);
|
|
1300
|
+
if (usageLimit) {
|
|
1301
|
+
// Not completed: the caller moves the conversation to another login of
|
|
1302
|
+
// the same kind, or completes it with this limit itself.
|
|
1303
|
+
log(`${timestamp()} ⏸ ${label} hit this login's usage limit after ${seconds}s`);
|
|
1304
|
+
if (limited && usageLimit.retryAfter)
|
|
1305
|
+
limited.retryAfter = usageLimit.retryAfter;
|
|
1306
|
+
return 'usage_limited';
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1162
1309
|
if (!result.ok) {
|
|
1163
1310
|
const { reason } = result;
|
|
1164
1311
|
// A venue run's detail is stderr and the agent's unfinished answer — the
|
|
@@ -1183,7 +1330,9 @@ mcp) {
|
|
|
1183
1330
|
}
|
|
1184
1331
|
return 'failed';
|
|
1185
1332
|
}
|
|
1186
|
-
const
|
|
1333
|
+
const shaped = shapeAnswer(result.reply, run.reply_body_extra, options.replyConfidence);
|
|
1334
|
+
const reply = shaped.text;
|
|
1335
|
+
const sendExtra = shaped.extra;
|
|
1187
1336
|
if (options.dryRun) {
|
|
1188
1337
|
const destination = run.reply_surface === 'none' ? '(no reply surface)' : `${run.reply_surface} ${run.reply_path}`;
|
|
1189
1338
|
log(`${timestamp()} ◦ ${label} dry-run — would post to ${destination}:`);
|
|
@@ -1220,9 +1369,7 @@ mcp) {
|
|
|
1220
1369
|
log(` ! could not mark the run complete (${describeFailure(completed)})`);
|
|
1221
1370
|
return 'unreported';
|
|
1222
1371
|
}
|
|
1223
|
-
|
|
1224
|
-
runtime.sessions.remember(sessionKeyFor(run), sessionId);
|
|
1225
|
-
}
|
|
1372
|
+
settleSession(sessionId);
|
|
1226
1373
|
return 'succeeded';
|
|
1227
1374
|
}
|
|
1228
1375
|
// A run with no reply surface is a legitimate outcome, not an error: it was
|
|
@@ -1242,14 +1389,12 @@ mcp) {
|
|
|
1242
1389
|
log(` ! could not mark the run complete (${describeFailure(completed)})`);
|
|
1243
1390
|
return 'unreported';
|
|
1244
1391
|
}
|
|
1245
|
-
|
|
1246
|
-
runtime.sessions.remember(sessionKeyFor(run), sessionId);
|
|
1247
|
-
}
|
|
1392
|
+
settleSession(sessionId);
|
|
1248
1393
|
return 'succeeded';
|
|
1249
1394
|
}
|
|
1250
1395
|
const replyPath = run.reply_path;
|
|
1251
1396
|
const replyBodyKey = run.reply_body_key;
|
|
1252
|
-
const posted = await postReplyWithReplay(api, () => postReply(api, replyPath, replyBodyKey, reply, run.run_id,
|
|
1397
|
+
const posted = await postReplyWithReplay(api, () => postReply(api, replyPath, replyBodyKey, reply, run.run_id, sendExtra), label, log, sleep);
|
|
1253
1398
|
if (!posted.ok) {
|
|
1254
1399
|
const failure = describeFailure(posted);
|
|
1255
1400
|
// The server's reason can quote the thread (a reply-race refusal previews
|
|
@@ -1267,10 +1412,11 @@ mcp) {
|
|
|
1267
1412
|
}
|
|
1268
1413
|
// Advance continuity only after the answer is durable on its reply surface.
|
|
1269
1414
|
// Remembering at process exit would strand the conversation behind an answer
|
|
1270
|
-
// it never received whenever this POST is rejected.
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1415
|
+
// it never received whenever this POST is rejected. A muted thread kept
|
|
1416
|
+
// nothing of it, so its session is not advanced either (and an eager record undone).
|
|
1417
|
+
if (posted.withheld === 'muted')
|
|
1418
|
+
muted = true;
|
|
1419
|
+
settleSession(sessionId);
|
|
1274
1420
|
const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep, runtime.model());
|
|
1275
1421
|
if (!completed.ok) {
|
|
1276
1422
|
// The reply is already in the conversation, so this is a bookkeeping
|
|
@@ -1400,7 +1546,10 @@ export async function runAgentHarness(options) {
|
|
|
1400
1546
|
const status = new RuntimeStatusReporter({
|
|
1401
1547
|
runtime: runtime.runtimeName,
|
|
1402
1548
|
provider: options.quotaProvider === undefined
|
|
1403
|
-
? defaultQuotaProvider(runtime.brain, options.exec, () =>
|
|
1549
|
+
? defaultQuotaProvider(runtime.brain, options.exec, () => ({
|
|
1550
|
+
...runtime.account?.spawnEnv(),
|
|
1551
|
+
...(runtime.brain === 'claude' ? extraEnvOf(options) : {}),
|
|
1552
|
+
}))
|
|
1404
1553
|
: (options.quotaProvider ?? undefined),
|
|
1405
1554
|
accounts: runtime.account
|
|
1406
1555
|
? (known) => runtime.account.snapshot([...accepted.values()].some((entry) => entry.executing), known)
|
|
@@ -2179,3 +2328,42 @@ export async function runAgentHarness(options) {
|
|
|
2179
2328
|
});
|
|
2180
2329
|
}
|
|
2181
2330
|
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Execute one run that `prohost host` has already claimed, the way
|
|
2333
|
+
* `agent run` executes one it accepted: the same prompt, Claude Code / Codex
|
|
2334
|
+
* shaping, session continuity, tool events, reply and completion.
|
|
2335
|
+
*
|
|
2336
|
+
* `options.credentials.api_key` is the run's own token, and `options.env`
|
|
2337
|
+
* points `PROHOST_HOME` at the employee's home (its sessions and workspace).
|
|
2338
|
+
* Heartbeats, cancellation and the queue belong to the caller, which hands
|
|
2339
|
+
* the outcome of each to `control`.
|
|
2340
|
+
*/
|
|
2341
|
+
export async function executeHostedRun(run, options, control, isolation, api) {
|
|
2342
|
+
const log = options.onLine ?? ((line) => process.stdout.write(`${line}\n`));
|
|
2343
|
+
const sleep = options.sleepImpl ?? defaultSleep;
|
|
2344
|
+
const runtime = buildRuntime(options, log);
|
|
2345
|
+
const worktrees = new WorktreeManager({ repos: options.repos ?? [], env: options.env, log });
|
|
2346
|
+
let mcp;
|
|
2347
|
+
try {
|
|
2348
|
+
mcp = runtime.prepareMcp();
|
|
2349
|
+
}
|
|
2350
|
+
catch (err) {
|
|
2351
|
+
mcp = { mcpWired: false, error: `could not prepare the run (${String(err)})` };
|
|
2352
|
+
}
|
|
2353
|
+
const limited = {};
|
|
2354
|
+
try {
|
|
2355
|
+
const outcome = await handleRun(run, options, runtime, api, log, sleep, control, { lane: isolation.lane, parallel: isolation.parallel, worktrees }, mcp, limited).catch((err) => {
|
|
2356
|
+
log(`${timestamp()} ✗ run ${run.run_id} crashed the run handler: ${String(err)}`);
|
|
2357
|
+
return 'failed';
|
|
2358
|
+
});
|
|
2359
|
+
return {
|
|
2360
|
+
outcome,
|
|
2361
|
+
...(outcome === 'usage_limited' && limited.retryAfter ? { retryAfter: limited.retryAfter } : {}),
|
|
2362
|
+
model: runtime.model(),
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
finally {
|
|
2366
|
+
if (mcp.accountLabel !== undefined)
|
|
2367
|
+
runtime.account?.finishSpawn(mcp.accountLabel);
|
|
2368
|
+
}
|
|
2369
|
+
}
|
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>;
|