@prohost/cli 0.2.3 → 0.4.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.
@@ -6,6 +6,87 @@
6
6
  * a shell script that greps it. The reply contract is stated explicitly so an
7
7
  * agent that would otherwise narrate its reasoning returns something sendable.
8
8
  */
9
+ /**
10
+ * Say the run's budget in a unit a reader thinks in.
11
+ *
12
+ * Rounded on purpose — the agent is being told roughly how much room it has,
13
+ * and "about 120 minutes" is the honest shape of a ceiling it will never be
14
+ * told the exact remainder of.
15
+ */
16
+ function renderBudget(timeoutMs) {
17
+ if (timeoutMs >= 90 * 60_000) {
18
+ const hours = timeoutMs / 3_600_000;
19
+ return `about ${Number.isInteger(hours) ? hours : hours.toFixed(1)} hours`;
20
+ }
21
+ if (timeoutMs >= 60_000)
22
+ return `about ${Math.round(timeoutMs / 60_000)} minutes`;
23
+ return `about ${Math.max(1, Math.round(timeoutMs / 1_000))} seconds`;
24
+ }
25
+ /**
26
+ * The bullet that tells the agent what its run is actually bounded by.
27
+ *
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.
45
+ */
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)
50
+ return [];
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) {
72
+ return [
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.',
80
+ ];
81
+ }
82
+ return [
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.',
88
+ ];
89
+ }
9
90
  /**
10
91
  * Describe one attachment.
11
92
  *
@@ -139,6 +220,11 @@ export function buildPrompt(run, context) {
139
220
  ' as you call them. Do the work rather than proposing it — but that\n' +
140
221
  ' also means a mistaken action happens for real, so check before you\n' +
141
222
  ' change or delete anything.', '- Nothing about your access changes after this turn. Do not say a tool is', ' "connecting", "loading", or about to become available, and do not', ' promise to follow up.',
223
+ // Stated up here with the other facts about the shape of the invocation,
224
+ // not buried under the tool advice: how long the agent has changes how it
225
+ // should approach the work, so it has to be read before any of the "go and
226
+ // look" bullets below.
227
+ ...timeBudgetLines(context.timeoutMs, context.idleTimeoutMs),
142
228
  // Claude Code lazy-loads part of its catalog — WebSearch/WebFetch among
143
229
  // them — so a run that inspects its loaded tools concludes it cannot
144
230
  // browse and says so to a customer, when one ToolSearch call away it
@@ -169,8 +255,7 @@ export function buildPrompt(run, context) {
169
255
  ' tell anyone you have no access to some repository, codebase, log, or',
170
256
  ' capability, take a QUICK look for it: the usual places a checkout',
171
257
  ' lives, `gh auth status`, whether the command is on PATH. A quick',
172
- ' look, not an audit — this run has a hard time limit and somebody',
173
- ' waiting at the end of it.',
258
+ ' look, not an audit — there is somebody waiting at the end of this.',
174
259
  '- "Looking" means running the command, not inspecting your own tool',
175
260
  ' list. Say you have no shell, no file access, or no checkout only',
176
261
  ' after an attempt failed and you can quote what it said. Introspection',
@@ -16,12 +16,46 @@
16
16
  * * **Runs are deduped on ``run_id``.** Webhook deliveries retry, and each
17
17
  * attempt is mirrored to this socket. Without dedupe a retry would execute
18
18
  * the command a second time and post a second reply.
19
+ *
20
+ * Runs can be long — hours — so every run this process has accepted and not yet
21
+ * reported is heartbeated (see `heartbeat.ts`), including the ones still queued
22
+ * behind the executing one. That keeps the server's lease alive and is the only
23
+ * way a stop pressed in the UI reaches a machine that accepts no connections.
19
24
  */
20
25
  import type { spawn } from 'node:child_process';
21
26
  import WebSocket from 'ws';
22
27
  import type { AgentCredentials } from './credentials.js';
23
- /** Default ceiling on one agent invocation. */
24
- export declare const DEFAULT_EXEC_TIMEOUT_MS = 300000;
28
+ /**
29
+ * Default ceiling on one agent invocation: none.
30
+ *
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.
57
+ */
58
+ export declare const DEFAULT_IDLE_TIMEOUT_MS = 900000;
25
59
  export interface AgentRunOptions {
26
60
  /** Shell command that runs the agent, e.g. ``claude -p``. */
27
61
  exec: string;
@@ -30,8 +64,16 @@ export interface AgentRunOptions {
30
64
  url?: string;
31
65
  /** Machine name shown in the ProhostAI UI. Defaults to the OS hostname. */
32
66
  machine?: string;
33
- /** Per-run execution timeout. */
67
+ /**
68
+ * Per-run wall-clock ceiling. ``0`` disables it, and that is now the default
69
+ * — see {@link DEFAULT_EXEC_TIMEOUT_MS}.
70
+ */
34
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;
35
77
  /** Print the reply that would be posted and make no writes. */
36
78
  dryRun?: boolean;
37
79
  /** Execute runs whose signature could not be verified. Off by default. */
@@ -73,6 +115,12 @@ export interface AgentRunOptions {
73
115
  };
74
116
  /** Environment used to locate the run ledger (tests point this at a temp dir). */
75
117
  env?: NodeJS.ProcessEnv;
118
+ /**
119
+ * How often open runs are heartbeated. Defaults to
120
+ * {@link HEARTBEAT_INTERVAL_MS}; tests shorten it so a cancellation arrives
121
+ * inside a test's lifetime.
122
+ */
123
+ heartbeatIntervalMs?: number;
76
124
  }
77
125
  export interface AgentRunMetrics {
78
126
  connections: number;