@prohost/cli 0.3.0 → 0.4.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.
@@ -63,14 +63,19 @@ export declare function agentRunCompletePath(runId: string): string;
63
63
  */
64
64
  export declare function agentRunHeartbeatPath(runId: string): string;
65
65
  /**
66
- * Where this run's heartbeats go.
66
+ * Where this run's heartbeats go, in descending order of how much we know.
67
67
  *
68
- * Derived from the server-supplied completion path rather than rebuilt from the
69
- * run id, on the same reasoning the rest of this module prefers server-supplied
70
- * paths: the two calls are the same resource, so a server that moves its
71
- * agent-run routes moves both together and this CLI follows without a release.
72
- * The documented path is the fallback for a completion path shaped in some way
73
- * we don't recognise.
68
+ * 1. The `heartbeat` block, when the payload carries one. A path the server
69
+ * stated is not a guess, so it is used verbatim.
70
+ * 2. Otherwise, derived from the server-supplied completion path: the two
71
+ * calls are the same resource, so a server that moves its agent-run routes
72
+ * moves both together and a CLI already on somebody's laptop follows
73
+ * without a release.
74
+ * 3. Otherwise the documented path, for a completion path shaped in some way
75
+ * we don't recognise.
76
+ *
77
+ * Steps 2 and 3 are exactly what shipped in 0.3.0 and are not going anywhere:
78
+ * the block is additive, and every server that predates it must keep working.
74
79
  */
75
80
  export declare function heartbeatPathFor(run: AgentRunRequest): string;
76
81
  /**
@@ -216,6 +221,14 @@ export interface AgentRunRequest {
216
221
  reply_body_extra?: Record<string, unknown>;
217
222
  /** Server-supplied path to complete the run at. */
218
223
  completion_path: string;
224
+ /**
225
+ * Server-supplied path to heartbeat the run at.
226
+ *
227
+ * Absent on every server built before the `heartbeat` block was added
228
+ * alongside `completion`, which is most of them — see {@link heartbeatPathFor}
229
+ * for what happens then.
230
+ */
231
+ heartbeat_path?: string;
219
232
  /**
220
233
  * Earlier messages on this surface, oldest first. Absent on a server that
221
234
  * predates the field — which is the case the harness shipped with, so an
@@ -69,16 +69,23 @@ export function agentRunHeartbeatPath(runId) {
69
69
  return `/v1/agent-runs/${encodeURIComponent(runId)}/heartbeat`;
70
70
  }
71
71
  /**
72
- * Where this run's heartbeats go.
72
+ * Where this run's heartbeats go, in descending order of how much we know.
73
73
  *
74
- * Derived from the server-supplied completion path rather than rebuilt from the
75
- * run id, on the same reasoning the rest of this module prefers server-supplied
76
- * paths: the two calls are the same resource, so a server that moves its
77
- * agent-run routes moves both together and this CLI follows without a release.
78
- * The documented path is the fallback for a completion path shaped in some way
79
- * we don't recognise.
74
+ * 1. The `heartbeat` block, when the payload carries one. A path the server
75
+ * stated is not a guess, so it is used verbatim.
76
+ * 2. Otherwise, derived from the server-supplied completion path: the two
77
+ * calls are the same resource, so a server that moves its agent-run routes
78
+ * moves both together and a CLI already on somebody's laptop follows
79
+ * without a release.
80
+ * 3. Otherwise the documented path, for a completion path shaped in some way
81
+ * we don't recognise.
82
+ *
83
+ * Steps 2 and 3 are exactly what shipped in 0.3.0 and are not going anywhere:
84
+ * the block is additive, and every server that predates it must keep working.
80
85
  */
