@prohost/cli 0.14.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +265 -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 +6 -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 +110 -1
  14. package/dist/agent/run.js +198 -22
  15. package/dist/codehost/history.d.ts +80 -0
  16. package/dist/codehost/history.js +98 -0
  17. package/dist/codehost/hooks.d.ts +10 -2
  18. package/dist/codehost/hooks.js +15 -4
  19. package/dist/codehost/host.d.ts +32 -0
  20. package/dist/codehost/host.js +26 -1
  21. package/dist/codehost/host_stats.d.ts +49 -2
  22. package/dist/codehost/host_stats.js +153 -2
  23. package/dist/codehost/relay.d.ts +16 -1
  24. package/dist/codehost/relay.js +69 -3
  25. package/dist/codehost/sizing.d.ts +22 -4
  26. package/dist/codehost/sizing.js +46 -6
  27. package/dist/codehost/terminals.d.ts +33 -2
  28. package/dist/codehost/terminals.js +92 -30
  29. package/dist/codehost/tmux.d.ts +6 -1
  30. package/dist/codehost/tmux.js +3 -0
  31. package/dist/host/api.d.ts +82 -0
  32. package/dist/host/api.js +122 -0
  33. package/dist/host/capacity.d.ts +41 -0
  34. package/dist/host/capacity.js +48 -0
  35. package/dist/host/command.d.ts +59 -0
  36. package/dist/host/command.js +584 -0
  37. package/dist/host/computer.d.ts +120 -0
  38. package/dist/host/computer.js +291 -0
  39. package/dist/host/events.d.ts +24 -0
  40. package/dist/host/events.js +47 -0
  41. package/dist/host/install.d.ts +152 -0
  42. package/dist/host/install.js +755 -0
  43. package/dist/host/ledger.d.ts +52 -0
  44. package/dist/host/ledger.js +131 -0
  45. package/dist/host/legacy.d.ts +38 -0
  46. package/dist/host/legacy.js +115 -0
  47. package/dist/host/logins.d.ts +109 -0
  48. package/dist/host/logins.js +190 -0
  49. package/dist/host/migrate.d.ts +165 -0
  50. package/dist/host/migrate.js +364 -0
  51. package/dist/host/pair.d.ts +62 -0
  52. package/dist/host/pair.js +219 -0
  53. package/dist/host/peak_memory.d.ts +44 -0
  54. package/dist/host/peak_memory.js +139 -0
  55. package/dist/host/scheduler.d.ts +86 -0
  56. package/dist/host/scheduler.js +186 -0
  57. package/dist/host/service.d.ts +114 -0
  58. package/dist/host/service.js +1027 -0
  59. package/dist/host/session_move.d.ts +48 -0
  60. package/dist/host/session_move.js +106 -0
  61. package/dist/host/skills.d.ts +29 -0
  62. package/dist/host/skills.js +135 -0
  63. package/dist/host/status.d.ts +91 -0
  64. package/dist/host/status.js +130 -0
  65. package/dist/index.d.ts +4 -0
  66. package/dist/index.js +47 -3
  67. package/dist/version.d.ts +2 -2
  68. package/dist/version.js +1 -1
  69. package/package.json +1 -1
@@ -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,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
- export {};
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';
@@ -52,7 +53,7 @@ import { SessionStore, sessionKeyFor } from './sessionstore.js';
52
53
  import { verifySignature } from './signature.js';
53
54
  import { VENUE_RUN_TOKEN_CAPABILITY, apiForRun, claudeSupportsVenueSessions, claudeVenueCommand, VENUE_PROGRESS, VENUE_SESSION_FEATURE, VENUE_SESSION_FRAME, VENUE_SESSION_OPEN_FRAME, VENUE_GRANT_CHECK_MS, VENUE_EXPIRY_MARGIN_MS, VENUE_GRANT_ENDED_ERROR, VENUE_GRANT_EXPIRED_ERROR, isVenueRun, venueEnv, venueGrantLive, venueGrantRemainingMs, venueRunFromSession, venueWorkdir, } from './venue.js';
54
55
  import { RUN_REPOS_ENV_VAR, WorktreeManager } from './worktrees.js';
55
- import { CODE_THREAD_FOLDERS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, RefusedDeliveryError, REMOTE_TERMINAL_CAPABILITY, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, } from '../codehost/host.js';
56
+ import { CODE_THREAD_FOLDERS_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, RefusedDeliveryError, REMOTE_TERMINAL_CAPABILITY, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, } from '../codehost/host.js';
56
57
  import { loadSettings as loadCodeHostSettings } from '../codehost/settings.js';
57
58
  import { isWebhookEvent } from '../forward.js';
