@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +266 -1
  3. package/dist/agent/accounts.d.ts +11 -0
  4. package/dist/agent/accounts.js +12 -1
  5. package/dist/agent/api.d.ts +12 -0
  6. package/dist/agent/api.js +5 -0
  7. package/dist/agent/claude.d.ts +6 -0
  8. package/dist/agent/claude.js +2 -2
  9. package/dist/agent/daemon.d.ts +33 -0
  10. package/dist/agent/daemon.js +56 -0
  11. package/dist/agent/prompt.d.ts +12 -0
  12. package/dist/agent/prompt.js +5 -0
  13. package/dist/agent/run.d.ts +116 -1
  14. package/dist/agent/run.js +217 -29
  15. package/dist/codehost/host.d.ts +25 -0
  16. package/dist/codehost/host.js +16 -1
  17. package/dist/codehost/host_stats.d.ts +49 -2
  18. package/dist/codehost/host_stats.js +153 -2
  19. package/dist/codehost/relay.d.ts +10 -0
  20. package/dist/codehost/relay.js +17 -1
  21. package/dist/host/api.d.ts +82 -0
  22. package/dist/host/api.js +122 -0
  23. package/dist/host/capacity.d.ts +41 -0
  24. package/dist/host/capacity.js +48 -0
  25. package/dist/host/command.d.ts +59 -0
  26. package/dist/host/command.js +597 -0
  27. package/dist/host/computer.d.ts +120 -0
  28. package/dist/host/computer.js +291 -0
  29. package/dist/host/events.d.ts +24 -0
  30. package/dist/host/events.js +47 -0
  31. package/dist/host/install.d.ts +154 -0
  32. package/dist/host/install.js +784 -0
  33. package/dist/host/ledger.d.ts +52 -0
  34. package/dist/host/ledger.js +131 -0
  35. package/dist/host/legacy.d.ts +38 -0
  36. package/dist/host/legacy.js +115 -0
  37. package/dist/host/logins.d.ts +109 -0
  38. package/dist/host/logins.js +190 -0
  39. package/dist/host/migrate.d.ts +165 -0
  40. package/dist/host/migrate.js +364 -0
  41. package/dist/host/pair.d.ts +62 -0
  42. package/dist/host/pair.js +219 -0
  43. package/dist/host/peak_memory.d.ts +44 -0
  44. package/dist/host/peak_memory.js +139 -0
  45. package/dist/host/scheduler.d.ts +86 -0
  46. package/dist/host/scheduler.js +186 -0
  47. package/dist/host/service.d.ts +114 -0
  48. package/dist/host/service.js +1027 -0
  49. package/dist/host/session_move.d.ts +48 -0
  50. package/dist/host/session_move.js +106 -0
  51. package/dist/host/skills.d.ts +29 -0
  52. package/dist/host/skills.js +135 -0
  53. package/dist/host/status.d.ts +91 -0
  54. package/dist/host/status.js +130 -0
  55. package/dist/index.d.ts +4 -0
  56. package/dist/index.js +47 -3
  57. package/dist/version.d.ts +2 -2
  58. package/dist/version.js +1 -1
  59. package/package.json +1 -1
@@ -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;
@@ -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
@@ -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
- export {};
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: options.accountCommandRunner,
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
- log(brain === 'claude'
354
- ? `${timestamp()} ⚙ Claude Code detected — resuming a session per conversation${tools}${where}`
355
- : `${timestamp()} ⚙ Codex detected — resuming a thread per conversation${tools}${where}`);
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
- log(skipPermissions
361
- ? `${timestamp()} ⚠ tools run WITHOUT permission prompts ` +
362
- `(${bypassFlag}). The agent acts as you, with your local ` +
363
- 'access, on instructions from your workspace. Use --safe-tools to gate them.'
364
- : `${timestamp()} ⚙ --safe-tools: tools are permission-gated; anything needing ` +
365
- 'approval will not run (nobody is at a terminal to approve it).');
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
- log(`${timestamp()} ⚙ account: ${account.label()}`);
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
- : postReply(api, earlyPath, earlyBodyKey, answer, key, run.reply_body_extra), label, log, sleep);
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 { reply } = result;
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
- if (sessionId) {
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
- if (sessionId) {
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, run.reply_body_extra), label, log, sleep);
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
- if (sessionId) {
1272
- runtime.sessions.remember(sessionKeyFor(run), sessionId);
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, () => runtime.account?.spawnEnv() ?? {})
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
+ }
@@ -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>;