81
86
  export function heartbeatPathFor(run) {
87
+ if (run.heartbeat_path)
88
+ return run.heartbeat_path;
82
89
  const completion = run.completion_path;
83
90
  const suffix = '/complete';
84
91
  return completion.endsWith(suffix)
@@ -255,6 +262,7 @@ export function parseRunRequest(data) {
255
262
  const trigger = obj(data.trigger);
256
263
  const replyInstructions = obj(data.reply_instructions);
257
264
  const completion = obj(data.completion);
265
+ const heartbeat = obj(data.heartbeat);
258
266
  const context = obj(data.context);
259
267
  const declaredSurface = str(replyInstructions.surface);
260
268
  const conversationId = str(data.conversation_id) ?? str(replyInstructions.conversation_id);
@@ -297,6 +305,9 @@ export function parseRunRequest(data) {
297
305
  reply_body_key: str(replyInstructions.body_field) ?? legacyBodyKeyFor(surface),
298
306
  reply_body_extra: bodyExtra(replyInstructions.body_extra),
299
307
  completion_path: str(completion.path) ?? agentRunCompletePath(runId),
308
+ // No fallback of its own: an absent block is not an error, it is an older
309
+ // server, and `heartbeatPathFor` already knows what to do about that.
310
+ heartbeat_path: str(heartbeat.path),
300
311
  // Accepted both at the top level and nested under `context`, because the
301
312
  // server can move them without a CLI release either way — and a published
302
313
  // CLI cannot be redeployed to follow.
@@ -83,6 +83,8 @@ export interface HarnessInvocation {
83
83
  exec: string;
84
84
  workdir: string;
85
85
  timeoutSeconds?: number;
86
+ /** Seconds of total silence before a run is treated as stalled. */
87
+ idleTimeoutSeconds?: number;
86
88
  machine?: string;
87
89
  url?: string;
88
90
  allowUnverified?: boolean;
@@ -176,8 +176,14 @@ export function harnessArguments(invocation) {
176
176
  '--workdir',
177
177
  invocation.workdir,
178
178
  ];
179
+ // Compared against `undefined` rather than tested for truth: `0` is a real
180
+ // setting for both flags — no wall-clock cap, no watchdog — and dropping it
181
+ // would install a daemon quietly running the defaults instead.
179
182
  if (invocation.timeoutSeconds !== undefined)
180
183
  args.push('--timeout', String(invocation.timeoutSeconds));
184
+ if (invocation.idleTimeoutSeconds !== undefined) {
185
+ args.push('--idle-timeout', String(invocation.idleTimeoutSeconds));
186
+ }
181
187
  if (invocation.machine)
182
188
  args.push('--machine', invocation.machine);
183
189
  if (invocation.url)
@@ -26,6 +26,19 @@ export interface ExecOptions {
26
26
  * genuinely takes hours and a local clock is the wrong thing to bound it by.
27
27
  */
28
28
  timeoutMs: number;
29
+ /**
30
+ * Kill the run when the child produces **nothing** for this long.
31
+ *
32
+ * The question a wall clock cannot answer: is this run working, or wedged?
33
+ * Bytes on stdout or stderr are the one signal every brain emits — stream
34
+ * events, a progress line, a log — so a window of total silence is the
35
+ * closest thing to evidence that a child has stopped making progress rather
36
+ * than merely taking a while. Every chunk on either stream resets it.
37
+ *
38
+ * ``0`` or unset disables it, at which point only {@link timeoutMs}, the
39
+ * {@link cancelSignal} and the server's lease can end the run.
40
+ */
41
+ idleTimeoutMs?: number;
29
42
  /** Working directory for the child. Defaults to the CLI's cwd. */
30
43
  cwd?: string;
31
44
  /** Extra environment for the child, merged over ``process.env``. */
@@ -55,7 +68,18 @@ export interface ExecResult {
55
68
  stdout: string;
56
69
  stderr: string;
57
70
  exitCode: number | null;
71
+ /** The run overran {@link ExecOptions.timeoutMs}. */
58
72
  timedOut: boolean;
73
+ /**
74
+ * The run went silent for {@link ExecOptions.idleTimeoutMs} and was killed.
75
+ *
76
+ * Separate from {@link timedOut} because the two say different things to
77
+ * whoever reads the failure: one run used its whole budget, the other stopped
78
+ * doing anything at all. They are handled identically everywhere else — both
79
+ * are kills from outside, so both salvage partial output and keep the
80
+ * session they had recorded.
81
+ */
82
+ idleTimedOut: boolean;
59
83
  /** The run was stopped through {@link ExecOptions.cancelSignal}. */
60
84
  cancelled: boolean;
61
85
  /** Populated when the process could not be started at all. */
@@ -76,6 +100,17 @@ export declare const CANCELLED_ERROR = "cancelled";
76
100
  * Kept to one line for the same reason.
77
101
  */
78
102
  export declare function timeoutMessage(timeoutMs: number): string;
103
+ /**
104
+ * What a run killed by the inactivity watchdog reports.
105
+ *
106
+ * Same job as {@link timeoutMessage}, and the same constraint: the server
107
+ * echoes a failed run's error into the thread, so this is read by the person
108
+ * who was waiting rather than by whoever tails `agent.log`. It has to say what
109
+ * happened ("nothing came out of it"), that this is an inference rather than a
110
+ * crash the agent reported, and how to change the judgement — in one line,
111
+ * because the notice it lands in has room for one.
112
+ */
113
+ export declare function idleTimeoutMessage(idleTimeoutMs: number): string;
79
114
  /**
80
115
  * Execute the agent command with ``prompt`` on stdin and return its stdout.
81
116
  *
@@ -30,6 +30,38 @@ export const CANCELLED_ERROR = 'cancelled';
30
30
  export function timeoutMessage(timeoutMs) {
31
31
  return `timed out after ${timeoutMs}ms — raise the ceiling with --timeout <seconds> (0 = no limit)`;
32
32
  }
33
+ /**
34
+ * Say a silence window the way a person would say it.
35
+ *
36
+ * The default is fifteen minutes and it wants to read as "15 minutes", not
37
+ * "900000ms": this string is shown to whoever was waiting on the agent, and a
38
+ * number they have to convert is a number they will misread.
39
+ */
40
+ function renderWindow(ms) {
41
+ if (ms >= 60_000 && ms % 60_000 === 0) {
42
+ const minutes = ms / 60_000;
43
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
44
+ }
45
+ const seconds = ms / 1_000;
46
+ const rendered = Number.isInteger(seconds)
47
+ ? String(seconds)
48
+ : seconds.toFixed(seconds < 1 ? 2 : 1);
49
+ return `${rendered} second${rendered === '1' ? '' : 's'}`;
50
+ }
51
+ /**
52
+ * What a run killed by the inactivity watchdog reports.
53
+ *
54
+ * Same job as {@link timeoutMessage}, and the same constraint: the server
55
+ * echoes a failed run's error into the thread, so this is read by the person
56
+ * who was waiting rather than by whoever tails `agent.log`. It has to say what
57
+ * happened ("nothing came out of it"), that this is an inference rather than a
58
+ * crash the agent reported, and how to change the judgement — in one line,
59
+ * because the notice it lands in has room for one.
60
+ */
61
+ export function idleTimeoutMessage(idleTimeoutMs) {
62
+ return (`no output for ${renderWindow(idleTimeoutMs)} — treated as stalled; ` +
63
+ 'tune with --idle-timeout <seconds> (0 = disabled)');
64
+ }
33
65
  function clamp(text) {
34
66
  if (text.length <= MAX_OUTPUT_CHARS)
35
67
  return text;
@@ -49,6 +81,7 @@ export async function execAgent(options) {
49
81
  let stdout = '';
50
82
  let stderr = '';
51
83
  let timedOut = false;
84
+ let idleTimedOut = false;
52
85
  let cancelled = false;
53
86
  let settled = false;
54
87
  // Nothing to stop if the stop already happened: a run cancelled while it
@@ -60,6 +93,7 @@ export async function execAgent(options) {
60
93
  stderr: '',
61
94
  exitCode: null,
62
95
  timedOut: false,
96
+ idleTimedOut: false,
63
97
  cancelled: true,
64
98
  error: CANCELLED_ERROR,
65
99
  durationMs: Date.now() - started,
@@ -88,6 +122,7 @@ export async function execAgent(options) {
88
122
  stderr: '',
89
123
  exitCode: null,
90
124
  timedOut: false,
125
+ idleTimedOut: false,
91
126
  cancelled: false,
92
127
  error: err instanceof Error ? err.message : String(err),
93
128
  durationMs: Date.now() - started,
@@ -95,15 +130,32 @@ export async function execAgent(options) {
95
130
  return;
96
131
  }
97
132
  let timer;
133
+ let idleTimer;
98
134
  let killTimer;
99
- /** Why the run ended, when it ended because we ended it. */
100
- const stopReason = () => cancelled ? CANCELLED_ERROR : timedOut ? timeoutMessage(options.timeoutMs) : undefined;
135
+ const idleMs = options.idleTimeoutMs ?? 0;
136
+ /**
137
+ * Why the run ended, when it ended because we ended it.
138
+ *
139
+ * Ordered by which decision was made first in practice: a deliberate stop
140
+ * outranks either clock, and the wall clock outranks the watchdog because
141
+ * `terminate` disarms the watchdog on its way out — so if both flags are
142
+ * somehow set, the ceiling is what actually fired.
143
+ */
144
+ const stopReason = () => cancelled
145
+ ? CANCELLED_ERROR
146
+ : timedOut
147
+ ? timeoutMessage(options.timeoutMs)
148
+ : idleTimedOut
149
+ ? idleTimeoutMessage(idleMs)
150
+ : undefined;
101
151
  const finish = (result) => {
102
152
  if (settled)
103
153
  return;
104
154
  settled = true;
105
155
  if (timer !== undefined)
106
156
  clearTimeout(timer);
157
+ if (idleTimer !== undefined)
158
+ clearTimeout(idleTimer);
107
159
  if (killTimer !== undefined)
108
160
  clearTimeout(killTimer);
109
161
  options.cancelSignal?.removeEventListener('abort', onAbort);
@@ -155,6 +207,21 @@ export async function execAgent(options) {
155
207
  const terminate = () => {
156
208
  if (settled || killTimer !== undefined)
157
209
  return;
210
+ // Both clocks are disarmed here rather than in `finish`, because a
211
+ // termination takes up to KILL_GRACE_MS and neither clock may re-decide
212
+ // the reason during it. A ceiling that lapses while a stalled run is
213
+ // being killed would otherwise overwrite "no output for 15 minutes" with
214
+ // "timed out", sending whoever reads it to the wrong flag — and the
215
+ // child's dying output would likewise re-arm a watchdog for a run that
216
+ // is already on its way out.
217
+ if (timer !== undefined) {
218
+ clearTimeout(timer);
219
+ timer = undefined;
220
+ }
221
+ if (idleTimer !== undefined) {
222
+ clearTimeout(idleTimer);
223
+ idleTimer = undefined;
224
+ }
158
225
  signalTree('SIGTERM');
159
226
  killTimer = setTimeout(() => {
160
227
  signalTree('SIGKILL');
@@ -167,11 +234,30 @@ export async function execAgent(options) {
167
234
  stderr: clamp(stderr),
168
235
  exitCode: null,
169
236
  timedOut,
237
+ idleTimedOut,
170
238
  cancelled,
171
239
  error: stopReason(),
172
240
  });
173
241
  }, KILL_GRACE_MS);
174
242
  };
243
+ /**
244
+ * (Re)start the silence window.
245
+ *
246
+ * Armed once the child exists and again on every chunk it writes, so the
247
+ * window measures time since the last sign of life rather than time since
248
+ * the run began. Deliberately a no-op once a kill is under way: the run's
249
+ * fate is already decided, and the flag that explains it is already set.
250
+ */
251
+ const armIdleWatchdog = () => {
252
+ if (idleMs <= 0 || settled || killTimer !== undefined)
253
+ return;
254
+ if (idleTimer !== undefined)
255
+ clearTimeout(idleTimer);
256
+ idleTimer = setTimeout(() => {
257
+ idleTimedOut = true;
258
+ terminate();
259
+ }, idleMs);
260
+ };
175
261
  function onAbort() {
176
262
  cancelled = true;
177
263
  terminate();
@@ -185,6 +271,7 @@ export async function execAgent(options) {
185
271
  options.cancelSignal?.addEventListener('abort', onAbort, { once: true });
186
272
  child.stdout?.on('data', (chunk) => {
187
273
  const text = chunk.toString();
274
+ armIdleWatchdog();
188
275
  // Capture is bounded; the callback is not. A parser that stops being fed
189
276
  // at 512 KB would report stale progress and salvage the wrong text for
190
277
  // the rest of a long run — and the callback keeps nothing per chunk.
@@ -198,6 +285,10 @@ export async function execAgent(options) {
198
285
  }
199
286
  });
200
287
  child.stderr?.on('data', (chunk) => {
288
+ // stderr counts as life exactly as much as stdout does. An agent that
289
+ // logs its progress to stderr and answers on stdout — several do — is
290
+ // plainly working, and a watchdog watching one stream would kill it.
291
+ armIdleWatchdog();
201
292
  if (stderr.length < MAX_OUTPUT_CHARS)
202
293
  stderr += chunk.toString();
203
294
  });
@@ -208,21 +299,27 @@ export async function execAgent(options) {
208
299
  stderr: clamp(stderr),
209
300
  exitCode: null,
210
301
  timedOut,
302
+ idleTimedOut,
211
303
  cancelled,
212
304
  error: err.message,
213
305
  });
214
306
  });
215
307
  child.on('close', (code) => {
216
308
  finish({
217
- ok: !timedOut && !cancelled && code === 0,
309
+ ok: !timedOut && !idleTimedOut && !cancelled && code === 0,
218
310
  stdout: clamp(stdout),
219
311
  stderr: clamp(stderr),
220
312
  exitCode: code,
221
313
  timedOut,
314
+ idleTimedOut,
222
315
  cancelled,
223
316
  error: stopReason(),
224
317
  });
225
318
  });
319
+ // Started here, not at the top of the function: the window measures the
320
+ // child's silence, and a child that has not been spawned yet — or that
321
+ // failed to spawn at all — has nothing to be silent about.
322
+ armIdleWatchdog();
226
323
  // stdin may already be closed if the command failed to start.
227
324
  try {
228
325
  child.stdin?.on('error', () => {
@@ -50,6 +50,17 @@ export interface PromptContext {
50
50
  * They cannot tell those apart from a sentence that says neither.
51
51
  */
52
52
  timeoutMs?: number;
53
+ /**
54
+ * How long the run may produce nothing at all before it is killed, in
55
+ * milliseconds. ``0`` is `--idle-timeout 0`: no watchdog.
56
+ *
57
+ * Threaded in for the same reason as {@link timeoutMs}, and it now carries
58
+ * most of the weight: with no wall-clock ceiling by default, this is usually
59
+ * the only local limit there is. It is also a limit an agent can act on,
60
+ * which a duration is not — "do not go completely silent for a quarter of an
61
+ * hour" is advice about how to work, not a request to hurry.
62
+ */
63
+ idleTimeoutMs?: number;
53
64
  }
54
65
  /** Build the stdin prompt for one run. */
55
66
  export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;
@@ -23,31 +23,68 @@ function renderBudget(timeoutMs) {
23
23
  return `about ${Math.max(1, Math.round(timeoutMs / 1_000))} seconds`;
24
24
  }
25
25
  /**
26
- * The bullet that tells the agent how much time it actually has.
26
+ * The bullet that tells the agent what its run is actually bounded by.
27
27
  *
28
- * Both branches are load-bearing. A named ceiling stops an agent from treating
29
- * a two-hour budget like a five-minute one (the failure this replaced: a
30
- * multi-minute coding task killed at 300s having been told only that a limit
31
- * existed). And an *uncapped* run still needs a word about pace, because
32
- * nothing is posted until the turn ends — a run that explores forever reads,
33
- * from the thread, exactly like one that crashed.
28
+ * Three shapes, and each says something different about how to work:
29
+ *
30
+ * * **A wall-clock ceiling** (`--timeout <n>`) means budget the turn. Naming
31
+ * the number is what stops an agent from treating two hours like five
32
+ * minutes — the failure this replaced was a multi-minute coding task killed
33
+ * at 300s having been told only that "a limit" existed. A watchdog set
34
+ * *shorter* than the ceiling is named alongside it, because it is then the
35
+ * deadline that actually fires first: told only "you have two hours", an
36
+ * agent has no reason to break up the quiet forty-minute step that kills it
37
+ * at fifteen.
38
+ * * **The watchdog alone** — the default — means the turn is bounded by
39
+ * *silence*, not duration. That is a limit an agent can actually act on, and
40
+ * it must be stated as what it is, because an agent told it has "no limit"
41
+ * and then killed at fifteen minutes of quiet was told something false.
42
+ * * **Neither** means nothing local will stop the run, which still needs a word
43
+ * about pace: nothing is posted until the turn ends, so a run that explores
44
+ * forever reads, from the thread, exactly like one that crashed.
34
45
  */
35
- function timeBudgetLines(timeoutMs) {
36
- if (timeoutMs === undefined)
46
+ function timeBudgetLines(timeoutMs, idleTimeoutMs) {
47
+ // Said nothing about by the caller: say nothing back, rather than describe a
48
+ // regime this prompt has not been told is in force.
49
+ if (timeoutMs === undefined && idleTimeoutMs === undefined)
37
50
  return [];
38
- if (timeoutMs <= 0) {
51
+ const wallMs = timeoutMs ?? 0;
52
+ const idleMs = idleTimeoutMs ?? 0;
53
+ if (wallMs > 0) {
54
+ return [
55
+ `- You have ${renderBudget(wallMs)} for this whole turn. Past that you are killed`,
56
+ ' mid-thought and the person waiting gets nothing, so spend the budget on the',
57
+ ' work and leave yourself room to write the answer.',
58
+ // Only when it can actually fire first. A watchdog window at or above the
59
+ // ceiling is unreachable — a run silent that long has already overrun —
60
+ // and naming an unreachable limit is noise the agent has to reason past.
61
+ ...(idleMs > 0 && idleMs < wallMs
62
+ ? [
63
+ '- Going quiet ends the turn early, well before that: produce no output at',
64
+ ` all for ${renderBudget(idleMs)} and the run is killed and reported as`,
65
+ ' stalled. Ordinary work clears that on its own; a single step that will',
66
+ ' be silent for longer is the one thing to break up.',
67
+ ]
68
+ : []),
69
+ ];
70
+ }
71
+ if (idleMs > 0) {
39
72
  return [
40
- '- There is no fixed time limit on this turn, so work that genuinely takes',
41
- ' a while is fine — finish it rather than cutting it short. But nothing you',
42
- ' write is posted until the turn ends, and a long silence gets reported as a',
43
- ' stalled run, so keep moving toward an answer rather than exploring',
44
- ' indefinitely.',
73
+ '- There is no fixed time limit on this turn, so work that genuinely takes a',
74
+ ' while is fine — finish it rather than cutting it short. What is limited is',
75
+ ` silence: produce no output at all for ${renderBudget(idleMs)} and the run is`,
76
+ ' killed and reported as stalled. Ordinary work clears that on its own; a',
77
+ ' single step that will be quiet for longer than that is the one thing to',
78
+ ' break up. Nothing you write is posted until the turn ends, so keep moving',
79
+ ' toward an answer rather than exploring indefinitely.',
45
80
  ];
46
81
  }
47
82
  return [
48
- `- You have ${renderBudget(timeoutMs)} for this whole turn. Past that you are killed`,
49
- ' mid-thought and the person waiting gets nothing, so spend the budget on the',
50
- ' work and leave yourself room to write the answer.',
83
+ '- There is no time limit on this turn and nothing local will stop it, so work',
84
+ ' that genuinely takes a while is fine — finish it rather than cutting it',
85
+ ' short. But nothing you write is posted until the turn ends, so a run that',
86
+ ' explores indefinitely is indistinguishable from one that crashed. Keep',
87
+ ' moving toward an answer.',
51
88
  ];
52
89
  }
53
90
  /**
@@ -187,7 +224,7 @@ export function buildPrompt(run, context) {
187
224
  // not buried under the tool advice: how long the agent has changes how it
188
225
  // should approach the work, so it has to be read before any of the "go and
189
226
  // look" bullets below.
190
- ...timeBudgetLines(context.timeoutMs),
227
+ ...timeBudgetLines(context.timeoutMs, context.idleTimeoutMs),
191
228
  // Claude Code lazy-loads part of its catalog — WebSearch/WebFetch among
192
229
  // them — so a run that inspects its loaded tools concludes it cannot
193
230
  // browse and says so to a customer, when one ToolSearch call away it
@@ -26,18 +26,36 @@ import type { spawn } from 'node:child_process';
26
26
  import WebSocket from 'ws';
27
27
  import type { AgentCredentials } from './credentials.js';
28
28
  /**
29
- * Default ceiling on one agent invocation.
29
+ * Default ceiling on one agent invocation: none.
30
30
  *
31
- * Two hours, up from five minutes. Five was a guess made when a run meant
32
- * "answer a mention", and it was wrong in the expensive direction: a paired
33
- * brain given a real coding task in production was killed at 300s, mid-edit,
34
- * and reported `timed out after 300000ms` to the person waiting. A coding
35
- * agent's unit of work is not a chat reply, and no local clock can tell a
36
- * wedged run from a productive one — the server's lease can, which is what
37
- * heartbeats are for. This is now a backstop against a genuinely stuck process
38
- * rather than a shape imposed on the work; `--timeout 0` removes it entirely.
31
+ * The wall clock kept being raised — five minutes, then two hours — because it
32
+ * was answering the wrong question. "How long has this run been going?" does
33
+ * not distinguish a wedged child from a productive one; it only distinguishes
34
+ * long work from short, and killing long work is precisely the failure the two
35
+ * previous defaults each produced in production. What the harness can actually
36
+ * observe is whether the child is *doing* anything, which is what
37
+ * {@link DEFAULT_IDLE_TIMEOUT_MS} watches.
38
+ *
39
+ * So a run is now bounded by three things, none of them a fixed duration:
40
+ * the inactivity watchdog (default 15 minutes of total silence), the server's
41
+ * lease on the run (heartbeats stop, the row is reaped), and the Stop button in
42
+ * ProhostAI (which reaches this machine on the next heartbeat). `--timeout`
43
+ * remains fully supported for anyone who wants a hard ceiling as well.
44
+ */
45
+ export declare const DEFAULT_EXEC_TIMEOUT_MS = 0;
46
+ /**
47
+ * Default silence window before a run is treated as stalled.
48
+ *
49
+ * Fifteen minutes is chosen against what real work looks like, not against
50
+ * what feels responsive: an agent mid-task emits stream events, tool calls and
51
+ * log lines continuously, so the gaps in a healthy run are seconds. The long
52
+ * tail that legitimately goes quiet is a single slow tool call — a test suite,
53
+ * a build, a big fetch — and fifteen minutes clears those comfortably while
54
+ * still catching the failure this exists for, a child wedged on a stdin prompt
55
+ * that heartbeats look perfectly healthy for and that burns the operator's
56
+ * tokens and their agent's availability indefinitely.
39
57
  */
40
- export declare const DEFAULT_EXEC_TIMEOUT_MS = 7200000;
58
+ export declare const DEFAULT_IDLE_TIMEOUT_MS = 900000;
41
59
  export interface AgentRunOptions {
42
60
  /** Shell command that runs the agent, e.g. ``claude -p``. */
43
61
  exec: string;
@@ -46,8 +64,16 @@ export interface AgentRunOptions {
46
64
  url?: string;
47
65
  /** Machine name shown in the ProhostAI UI. Defaults to the OS hostname. */
48
66
  machine?: string;
49
- /** Per-run execution timeout. ``0`` disables it — see `--timeout 0`. */
67
+ /**
68
+ * Per-run wall-clock ceiling. ``0`` disables it, and that is now the default
69
+ * — see {@link DEFAULT_EXEC_TIMEOUT_MS}.
70
+ */
50
71
  timeoutMs?: number;
72
+ /**
73
+ * How long a run may produce no output at all before it is killed. ``0``
74
+ * disables the watchdog — see {@link DEFAULT_IDLE_TIMEOUT_MS}.
75
+ */
76
+ idleTimeoutMs?: number;
51
77
  /** Print the reply that would be posted and make no writes. */
52
78
  dryRun?: boolean;
53
79
  /** Execute runs whose signature could not be verified. Off by default. */
@@ -120,6 +146,8 @@ interface PreparedMcp {
120
146
  mcpConfigPath?: string;
121
147
  /** Codex: entries in the single verified `mcp_servers` override. */
122
148
  codexMcpEntries?: string[];
149
+ /** Codex: verified non-`mcp_servers` overrides, e.g. plugins off. */
150
+ codexConfigOverrides?: string[];
123
151
  /** Credentials moved out of Codex's process argv. */
124
152
  execEnv?: NodeJS.ProcessEnv;
125
153
  /** ProhostAI tools are actually reachable from this invocation. */