58
59
  import { DEFAULT_WS_URL } from '../listen.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: options.accountCommandRunner,
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
- 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}`);
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
- 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).');
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
- log(`${timestamp()} ⚙ account: ${account.label()}`);
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
- : postReply(api, earlyPath, earlyBodyKey, answer, key, run.reply_body_extra), label, log, sleep);
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 { reply } = result;
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, run.reply_body_extra), label, log, sleep);
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, () => runtime.account?.spawnEnv() ?? {})
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)
@@ -1418,10 +1546,19 @@ export async function runAgentHarness(options) {
1418
1546
  // With folder starts (`folder` / `separateCopy`): the server sends one only to a socket saying so.
1419
1547
  // With the agents whose binaries resolve here, re-checked per frame: the server offers only those.
1420
1548
  ...(codeHost?.authorized()
1421
- ? [CODE_THREADS_CAPABILITY, CODE_THREAD_FOLDERS_CAPABILITY, codeHost.instanceCapability(), ...(codeHost.agentCapabilities?.() ?? [])]
1549
+ ? [
1550
+ CODE_THREADS_CAPABILITY,
1551
+ CODE_THREAD_FOLDERS_CAPABILITY,
1552
+ codeHost.instanceCapability(),
1553
+ ...(codeHost.agentCapabilities?.() ?? []),
1554
+ ]
1422
1555
  : []),
1423
1556
  // Read on every frame, so `remote off` stops being declared at the next one.
1424
- ...(codeHost?.authorized() && codeHost.remoteTerminalAllowed() ? [REMOTE_TERMINAL_CAPABILITY] : []),
1557
+ // With it, skipping permission prompts (`skipPermissions` on a start): only while remote control
1558
+ // is on, as the host applies it only then; the server records a thread as skipping only so.
1559
+ ...(codeHost?.authorized() && codeHost.remoteTerminalAllowed()
1560
+ ? [REMOTE_TERMINAL_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY]
1561
+ : []),
1425
1562
  ],
1426
1563
  // `--machine` names this machine everywhere, exactly as given: whoever set it never sends the
1427
1564
  // real hostname. Only the real hostname is cut to its short form.
@@ -2170,3 +2307,42 @@ export async function runAgentHarness(options) {
2170
2307
  });
2171
2308
  }
2172
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
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * A viewer's local scroll-back (CONTRACTS-R2 R1): the pane's history as text, so the viewer
3
+ * scrolls it on its own screen instead of driving tmux copy mode over the network (one wheel
4
+ * step per round trip, and copy mode is the PANE's: one viewer's scroll froze everyone's view).
5
+ *
6
+ * - client → server: `{"type":"history","id":<string>,"lines":<int, capped 10000>}`;
7
+ * - server → that client only: `{"type":"history","id","text","cols","rows"}`, the history and
8
+ * visible screen as `tmux capture-pane -p -e -J -S -<lines>` prints them (SGR colours kept,
9
+ * wrapped lines joined), or `{"type":"history","id","error":"<code>"}`.
10
+ *
11
+ * Answered only to a viewer that said `hello` (an older page never sees one), and bounded: a
12
+ * frame never exceeds {@link HISTORY_MAX_FRAME_BYTES}; the oldest lines go first.
13
+ */
14
+ /** Most lines a viewer may ask for. */
15
+ export declare const HISTORY_MAX_LINES = 10000;
16
+ /** Lines asked for when a request names none (or an unusable number). */
17
+ export declare const HISTORY_DEFAULT_LINES = 2000;
18
+ /**
19
+ * Largest answer, as sent (JSON): the relay carries it over Redis and the cloud pipe's upstream
20
+ * socket takes at most 1 MiB, so this stays well under both.
21
+ */
22
+ export declare const HISTORY_MAX_FRAME_BYTES: number;
23
+ /** A request id: what the viewer matches its answer by. */
24
+ export declare const HISTORY_ID_PATTERN: RegExp;
25
+ export type HistoryError = 'alternate_screen' | 'capture_failed' | 'unavailable';
26
+ export interface HistoryRequest {
27
+ type: 'history';
28
+ id: string;
29
+ lines: number;
30
+ }
31
+ export type HistoryFrame = {
32
+ type: 'history';
33
+ id: string;
34
+ text: string;
35
+ cols: number;
36
+ rows: number;
37
+ } | {
38
+ type: 'history';
39
+ id: string;
40
+ error: HistoryError;
41
+ };
42
+ export type HistoryResult = {
43
+ ok: true;
44
+ text: string;
45
+ cols: number;
46
+ rows: number;
47
+ } | {
48
+ ok: false;
49
+ error: HistoryError;
50
+ };
51
+ /** `lines` as asked, within 1..{@link HISTORY_MAX_LINES}. */
52
+ export declare function clampHistoryLines(raw: unknown): number;
53
+ /** A `history` request frame's fields, or `null` when it can't be answered (no usable id). */
54
+ export declare function parseHistoryRequest(frame: Record<string, unknown>): HistoryRequest | null;
55
+ /**
56
+ * `-p` print, `-e` keep the SGR escapes (colours, bold), `-J` join wrapped lines (the viewer
57
+ * wraps them at its own width), `-S -<lines>` from that far back in the history to the screen's end.
58
+ */
59
+ export declare function captureHistoryArgs(name: string, lines: number): string[];
60
+ /** The pane's size, and whether a program (vim, less) holds its alternate screen: `<cols> <rows> <0|1>`. */
61
+ export declare function paneGeometryArgs(name: string): string[];
62
+ export declare function parsePaneGeometry(stdout: string): {
63
+ cols: number;
64
+ rows: number;
65
+ alternate: boolean;
66
+ } | null;
67
+ /**
68
+ * The answer to request `id`, at most `maxBytes` as JSON: whole lines are dropped from the top
69
+ * (the oldest) until it fits. tmux ends its capture with a newline, which is not a line.
70
+ */
71
+ export declare function historyFrame(id: string, result: HistoryResult, maxBytes?: number): HistoryFrame;
72
+ export type HistoryTmux = (args: string[]) => Promise<{
73
+ ok: boolean;
74
+ stdout?: string;
75
+ }>;
76
+ /**
77
+ * Read `lines` of window `name`'s history. A pane in the alternate screen (a full-screen program)
78
+ * has no scroll-back to show: the viewer falls back to sending it the wheel.
79
+ */
80
+ export declare function readHistory(tmux: HistoryTmux, name: string, lines: number): Promise<HistoryResult>;