@prohost/cli 0.16.2 → 0.16.4

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 CHANGED
@@ -6,6 +6,35 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.16.4
10
+
11
+ - **`409 conversation_busy` on claim** (ProhostAI serializes a conversation's turns): the run
12
+ stays queued instead of being skipped. Its slot goes to other work, and it is claimed again
13
+ when ProhostAI resends its frame (the earlier turn ended) or after a backoff of 30 s doubling to
14
+ at most 2 min — never in a tight loop. While it waits it does not hold its conversation's place
15
+ in the queue, so the earlier turn it waits for starts even if its frame came second; a resend
16
+ that arrives while its claim is still in flight is kept. Any other `409` is still another
17
+ computer's run. Works with servers that never send it.
18
+
19
+ ## 0.16.3
20
+
21
+ - **A dedicated service records its terminal switch in its home's code-host settings** as it
22
+ starts (`--remote-terminal` / `--no-remote-terminal`), instead of an environment variable a
23
+ surviving tmux window would keep: after a reinstall with `--no-remote-terminal`, a Claude window
24
+ still open from before stops advertising remote approvals nobody can give, and answers its
25
+ permission prompts in the terminal. It fails closed: `--no-remote-terminal` never hosts (the
26
+ definition decides in the service, whatever the file says), and a switch it can't record hosts
27
+ nothing and removes the old file when it can, so hooks read remote control as off (the log says
28
+ when even that fails).
29
+ - **A muted thread's turn is never resumed.** When an early answer was dropped because the thread
30
+ is muted (`409 ai_muted`), the conversation's session is forgotten on every exit — cancelled,
31
+ usage-limited, closed server-side, expired or failed too — not only when the run finishes.
32
+ - **Replies the server holds back are handled, not failed or retried** (dispatch #22485):
33
+ `409 approval_required` ("waiting for approval": sent when someone approves the card — the
34
+ session continues), `409 send_blocked` ("blocked: <reason>", e.g. a duplicate — never delivered,
35
+ so the session is forgotten), and `held_as_suggestion` with any reason, `pinned_rule` included
36
+ (the session continues, as before). Any other 409 is still a failure. Works with today's server.
37
+
9
38
  ## 0.16.2
10
39
 
11
40
  More `prohost host` follow-ups (they missed #22513):
package/README.md CHANGED
@@ -97,7 +97,10 @@ program (no terminal) relies on `--expect-account`.
97
97
  2. The run waits its turn (below). When a slot is free and its AI login has
98
98
  room, the computer **claims** it (`POST /v1/computers/{id}/runs/{run_id}/claim`).
99
99
  Exactly one computer ever gets a claim; a `409` means another computer took
100
- it, and it is skipped.
100
+ it, and it is skipped. A `409 conversation_busy` means an earlier turn of the
101
+ same employee's conversation is still open: the run stays queued (its slot
102
+ goes to other work) and is claimed when ProhostAI sends its frame again, or
103
+ after a backoff of 30 s doubling to at most 2 min.
101
104
  3. The claim returns the run, a short-lived **run token** and the employee's
102
105
  **skills**. The skills are written into the employee's workspace —
103
106
  `.claude/skills/<key>/…` for Claude Code; `.codex/skills/<key>/…` plus a
@@ -31,10 +31,11 @@ export interface ApiResult {
31
31
  ok: boolean;
32
32
  /**
33
33
  * A send the server answered without sending (still `ok`: the run is done):
34
- * saved as a draft for the team (`suggestion`), or dropped because the thread
35
- * is muted for AI (`muted`).
34
+ * saved as a draft for the team (`suggestion`), waiting on an approval card
35
+ * (`approval`), dropped because the thread is muted for AI (`muted`), or
36
+ * stopped by the outbound guard (`blocked`). See `withheldBy` in run.ts.
36
37
  */
37
- withheld?: 'suggestion' | 'muted';
38
+ withheld?: 'suggestion' | 'approval' | 'muted' | 'blocked';
38
39
  /** HTTP status, or ``0`` when no response arrived at all — see ``failure``. */
39
40
  status: number;
40
41
  error?: string;
@@ -270,12 +270,11 @@ export interface AgentRunMetrics {
270
270
  * nor a failure and must not be counted as either.
271
271
  */
272
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;
273
+ export declare function withheldBy(result: ApiResult): {
274
+ kind: NonNullable<ApiResult['withheld']>;
275
+ reason?: string;
276
+ approvalRequestId?: string;
277
+ } | undefined;
279
278
  /**
280
279
  * A sign-in checker that adds `extra` only when it runs `binary` (this
281
280
  * harness's Claude): the account snapshot checks other runtimes' logins too,
package/dist/agent/run.js CHANGED
@@ -210,13 +210,18 @@ const REPLAY_IN_FLIGHT_DELAY_MS = 5_000;
210
210
  */
211
211
  async function postReplyWithReplay(api, send, label, log, sleep) {
212
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
213
+ // The server decided not to send it (yet): handled, not failed, and never retried.
214
+ const held = withheldBy(result);
215
+ if (held) {
216
+ const why = held.reason ? `: ${held.reason}` : '';
217
+ log(held.kind === 'muted'
217
218
  ? `${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' };
219
+ : held.kind === 'approval'
220
+ ? `${timestamp()} ◦ ${label} reply is waiting for approval${held.approvalRequestId ? ` (approval ${held.approvalRequestId})` : ''}: it is sent when someone approves it`
221
+ : held.kind === 'blocked'
222
+ ? `${timestamp()} ◦ ${label} reply was not sent: blocked: ${held.reason ?? 'the outbound guard'}`
223
+ : `${timestamp()} ◦ ${label} reply was held as a suggestion for the team (the employee's send rule${why})`);
224
+ return { ...result, ok: true, withheld: held.kind };
220
225
  }
221
226
  return result;
222
227
  }
@@ -272,12 +277,62 @@ function resolveWorkdir(options, log) {
272
277
  }
273
278
  }
274
279
  /**
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.
280
+ * A send the server answered without sending, deliberately (a 409). Either way the run is done,
281
+ * nothing is retried, and whether the conversation's session continues follows from it:
282
+ *
283
+ * - `held_as_suggestion` → `suggestion`: saved as a draft for the team (the send rule, or a
284
+ * `pinned_rule`). The team may send it as is: the session continues.
285
+ * - `approval_required` → `approval`: below the employee's threshold, it waits on an approval card
286
+ * and the server sends it when someone approves: the session continues.
287
+ * - `ai_muted` → `muted`: the thread is muted; nothing saved. The guest never sees it: the session
288
+ * is forgotten.
289
+ * - `send_blocked` → `blocked`: the shared outbound guard stopped it (e.g. a duplicate). Not
290
+ * delivered by this run: the session is forgotten.
291
+ *
292
+ * Any other 409 is not a decision and stays a failure.
278
293
  */
279
- export function isHeldAsSuggestion(result) {
280
- return result.status === 409 && /held_as_suggestion|ai_muted/.test(result.error ?? '');
294
+ const WITHHELD_CODES = new Map([
295
+ ['ai_muted', 'muted'],
296
+ ['approval_required', 'approval'],
297
+ ['send_blocked', 'blocked'],
298
+ ['held_as_suggestion', 'suggestion'],
299
+ ]);
300
+ export function withheldBy(result) {
301
+ if (result.status !== 409)
302
+ return undefined;
303
+ const text = result.error ?? '';
304
+ // The server's own code, matched exactly — never a word found elsewhere in the body (a 409
305
+ // like `reply_race_held` quotes another reply's text, which may name any of these):
306
+ // `{detail: {error, reason, approval_request_id, …}}`, the same fields at the top, or
307
+ // `{detail: "<code>"}`. A body cut short (`api.ts` keeps 500 chars) is read for its first
308
+ // `"error": "…"` field only.
309
+ let fields = {};
310
+ let code;
311
+ try {
312
+ const parsed = JSON.parse(text);
313
+ const top = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
314
+ const detail = top.detail;
315
+ if (typeof detail === 'string')
316
+ code = detail;
317
+ else {
318
+ fields = detail && typeof detail === 'object' && !Array.isArray(detail) ? detail : top;
319
+ code = typeof fields.error === 'string' ? fields.error : undefined;
320
+ }
321
+ }
322
+ catch {
323
+ code = /"error"\s*:\s*"([a-z_]+)"/.exec(text)?.[1];
324
+ }
325
+ const kind = code === undefined ? undefined : WITHHELD_CODES.get(code);
326
+ if (!kind)
327
+ return undefined;
328
+ const str = (value) => (typeof value === 'string' && value ? value.slice(0, 120) : undefined);
329
+ const reason = str(fields.reason);
330
+ const approvalRequestId = str(fields.approval_request_id);
331
+ return { kind, ...(reason ? { reason } : {}), ...(approvalRequestId ? { approvalRequestId } : {}) };
332
+ }
333
+ /** A withheld reply the guest will never get from this run: its session must not continue. */
334
+ function undeliveredFor(withheld) {
335
+ return withheld === 'muted' || withheld === 'blocked';
281
336
  }
282
337
  /**
283
338
  * A sign-in checker that adds `extra` only when it runs `binary` (this
@@ -1193,8 +1248,11 @@ limited) {
1193
1248
  let earlyCount = 0;
1194
1249
  let earlyPosted = 0;
1195
1250
  let earlyPosts = Promise.resolve();
1196
- /** An answer of this run was dropped because the thread is muted: its session must not continue. */
1197
- let muted = false;
1251
+ /**
1252
+ * An answer of this run will never reach the guest (the thread is muted, or the outbound guard
1253
+ * blocked it — `undeliveredFor`): its session must not continue.
1254
+ */
1255
+ let undelivered = false;
1198
1256
  const earlyPath = run.reply_path;
1199
1257
  const earlyBodyKey = run.reply_body_key;
1200
1258
  const stoppedNow = () => control.signal.aborted || control.disposition() !== undefined;
@@ -1222,8 +1280,8 @@ limited) {
1222
1280
  }
1223
1281
  early.shift();
1224
1282
  earlyPosted += 1;
1225
- if (posted.withheld === 'muted')
1226
- muted = true;
1283
+ if (undeliveredFor(posted.withheld))
1284
+ undelivered = true;
1227
1285
  log(`${timestamp()} ✓ ${label} posted an answer; a background task is still running`);
1228
1286
  }
1229
1287
  };
@@ -1250,15 +1308,19 @@ limited) {
1250
1308
  const sessionId = venue ? undefined : result.ok ? result.sessionId : undefined;
1251
1309
  /**
1252
1310
  * Continue the conversation's session from this turn — unless an answer of
1253
- * it was dropped because the thread is muted (any runtime): then the session
1311
+ * it will never reach the guest (muted thread, blocked send; any runtime): then the session
1254
1312
  * holds a turn nobody saw, so it is forgotten, eager record or resumed one alike.
1255
1313
  */
1256
1314
  const settleSession = (id) => {
1257
- if (muted)
1315
+ if (undelivered)
1258
1316
  runtime.sessions.forget(sessionKeyFor(run));
1259
1317
  else if (id)
1260
1318
  runtime.sessions.remember(sessionKeyFor(run), id);
1261
1319
  };
1320
+ // An early answer the guest will never get (muted, blocked): forgotten on every exit, the
1321
+ // stopped, failed and usage-limited ones below included — none may continue a turn nobody saw.
1322
+ if (undelivered)
1323
+ runtime.sessions.forget(sessionKeyFor(run));
1262
1324
  // Whatever the turn did, the timeline is whole BEFORE the reply is posted or
1263
1325
  // the run is closed — a completion that beats its own last tool row would
1264
1326
  // leave a `calling` row on a finished run. Never rejects, and after `stop`
@@ -1423,10 +1485,11 @@ limited) {
1423
1485
  }
1424
1486
  // Advance continuity only after the answer is durable on its reply surface.
1425
1487
  // Remembering at process exit would strand the conversation behind an answer
1426
- // it never received whenever this POST is rejected. A muted thread kept
1427
- // nothing of it, so its session is not advanced either (and an eager record undone).
1428
- if (posted.withheld === 'muted')
1429
- muted = true;
1488
+ // it never received whenever this POST is rejected. A muted thread or a blocked send delivers
1489
+ // nothing of it, so its session is not advanced either (and an eager record undone); a
1490
+ // suggestion or a reply waiting for approval may still be sent as written, so it continues.
1491
+ if (undeliveredFor(posted.withheld))
1492
+ undelivered = true;
1430
1493
  settleSession(sessionId);
1431
1494
  const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep, runtime.model());
1432
1495
  if (!completed.ok) {
@@ -26,9 +26,10 @@ export interface CodeHostSettings {
26
26
  export declare const DEFAULT_SETTINGS: CodeHostSettings;
27
27
  /**
28
28
  * A dedicated `prohost host` service decides terminal sessions in its own definition
29
- * (`host install --dedicated --remote-terminal | --no-remote-terminal`), not in the home's
30
- * `settings.json`: `1` hosts with remote control on, `0` hosts nothing. Every reader — the daemon,
31
- * the relay and the permission hook in each Claude window (which inherits it) — sees the same.
29
+ * (`host install --dedicated --remote-terminal | --no-remote-terminal`): `1` hosts with remote
30
+ * control on, `0` hosts nothing. The service writes it into its home's `settings.json` as it
31
+ * starts, so every reader — the daemon, the relay and the permission hook of a Claude window
32
+ * that outlived a reinstall — reads the one file.
32
33
  */
33
34
  export declare const TERMINAL_SESSIONS_ENV = "PROHOST_HOST_TERMINAL_SESSIONS";
34
35
  /** `$PROHOST_HOME/code-host`. */
@@ -57,3 +58,15 @@ export declare function readJsonObject(file: string): Record<string, unknown> |
57
58
  */
58
59
  export declare function loadSettings(env?: NodeJS.ProcessEnv): CodeHostSettings;
59
60
  export declare function saveSettings(settings: CodeHostSettings, env?: NodeJS.ProcessEnv): void;
61
+ /**
62
+ * A dedicated service's terminal switch (`TERMINAL_SESSIONS_ENV`), recorded in the home's
63
+ * `settings.json` as it starts — for the readers outside the service's process: the permission
64
+ * hook of each window, which may have outlived a reinstall. True only when it is on AND recorded.
65
+ * Fails closed: a switch it can't record hosts nothing, and the old file is removed when it can be
66
+ * (a missing or unreadable file is off to every reader). When even that fails — `code-host/`
67
+ * itself unwritable — the error says so: windows still open keep reading the old switches.
68
+ */
69
+ export declare function recordServiceSwitch(on: boolean, env: NodeJS.ProcessEnv, save?: (settings: CodeHostSettings, env: NodeJS.ProcessEnv) => void): {
70
+ hosting: boolean;
71
+ error?: string;
72
+ };
@@ -29,9 +29,10 @@ export const DEFAULT_SETTINGS = Object.freeze({
29
29
  });
30
30
  /**
31
31
  * A dedicated `prohost host` service decides terminal sessions in its own definition
32
- * (`host install --dedicated --remote-terminal | --no-remote-terminal`), not in the home's
33
- * `settings.json`: `1` hosts with remote control on, `0` hosts nothing. Every reader — the daemon,
34
- * the relay and the permission hook in each Claude window (which inherits it) — sees the same.
32
+ * (`host install --dedicated --remote-terminal | --no-remote-terminal`): `1` hosts with remote
33
+ * control on, `0` hosts nothing. The service writes it into its home's `settings.json` as it
34
+ * starts, so every reader — the daemon, the relay and the permission hook of a Claude window
35
+ * that outlived a reinstall — reads the one file.
35
36
  */
36
37
  export const TERMINAL_SESSIONS_ENV = 'PROHOST_HOST_TERMINAL_SESSIONS';
37
38
  /** `$PROHOST_HOME/code-host`. */
@@ -101,11 +102,6 @@ export function readJsonObject(file) {
101
102
  * is the default — both off — and only a literal `true` turns a switch on.
102
103
  */
103
104
  export function loadSettings(env = process.env) {
104
- const decided = env[TERMINAL_SESSIONS_ENV];
105
- if (decided === '1')
106
- return { version: 1, enabled: true, allowRemoteTerminal: true };
107
- if (decided === '0')
108
- return { ...DEFAULT_SETTINGS };
109
105
  const raw = readJsonObject(settingsPath(env));
110
106
  if (!raw || raw.version !== 1)
111
107
  return { ...DEFAULT_SETTINGS };
@@ -124,3 +120,30 @@ export function saveSettings(settings, env = process.env) {
124
120
  allowRemoteTerminal: settings.enabled && settings.allowRemoteTerminal,
125
121
  });
126
122
  }
123
+ /**
124
+ * A dedicated service's terminal switch (`TERMINAL_SESSIONS_ENV`), recorded in the home's
125
+ * `settings.json` as it starts — for the readers outside the service's process: the permission
126
+ * hook of each window, which may have outlived a reinstall. True only when it is on AND recorded.
127
+ * Fails closed: a switch it can't record hosts nothing, and the old file is removed when it can be
128
+ * (a missing or unreadable file is off to every reader). When even that fails — `code-host/`
129
+ * itself unwritable — the error says so: windows still open keep reading the old switches.
130
+ */
131
+ export function recordServiceSwitch(on, env, save = saveSettings) {
132
+ try {
133
+ save({ version: 1, enabled: on, allowRemoteTerminal: on }, env);
134
+ return { hosting: on && loadSettings(env).allowRemoteTerminal };
135
+ }
136
+ catch (e) {
137
+ const error = e instanceof Error ? e.message : String(e);
138
+ try {
139
+ rmSync(settingsPath(env), { force: true });
140
+ }
141
+ catch {
142
+ return {
143
+ hosting: false,
144
+ error: `${error}; the old switches could not be removed either, so windows still open may wait for remote approvals until ${settingsPath(env)} is writable`,
145
+ };
146
+ }
147
+ return { hosting: false, error };
148
+ }
149
+ }
@@ -33,7 +33,6 @@ import { readHistory } from './history.js';
33
33
  import { CODE_THREAD_ID_ENV, TERMINAL_ID_ENV } from './hooks.js';
34
34
  import { MAX_OPEN_PTYS } from './pty.js';
35
35
  import { claudeSessionSince, isReady, markReady } from './ready.js';
36
- import { TERMINAL_SESSIONS_ENV } from './settings.js';
37
36
  import { defaultSizeArgs, HOLD_OPTION, holdChannel, holdCommand, resizeWindowArgs, TerminalSizing } from './sizing.js';
38
37
  import { noteInputTakeover } from './sizing.js';
39
38
  import { attachArgs, baseArgs, clampDim, DEFAULT_COLS, DEFAULT_ROWS, hasSessionArgs, isCopyModeExitKey, isHumanInput, isTypedText, isInterruptInput, isScrollGesture, leaveCopyModeArgs, mayEnterCopyMode, paneInModeArgs, isValidId, killSessionArgs, listWindowsArgs, newSessionArgs, newWindowArgs, parseListWindows, sendTextCommands, setWindowMetaArgs, TERMINAL_WINDOWS, DAEMON_ONLY_ENV, paneEnv, viewSessionName, windowName, windowTarget, } from './tmux.js';
@@ -170,14 +169,6 @@ const CLAUDE_THREAD_ENV = { CLAUDE_CODE_ENABLE_CFC: '0' };
170
169
  * and the like), then the thread's overrides, which a login's environment never undoes. The one
171
170
  * builder for it: `startOnce` launches with it, and `adoptReadiness` reads the config dir from it.
172
171
  */
173
- /**
174
- * A dedicated service's terminal switch (`TERMINAL_SESSIONS_ENV`), handed to each window so its
175
- * permission hook reads the same switches as the daemon whatever the tmux server inherited.
176
- */
177
- function terminalSessionsEnv(env) {
178
- const decided = env[TERMINAL_SESSIONS_ENV];
179
- return decided === undefined ? {} : { [TERMINAL_SESSIONS_ENV]: decided };
180
- }
181
172
  function agentWindowEnv(agent, accountEnv) {
182
173
  return { ...stringEnv(accountEnv), ...(agent === 'claude_code' ? CLAUDE_THREAD_ENV : {}) };
183
174
  }
@@ -489,7 +480,6 @@ export class CodeTerminals {
489
480
  cwd: checkout.path,
490
481
  env: {
491
482
  ...agentWindowEnv(request.agent, account.env),
492
- ...terminalSessionsEnv(this.options.processEnv ?? process.env),
493
483
  PROHOST_HOME: this.options.home,
494
484
  [TERMINAL_ID_ENV]: request.terminalId,
495
485
  [CODE_THREAD_ID_ENV]: request.codeThreadId,
@@ -61,6 +61,13 @@ export type ClaimResult = {
61
61
  | {
62
62
  kind: 'elsewhere';
63
63
  }
64
+ /**
65
+ * `409 conversation_busy`: an earlier turn of the same employee's conversation is still open.
66
+ * The run stays queued here; the server sends its frame again when that turn ends.
67
+ */
68
+ | {
69
+ kind: 'busy';
70
+ }
64
71
  /** Gone server-side (404 / 410): nothing to run. */
65
72
  | {
66
73
  kind: 'gone';
@@ -78,5 +85,4 @@ export type ClaimResult = {
78
85
  status: number;
79
86
  error?: string;
80
87
  };
81
- /** Claim a run for this computer. Exactly one computer ever gets a 200. */
82
88
  export declare function claimRun(options: ComputerApiOptions, computerId: string, runId: string): Promise<ClaimResult>;
package/dist/host/api.js CHANGED
@@ -94,6 +94,20 @@ function parseSkills(value) {
94
94
  });
95
95
  }
96
96
  /** Claim a run for this computer. Exactly one computer ever gets a 200. */
97
+ /** A refusal's code: `{detail: {error}}`, `{detail: "<code>"}` or `{error}`, matched exactly by the caller. */
98
+ function errorCode(body) {
99
+ if (!body || typeof body !== 'object' || Array.isArray(body))
100
+ return undefined;
101
+ const top = body;
102
+ const detail = top.detail;
103
+ if (typeof detail === 'string')
104
+ return detail;
105
+ if (detail && typeof detail === 'object' && !Array.isArray(detail)) {
106
+ const code = detail.error;
107
+ return typeof code === 'string' ? code : undefined;
108
+ }
109
+ return typeof top.error === 'string' ? top.error : undefined;
110
+ }
97
111
  export async function claimRun(options, computerId, runId) {
98
112
  const response = await callJson(options, claimPath(computerId, runId), {});
99
113
  if (response.status === 200 || response.status === 201) {
@@ -112,7 +126,7 @@ export async function claimRun(options, computerId, runId) {
112
126
  };
113
127
  }
114
128
  if (response.status === 409)
115
- return { kind: 'elsewhere' };
129
+ return errorCode(response.body) === 'conversation_busy' ? { kind: 'busy' } : { kind: 'elsewhere' };
116
130
  if (response.status === 404 || response.status === 410)
117
131
  return { kind: 'gone', status: response.status };
118
132
  if (response.status === 0 || response.status === 429 || response.status >= 500) {
@@ -67,8 +67,12 @@ export declare class HostScheduler<T extends QueuedRun = QueuedRun> {
67
67
  * run keeps its place and does not block the others.
68
68
  *
69
69
  * Taking it marks it running; call {@link finish} when it is done.
70
+ *
71
+ * A `parked` run (one the server said must wait for another turn of its conversation) neither
72
+ * starts nor holds its lane: the next run of the lane — possibly the very turn it waits for —
73
+ * is the lane's candidate instead. Runs of one lane still never run side by side.
70
74
  */
71
- take(config: SchedulerConfig, eligible?: (run: T) => boolean): T | undefined;
75
+ take(config: SchedulerConfig, eligible?: (run: T) => boolean, parked?: (run: T) => boolean): T | undefined;
72
76
  /** A started run is done. */
73
77
  finish(runId: string): void;
74
78
  /** A taken run could not start after all: back in the queue, in its old place. */
@@ -66,14 +66,20 @@ export class HostScheduler {
66
66
  * run keeps its place and does not block the others.
67
67
  *
68
68
  * Taking it marks it running; call {@link finish} when it is done.
69
+ *
70
+ * A `parked` run (one the server said must wait for another turn of its conversation) neither
71
+ * starts nor holds its lane: the next run of the lane — possibly the very turn it waits for —
72
+ * is the lane's candidate instead. Runs of one lane still never run side by side.
69
73
  */
70
- take(config, eligible = () => true) {
74
+ take(config, eligible = () => true, parked = () => false) {
71
75
  if (config.held)
72
76
  return undefined;
73
77
  const busyLanes = new Set([...this.running.values()].map((r) => r.lane));
74
78
  const seenLanes = new Set();
75
79
  const candidates = [];
76
80
  for (const run of this.queue) {
81
+ if (parked(run))
82
+ continue;
77
83
  // Only the oldest run of each lane is a candidate: later ones wait their turn.
78
84
  if (seenLanes.has(run.lane))
79
85
  continue;
@@ -30,6 +30,7 @@ import { executeHostedRun } from '../agent/run.js';
30
30
  import type { AgentRunOptions } from '../agent/run.js';
31
31
  import type { CodeHostLike } from '../codehost/host.js';
32
32
  import type { HostStats } from '../codehost/host_stats.js';
33
+ import { recordServiceSwitch } from '../codehost/settings.js';
33
34
  import type { Workload } from './capacity.js';
34
35
  import type { ComputerCredentials } from './computer.js';
35
36
  import type { LoginInfo } from './logins.js';
@@ -92,6 +93,8 @@ export interface HostServiceOptions {
92
93
  peak?: PeakMemory;
93
94
  detectLogins?: () => Promise<LoginInfo[]>;
94
95
  codeHost?: CodeHostLike | null;
96
+ /** Records a dedicated service's terminal switch in the home (tests: one that fails). */
97
+ recordTerminalSwitch?: typeof recordServiceSwitch;
95
98
  executeRun?: typeof executeHostedRun;
96
99
  updateAvailable?: () => boolean;
97
100
  statusIntervalMs?: number;
@@ -109,6 +112,11 @@ export interface HostServiceResult {
109
112
  runsStarted: number;
110
113
  runsSkipped: number;
111
114
  }
115
+ /**
116
+ * How long a run whose conversation is busy waits before it is claimed again, unless the server
117
+ * sends its frame first (it does when the earlier turn ends): 30 s, doubling, at most 2 min.
118
+ */
119
+ export declare function busyBackoffMs(waits: number): number;
112
120
  /** The CLI's own entry file: when it changes on disk, an update was installed. */
113
121
  export declare function entrypointStamp(entry?: string): () => boolean;
114
122
  export declare function runHostService(options: HostServiceOptions): Promise<HostServiceResult>;
@@ -40,7 +40,7 @@ import { SessionStore, sessionKeyFor } from '../agent/sessionstore.js';
40
40
  import { verifySignature } from '../agent/signature.js';
41
41
  import { CODE_THREAD_FOLDERS_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, REMOTE_TERMINAL_CAPABILITY, RefusedDeliveryError, } from '../codehost/host.js';
42
42
  import { HostStatsSampler } from '../codehost/host_stats.js';
43
- import { loadSettings as loadCodeHostSettings } from '../codehost/settings.js';
43
+ import { TERMINAL_SESSIONS_ENV, loadSettings as loadCodeHostSettings, recordServiceSwitch } from '../codehost/settings.js';
44
44
  import { isWebhookEvent } from '../forward.js';
45
45
  import { ServerLiveness, enableTcpKeepAlive, terminateSocket } from '../liveness.js';
46
46
  import { CLI_VERSION, USER_AGENT } from '../version.js';
@@ -82,6 +82,13 @@ const ACK_RETRY = 503;
82
82
  const RECONNECT_MAX_DELAY_MS = 30_000;
83
83
  /** An employee id names a folder: nothing else gets in. */
84
84
  const SAFE_ID = /^[A-Za-z0-9-]{1,64}$/;
85
+ /**
86
+ * How long a run whose conversation is busy waits before it is claimed again, unless the server
87
+ * sends its frame first (it does when the earlier turn ends): 30 s, doubling, at most 2 min.
88
+ */
89
+ export function busyBackoffMs(waits) {
90
+ return Math.min(30_000 * 2 ** waits, 120_000);
91
+ }
85
92
  function timestamp() {
86
93
  return new Date().toISOString().slice(11, 19);
87
94
  }
@@ -186,13 +193,20 @@ async function serveHost(options) {
186
193
  ? { ...process.env, ...env, PROHOST_HOME: computer.code_host_home }
187
194
  : { ...process.env, ...env };
188
195
  // A dedicated install decides terminal sessions in the service's own definition
189
- // (`--remote-terminal` / `--no-remote-terminal`, `TERMINAL_SESSIONS_ENV`), which `loadSettings`
190
- // reads over the home's switches; a personal service follows the home's switches.
191
- const codeHost = options.codeHost !== undefined
192
- ? (options.codeHost ?? undefined)
193
- : loadCodeHostSettings(codeHostEnv).enabled
194
- ? buildCodeHost()
195
- : undefined;
196
+ // (`--remote-terminal` / `--no-remote-terminal`): written into the home's switches as it starts,
197
+ // the one file the daemon, the relay and every window's permission hook read (a window that
198
+ // outlived a reinstall too). A personal service follows the home's switches as the owner set them.
199
+ // The definition is authoritative in this process: off never hosts, whatever the file says or
200
+ // whether it could be written; on hosts only once recorded (`recordServiceSwitch` fails closed).
201
+ const decided = env[TERMINAL_SESSIONS_ENV] === '1' ? true : env[TERMINAL_SESSIONS_ENV] === '0' ? false : undefined;
202
+ let hosting = decided ?? loadCodeHostSettings(codeHostEnv).enabled;
203
+ if (decided !== undefined) {
204
+ const recorded = (options.recordTerminalSwitch ?? recordServiceSwitch)(decided, codeHostEnv);
205
+ hosting = decided && recorded.hosting;
206
+ if (recorded.error)
207
+ log(`terminal sessions: off — could not record the service's switch (${recorded.error})`);
208
+ }
209
+ const codeHost = options.codeHost !== undefined ? (options.codeHost ?? undefined) : hosting ? buildCodeHost() : undefined;
196
210
  function buildCodeHost() {
197
211
  try {
198
212
  return new CodeHost({
@@ -253,6 +267,8 @@ async function serveHost(options) {
253
267
  }
254
268
  /** Runs being claimed right now, and the login each will run on. */
255
269
  const claiming = new Map();
270
+ /** Runs the server resent while their claim was in flight: a `conversation_busy` answer to that claim is already stale. */
271
+ const resentWhileClaiming = new Set();
256
272
  function runsOnLogin(accountKey) {
257
273
  let n = 0;
258
274
  for (const run of active.values())
@@ -482,7 +498,9 @@ async function serveHost(options) {
482
498
  return false;
483
499
  const pick = pool.pick(runtimeOf(run.agentId, run.data), conversationOf(run.lane), config.share_logins[runtimeOf(run.agentId, run.data)], runsOnLogin);
484
500
  return pick.kind === 'use' || pick.kind === 'move';
485
- });
501
+ },
502
+ // Waiting on an earlier turn of its conversation: that turn may be queued behind it.
503
+ (run) => Boolean(run.busyWaits) && run.notBefore > now());
486
504
  if (!next)
487
505
  break;
488
506
  background.push(startRun(next));
@@ -509,6 +527,19 @@ async function serveHost(options) {
509
527
  const claim = await claimRun(computerApi, computer.computer_id, queued.runId).finally(() => {
510
528
  claiming.delete(queued.runId);
511
529
  });
530
+ const resent = resentWhileClaiming.delete(queued.runId);
531
+ if (claim.kind === 'busy' && !draining) {
532
+ // Serialized server-side: an earlier turn of this conversation is still open. Not dropped,
533
+ // not failed: queued again (its slot is free for other work) until the server resends its
534
+ // frame, or its backoff ends. A resend that came while this claim was in flight already
535
+ // said the earlier turn ended: claimed again at once.
536
+ const waits = queued.busyWaits ?? 0;
537
+ scheduler.requeue({ ...queued, busyWaits: waits + 1, notBefore: resent ? 0 : now() + busyBackoffMs(waits) });
538
+ if (waits === 0)
539
+ log(`${timestamp()} ◦ run ${queued.runId.slice(0, 12)} waits — an earlier turn of its conversation is still open`);
540
+ pump();
541
+ return;
542
+ }
512
543
  if (claim.kind !== 'claimed') {
513
544
  if (claim.kind === 'retry' && queued.claimAttempts + 1 < MAX_CLAIM_ATTEMPTS && !draining) {
514
545
  scheduler.requeue({ ...queued, claimAttempts: queued.claimAttempts + 1, notBefore: now() + 2_000 * 2 ** queued.claimAttempts });
@@ -518,7 +549,7 @@ async function serveHost(options) {
518
549
  result.runsSkipped += 1;
519
550
  const why = claim.kind === 'elsewhere'
520
551
  ? 'another computer has it'
521
- : claim.kind === 'gone'
552
+ : claim.kind === 'gone' || claim.kind === 'busy'
522
553
  ? 'it is no longer waiting'
523
554
  : `the claim was refused (${claim.status}${'error' in claim && claim.error ? `: ${claim.error}` : ''})`;
524
555
  log(`${timestamp()} ◦ run ${queued.runId.slice(0, 12)} skipped — ${why}`);
@@ -1009,8 +1040,19 @@ async function serveHost(options) {
1009
1040
  return [ACK_IGNORED];
1010
1041
  if (typeof data.computer_id === 'string' && data.computer_id !== computer.computer_id)
1011
1042
  return [ACK_IGNORED];
1012
- if (scheduler.has(runId) || active.has(runId) || ledger.get(runId))
1043
+ if (scheduler.has(runId) || active.has(runId) || ledger.get(runId)) {
1044
+ // The server's resend of a run waiting on its busy conversation: the earlier turn ended, so
1045
+ // it is claimed now rather than at the end of its backoff.
1046
+ const waiting = scheduler.queued().find((run) => run.runId === runId);
1047
+ if (waiting?.busyWaits && waiting.notBefore > now()) {
1048
+ waiting.notBefore = 0;
1049
+ pump();
1050
+ }
1051
+ else if (claiming.has(runId)) {
1052
+ resentWhileClaiming.add(runId);
1053
+ }
1013
1054
  return [ACK_ACCEPTED];
1055
+ }
1014
1056
  if (draining)
1015
1057
  return [ACK_RETRY, 'draining'];
1016
1058
  if (!config.ai_employees_enabled)
package/dist/version.d.ts CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export declare const CLI_VERSION = "0.16.2";
10
+ export declare const CLI_VERSION = "0.16.4";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.16.2";
12
+ export declare const USER_AGENT = "prohost-cli/0.16.4";
package/dist/version.js CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export const CLI_VERSION = '0.16.2';
10
+ export const CLI_VERSION = '0.16.4';
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
12
  export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.16.2",
3
+ "version": "0.16.4",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",