@prohost/cli 0.2.2 → 0.3.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.
- package/CHANGELOG.md +169 -0
- package/README.md +170 -21
- package/dist/agent/api.d.ts +53 -5
- package/dist/agent/api.js +51 -11
- package/dist/agent/claude.d.ts +74 -4
- package/dist/agent/claude.js +172 -21
- package/dist/agent/codex.d.ts +215 -11
- package/dist/agent/codex.js +555 -23
- package/dist/agent/command.js +11 -2
- package/dist/agent/contract.d.ts +39 -0
- package/dist/agent/contract.js +47 -0
- package/dist/agent/exec.d.ts +42 -4
- package/dist/agent/exec.js +104 -18
- package/dist/agent/extras.d.ts +73 -0
- package/dist/agent/extras.js +118 -0
- package/dist/agent/heartbeat.d.ts +114 -0
- package/dist/agent/heartbeat.js +165 -0
- package/dist/agent/mcp.d.ts +7 -6
- package/dist/agent/mcp.js +7 -6
- package/dist/agent/prompt.d.ts +17 -5
- package/dist/agent/prompt.js +66 -2
- package/dist/agent/run.d.ts +58 -3
- package/dist/agent/run.js +525 -97
- package/dist/index.js +13 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps long runs alive, and is how a laptop hears "stop".
|
|
3
|
+
*
|
|
4
|
+
* The server holds a lease on every `running` agent-run row and reaps the ones
|
|
5
|
+
* that go quiet, because the alternative — trusting a laptop to always report —
|
|
6
|
+
* leaves rows spinning forever whenever someone closes a lid. A run that
|
|
7
|
+
* legitimately takes four hours therefore has to say so on a cadence.
|
|
8
|
+
*
|
|
9
|
+
* The response is the other half: this CLI dials *out* over a websocket and
|
|
10
|
+
* accepts no inbound connections, so the heartbeat's `cancel_requested` flag is
|
|
11
|
+
* the only channel by which "somebody pressed stop in the UI" can reach a
|
|
12
|
+
* machine behind NAT.
|
|
13
|
+
*
|
|
14
|
+
* Two properties this module is written around:
|
|
15
|
+
*
|
|
16
|
+
* * **Every accepted run beats, not just the executing one.** Runs are
|
|
17
|
+
* serialized, so a run queued behind a four-hour job sits `running`
|
|
18
|
+
* server-side for four hours without a single line of work being done on it.
|
|
19
|
+
* Beating only for the executing run would have the server reap the queue.
|
|
20
|
+
* * **It never takes the daemon down.** A heartbeat is bookkeeping. Every
|
|
21
|
+
* failure mode — an old server that 404s, a dropped network, a handler that
|
|
22
|
+
* throws — costs at most the beat it happened on.
|
|
23
|
+
*/
|
|
24
|
+
import type { ApiOptions } from './api.js';
|
|
25
|
+
/**
|
|
26
|
+
* How often each open run is reported.
|
|
27
|
+
*
|
|
28
|
+
* Well inside any sane server-side lease so a run survives losing a beat or
|
|
29
|
+
* two to a flaky link, and long enough that a queue of runs is a trickle of
|
|
30
|
+
* requests rather than a load source.
|
|
31
|
+
*/
|
|
32
|
+
export declare const HEARTBEAT_INTERVAL_MS = 60000;
|
|
33
|
+
/** A cancelled timer. Returned by {@link Scheduler} implementations. */
|
|
34
|
+
export interface ScheduledTick {
|
|
35
|
+
cancel(): void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Deferred execution, injectable so tests drive the loop rather than wait it
|
|
39
|
+
* out. The default unrefs its timer: a heartbeat loop must never be the reason
|
|
40
|
+
* a process refuses to exit.
|
|
41
|
+
*/
|
|
42
|
+
export type Scheduler = (run: () => void, ms: number) => ScheduledTick;
|
|
43
|
+
/** One run the loop is beating for. */
|
|
44
|
+
export interface HeartbeatTarget {
|
|
45
|
+
/** Where the heartbeat POSTs — see `heartbeatPathFor`. */
|
|
46
|
+
path: string;
|
|
47
|
+
/**
|
|
48
|
+
* Latest one-line description of what the agent is doing, read fresh at each
|
|
49
|
+
* beat. Absent for a queued run, and for brains whose output we don't stream.
|
|
50
|
+
*/
|
|
51
|
+
progress?: () => string | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* The server wants this run stopped. Called at most once, and never again
|
|
54
|
+
* beaten for.
|
|
55
|
+
*/
|
|
56
|
+
onCancelRequested: () => void | Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* The server has already closed this run out (409) — reaped, or resolved by
|
|
59
|
+
* someone else. Called at most once. The work in flight is now pointless:
|
|
60
|
+
* whatever it produces has nowhere to go.
|
|
61
|
+
*/
|
|
62
|
+
onResolved: () => void | Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
export interface HeartbeatLoopOptions {
|
|
65
|
+
api: ApiOptions;
|
|
66
|
+
log: (line: string) => void;
|
|
67
|
+
/** Defaults to {@link HEARTBEAT_INTERVAL_MS}. */
|
|
68
|
+
intervalMs?: number;
|
|
69
|
+
/** Defaults to an unref'd `setTimeout`. */
|
|
70
|
+
scheduler?: Scheduler;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Beats for every open run on a fixed cadence.
|
|
74
|
+
*
|
|
75
|
+
* Idle when nothing is tracked — no timer is armed at all — so a harness that
|
|
76
|
+
* never receives a run makes no requests, and the first {@link track} starts
|
|
77
|
+
* the cadence.
|
|
78
|
+
*/
|
|
79
|
+
export declare class HeartbeatLoop {
|
|
80
|
+
private readonly options;
|
|
81
|
+
private readonly targets;
|
|
82
|
+
private readonly intervalMs;
|
|
83
|
+
private readonly scheduler;
|
|
84
|
+
private pending;
|
|
85
|
+
private stopped;
|
|
86
|
+
/** A tick in flight. Ticks never overlap; a slow one delays the next. */
|
|
87
|
+
private ticking;
|
|
88
|
+
constructor(options: HeartbeatLoopOptions);
|
|
89
|
+
/** Begin beating for a run. Idempotent on ``runId``. */
|
|
90
|
+
track(runId: string, target: HeartbeatTarget): void;
|
|
91
|
+
/** Stop beating for a run — it reached a terminal state, one way or another. */
|
|
92
|
+
untrack(runId: string): void;
|
|
93
|
+
/** Runs currently being beaten for. Exposed for assertions and logging. */
|
|
94
|
+
tracked(): string[];
|
|
95
|
+
/** Stop the loop for good. Called once, when the harness is shutting down. */
|
|
96
|
+
stop(): void;
|
|
97
|
+
/**
|
|
98
|
+
* Beat once for every tracked run.
|
|
99
|
+
*
|
|
100
|
+
* Public so tests can drive the cadence directly instead of waiting on a
|
|
101
|
+
* timer. Beats are issued concurrently: they are independent requests, and
|
|
102
|
+
* doing them in series would make one slow response delay every run behind it.
|
|
103
|
+
*/
|
|
104
|
+
tick(): Promise<void>;
|
|
105
|
+
private arm;
|
|
106
|
+
private runTick;
|
|
107
|
+
/**
|
|
108
|
+
* One run's heartbeat, and what its answer means.
|
|
109
|
+
*
|
|
110
|
+
* Wrapped so that a throwing handler — or a fetch implementation that rejects
|
|
111
|
+
* rather than resolving — costs this run's beat and nothing else.
|
|
112
|
+
*/
|
|
113
|
+
private beat;
|
|
114
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps long runs alive, and is how a laptop hears "stop".
|
|
3
|
+
*
|
|
4
|
+
* The server holds a lease on every `running` agent-run row and reaps the ones
|
|
5
|
+
* that go quiet, because the alternative — trusting a laptop to always report —
|
|
6
|
+
* leaves rows spinning forever whenever someone closes a lid. A run that
|
|
7
|
+
* legitimately takes four hours therefore has to say so on a cadence.
|
|
8
|
+
*
|
|
9
|
+
* The response is the other half: this CLI dials *out* over a websocket and
|
|
10
|
+
* accepts no inbound connections, so the heartbeat's `cancel_requested` flag is
|
|
11
|
+
* the only channel by which "somebody pressed stop in the UI" can reach a
|
|
12
|
+
* machine behind NAT.
|
|
13
|
+
*
|
|
14
|
+
* Two properties this module is written around:
|
|
15
|
+
*
|
|
16
|
+
* * **Every accepted run beats, not just the executing one.** Runs are
|
|
17
|
+
* serialized, so a run queued behind a four-hour job sits `running`
|
|
18
|
+
* server-side for four hours without a single line of work being done on it.
|
|
19
|
+
* Beating only for the executing run would have the server reap the queue.
|
|
20
|
+
* * **It never takes the daemon down.** A heartbeat is bookkeeping. Every
|
|
21
|
+
* failure mode — an old server that 404s, a dropped network, a handler that
|
|
22
|
+
* throws — costs at most the beat it happened on.
|
|
23
|
+
*/
|
|
24
|
+
import { heartbeatRun } from './api.js';
|
|
25
|
+
/**
|
|
26
|
+
* How often each open run is reported.
|
|
27
|
+
*
|
|
28
|
+
* Well inside any sane server-side lease so a run survives losing a beat or
|
|
29
|
+
* two to a flaky link, and long enough that a queue of runs is a trickle of
|
|
30
|
+
* requests rather than a load source.
|
|
31
|
+
*/
|
|
32
|
+
export const HEARTBEAT_INTERVAL_MS = 60_000;
|
|
33
|
+
const defaultScheduler = (run, ms) => {
|
|
34
|
+
const timer = setTimeout(run, ms);
|
|
35
|
+
// `unref` exists on Node/Bun timers and not in the DOM lib's typing.
|
|
36
|
+
timer.unref?.();
|
|
37
|
+
return { cancel: () => clearTimeout(timer) };
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Beats for every open run on a fixed cadence.
|
|
41
|
+
*
|
|
42
|
+
* Idle when nothing is tracked — no timer is armed at all — so a harness that
|
|
43
|
+
* never receives a run makes no requests, and the first {@link track} starts
|
|
44
|
+
* the cadence.
|
|
45
|
+
*/
|
|
46
|
+
export class HeartbeatLoop {
|
|
47
|
+
options;
|
|
48
|
+
targets = new Map();
|
|
49
|
+
intervalMs;
|
|
50
|
+
scheduler;
|
|
51
|
+
pending;
|
|
52
|
+
stopped = false;
|
|
53
|
+
/** A tick in flight. Ticks never overlap; a slow one delays the next. */
|
|
54
|
+
ticking = false;
|
|
55
|
+
constructor(options) {
|
|
56
|
+
this.options = options;
|
|
57
|
+
this.intervalMs = options.intervalMs ?? HEARTBEAT_INTERVAL_MS;
|
|
58
|
+
this.scheduler = options.scheduler ?? defaultScheduler;
|
|
59
|
+
}
|
|
60
|
+
/** Begin beating for a run. Idempotent on ``runId``. */
|
|
61
|
+
track(runId, target) {
|
|
62
|
+
if (this.stopped)
|
|
63
|
+
return;
|
|
64
|
+
this.targets.set(runId, { ...target, runId });
|
|
65
|
+
this.arm();
|
|
66
|
+
}
|
|
67
|
+
/** Stop beating for a run — it reached a terminal state, one way or another. */
|
|
68
|
+
untrack(runId) {
|
|
69
|
+
this.targets.delete(runId);
|
|
70
|
+
}
|
|
71
|
+
/** Runs currently being beaten for. Exposed for assertions and logging. */
|
|
72
|
+
tracked() {
|
|
73
|
+
return [...this.targets.keys()];
|
|
74
|
+
}
|
|
75
|
+
/** Stop the loop for good. Called once, when the harness is shutting down. */
|
|
76
|
+
stop() {
|
|
77
|
+
this.stopped = true;
|
|
78
|
+
this.targets.clear();
|
|
79
|
+
this.pending?.cancel();
|
|
80
|
+
this.pending = undefined;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Beat once for every tracked run.
|
|
84
|
+
*
|
|
85
|
+
* Public so tests can drive the cadence directly instead of waiting on a
|
|
86
|
+
* timer. Beats are issued concurrently: they are independent requests, and
|
|
87
|
+
* doing them in series would make one slow response delay every run behind it.
|
|
88
|
+
*/
|
|
89
|
+
async tick() {
|
|
90
|
+
const targets = [...this.targets.values()];
|
|
91
|
+
await Promise.all(targets.map((target) => this.beat(target)));
|
|
92
|
+
}
|
|
93
|
+
arm() {
|
|
94
|
+
if (this.stopped || this.pending || this.ticking || this.targets.size === 0)
|
|
95
|
+
return;
|
|
96
|
+
this.pending = this.scheduler(() => {
|
|
97
|
+
this.pending = undefined;
|
|
98
|
+
void this.runTick();
|
|
99
|
+
}, this.intervalMs);
|
|
100
|
+
}
|
|
101
|
+
async runTick() {
|
|
102
|
+
this.ticking = true;
|
|
103
|
+
try {
|
|
104
|
+
await this.tick();
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
// `tick` already contains every beat's failure; reaching here means the
|
|
108
|
+
// bookkeeping around them broke. Say so and keep the cadence — a harness
|
|
109
|
+
// that stops beating because of a bug here silently loses every long run.
|
|
110
|
+
this.options.log(` ! heartbeat loop error: ${err instanceof Error ? err.message : String(err)}`);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
this.ticking = false;
|
|
114
|
+
this.arm();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* One run's heartbeat, and what its answer means.
|
|
119
|
+
*
|
|
120
|
+
* Wrapped so that a throwing handler — or a fetch implementation that rejects
|
|
121
|
+
* rather than resolving — costs this run's beat and nothing else.
|
|
122
|
+
*/
|
|
123
|
+
async beat(target) {
|
|
124
|
+
try {
|
|
125
|
+
const outcome = await heartbeatRun(this.options.api, target.path, target.progress?.());
|
|
126
|
+
// The run may have finished while its own heartbeat was in flight; acting
|
|
127
|
+
// on a stale answer would cancel a run that is already reporting itself.
|
|
128
|
+
if (!this.targets.has(target.runId))
|
|
129
|
+
return;
|
|
130
|
+
if (outcome.kind === 'alive') {
|
|
131
|
+
if (!outcome.cancelRequested)
|
|
132
|
+
return;
|
|
133
|
+
this.untrack(target.runId);
|
|
134
|
+
this.options.log(` ⏹ run ${target.runId.slice(0, 12)} was cancelled from ProhostAI — stopping it`);
|
|
135
|
+
await target.onCancelRequested();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (outcome.kind === 'unknown') {
|
|
139
|
+
// Said once per run, then silence: on a server that predates
|
|
140
|
+
// heartbeats this is the answer for every run forever, and a line a
|
|
141
|
+
// minute per run would bury the log that operators actually read.
|
|
142
|
+
this.untrack(target.runId);
|
|
143
|
+
this.options.log(` ◦ run ${target.runId.slice(0, 12)} cannot be kept alive (HTTP 404) — this ProhostAI ` +
|
|
144
|
+
'server has no heartbeat endpoint, or no longer knows this run. Long runs may be ' +
|
|
145
|
+
'reaped while they are still working.');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (outcome.kind === 'resolved') {
|
|
149
|
+
this.untrack(target.runId);
|
|
150
|
+
this.options.log(` ⏹ run ${target.runId.slice(0, 12)} was already closed server-side — abandoning it`);
|
|
151
|
+
await target.onResolved();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Transient by assumption: keep the cadence rather than give up on the
|
|
155
|
+
// run. A run whose beats all fail is reaped server-side eventually, which
|
|
156
|
+
// is the correct end state for a machine that cannot reach the API.
|
|
157
|
+
this.options.log(` ! heartbeat for run ${target.runId.slice(0, 12)} failed (HTTP ${outcome.status})` +
|
|
158
|
+
`${outcome.detail ? ` ${outcome.detail}` : ''}`);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
this.options.log(` ! heartbeat for run ${target.runId.slice(0, 12)} threw: ` +
|
|
162
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
package/dist/agent/mcp.d.ts
CHANGED
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export declare const MCP_SERVER_NAME = "prohost";
|
|
14
14
|
/**
|
|
15
|
-
* How
|
|
15
|
+
* How both shaped brains spell the wired-in tools.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* Claude Code's spelling was known from its documentation. Codex's was an open
|
|
18
|
+
* question until 0.2.3 — the prompt named the server and left the pattern out
|
|
19
|
+
* rather than guess — and the answer, measured by registering a stub MCP server
|
|
20
|
+
* against a real `codex-cli 0.144.1` and asking the model what it had just
|
|
21
|
+
* called, is that Codex uses the same `mcp__<server>__<tool>` convention. So
|
|
22
|
+
* both brains now get the pattern. See {@link ../prompt.ts}.
|
|
22
23
|
*/
|
|
23
24
|
export declare const MCP_TOOL_PATTERN = "mcp__prohost__*";
|
package/dist/agent/mcp.js
CHANGED
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export const MCP_SERVER_NAME = 'prohost';
|
|
14
14
|
/**
|
|
15
|
-
* How
|
|
15
|
+
* How both shaped brains spell the wired-in tools.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* Claude Code's spelling was known from its documentation. Codex's was an open
|
|
18
|
+
* question until 0.2.3 — the prompt named the server and left the pattern out
|
|
19
|
+
* rather than guess — and the answer, measured by registering a stub MCP server
|
|
20
|
+
* against a real `codex-cli 0.144.1` and asking the model what it had just
|
|
21
|
+
* called, is that Codex uses the same `mcp__<server>__<tool>` convention. So
|
|
22
|
+
* both brains now get the pattern. See {@link ../prompt.ts}.
|
|
22
23
|
*/
|
|
23
24
|
export const MCP_TOOL_PATTERN = `mcp__${MCP_SERVER_NAME}__*`;
|
package/dist/agent/prompt.d.ts
CHANGED
|
@@ -22,11 +22,12 @@ export interface PromptContext {
|
|
|
22
22
|
* How this particular brain spells the wired-in tools, e.g. `mcp__prohost__*`.
|
|
23
23
|
*
|
|
24
24
|
* Set only when the harness has actually verified the spelling for the brain
|
|
25
|
-
* it is driving — Claude Code,
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
25
|
+
* it is driving — Claude Code and, since 0.2.3, Codex, which turned out to
|
|
26
|
+
* use the same convention. Left unset for every other `--exec` command,
|
|
27
|
+
* whose namespacing we have no way to know: the prompt then names the server
|
|
28
|
+
* without claiming a prefix, because a wrong tool name is the same failure as
|
|
29
|
+
* a wrong endpoint. The agent can see its own tool list; it does not need us
|
|
30
|
+
* to guess at it.
|
|
30
31
|
*/
|
|
31
32
|
mcpToolPattern?: string;
|
|
32
33
|
/**
|
|
@@ -38,6 +39,17 @@ export interface PromptContext {
|
|
|
38
39
|
* decline work it is perfectly able to do.
|
|
39
40
|
*/
|
|
40
41
|
toolsGated?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* How long this run may take before it is killed, in milliseconds. ``0`` is
|
|
44
|
+
* `--timeout 0`: no local cap at all.
|
|
45
|
+
*
|
|
46
|
+
* Threaded in from the harness rather than assumed, because the prompt used
|
|
47
|
+
* to say "this run has a hard time limit" without ever naming it — which an
|
|
48
|
+
* agent reads as "hurry", whatever the number happens to be. An agent given
|
|
49
|
+
* five minutes should triage; an agent given two hours should do the work.
|
|
50
|
+
* They cannot tell those apart from a sentence that says neither.
|
|
51
|
+
*/
|
|
52
|
+
timeoutMs?: number;
|
|
41
53
|
}
|
|
42
54
|
/** Build the stdin prompt for one run. */
|
|
43
55
|
export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;
|
package/dist/agent/prompt.js
CHANGED
|
@@ -6,6 +6,50 @@
|
|
|
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 how much time it actually has.
|
|
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.
|
|
34
|
+
*/
|
|
35
|
+
function timeBudgetLines(timeoutMs) {
|
|
36
|
+
if (timeoutMs === undefined)
|
|
37
|
+
return [];
|
|
38
|
+
if (timeoutMs <= 0) {
|
|
39
|
+
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.',
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
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.',
|
|
51
|
+
];
|
|
52
|
+
}
|
|
9
53
|
/**
|
|
10
54
|
* Describe one attachment.
|
|
11
55
|
*
|
|
@@ -139,12 +183,27 @@ export function buildPrompt(run, context) {
|
|
|
139
183
|
' as you call them. Do the work rather than proposing it — but that\n' +
|
|
140
184
|
' also means a mistaken action happens for real, so check before you\n' +
|
|
141
185
|
' 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.',
|
|
186
|
+
// Stated up here with the other facts about the shape of the invocation,
|
|
187
|
+
// not buried under the tool advice: how long the agent has changes how it
|
|
188
|
+
// should approach the work, so it has to be read before any of the "go and
|
|
189
|
+
// look" bullets below.
|
|
190
|
+
...timeBudgetLines(context.timeoutMs),
|
|
142
191
|
// Claude Code lazy-loads part of its catalog — WebSearch/WebFetch among
|
|
143
192
|
// them — so a run that inspects its loaded tools concludes it cannot
|
|
144
193
|
// browse and says so to a customer, when one ToolSearch call away it
|
|
145
194
|
// could. Field-observed on the first 0.2.0 deployment; phrased
|
|
146
195
|
// conditionally because other `--exec` brains have no ToolSearch.
|
|
147
196
|
'- If you have a ToolSearch tool, part of your toolkit is deferred and', ' invisible until loaded — web browsing (WebSearch, WebFetch) usually', ' is. Search ToolSearch for a capability before telling anyone you', ' lack it.',
|
|
197
|
+
// The correction to the bullet above, and it is here because the bullet
|
|
198
|
+
// above caused a real production failure. A paired brain searched
|
|
199
|
+
// ToolSearch for "bash", found nothing — ToolSearch indexes only the
|
|
200
|
+
// *deferred* tools, and Bash is a direct one — and concluded across a
|
|
201
|
+
// whole resumed session that it had no shell, while
|
|
202
|
+
// `--dangerously-skip-permissions` had been handing it the full toolset
|
|
203
|
+
// the entire time. "Search before you claim you lack it" quietly taught
|
|
204
|
+
// the opposite of what it meant: that a ToolSearch miss is evidence of
|
|
205
|
+
// absence. It is not evidence of anything.
|
|
206
|
+
'- A ToolSearch miss proves nothing. ToolSearch indexes only the deferred', ' tools; the tools listed directly to you are the authoritative set, and', ' a tool can be fully available while ToolSearch has never heard of it', ' (Bash is exactly this). Never reason from a ToolSearch miss to "I do', ' not have that tool".',
|
|
148
207
|
// The counterpart to the ToolSearch bullet, and the same failure one level
|
|
149
208
|
// up: a brain that assumes it is boxed in reports "I have no access to the
|
|
150
209
|
// codebase" from a machine with the checkout, the credentials and the CLI
|
|
@@ -159,8 +218,13 @@ export function buildPrompt(run, context) {
|
|
|
159
218
|
' tell anyone you have no access to some repository, codebase, log, or',
|
|
160
219
|
' capability, take a QUICK look for it: the usual places a checkout',
|
|
161
220
|
' lives, `gh auth status`, whether the command is on PATH. A quick',
|
|
162
|
-
' look, not an audit —
|
|
163
|
-
'
|
|
221
|
+
' look, not an audit — there is somebody waiting at the end of this.',
|
|
222
|
+
'- "Looking" means running the command, not inspecting your own tool',
|
|
223
|
+
' list. Say you have no shell, no file access, or no checkout only',
|
|
224
|
+
' after an attempt failed and you can quote what it said. Introspection',
|
|
225
|
+
' is not evidence here: you were started with your tools unrestricted,',
|
|
226
|
+
' so a capability you cannot find in a list is far more likely to be',
|
|
227
|
+
' one you have not tried.',
|
|
164
228
|
]),
|
|
165
229
|
// Only when there is something to look at. Said unconditionally it is a
|
|
166
230
|
// paragraph of advice about a situation that does not exist, which is how a
|
package/dist/agent/run.d.ts
CHANGED
|
@@ -16,12 +16,28 @@
|
|
|
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
|
-
/**
|
|
24
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Default ceiling on one agent invocation.
|
|
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.
|
|
39
|
+
*/
|
|
40
|
+
export declare const DEFAULT_EXEC_TIMEOUT_MS = 7200000;
|
|
25
41
|
export interface AgentRunOptions {
|
|
26
42
|
/** Shell command that runs the agent, e.g. ``claude -p``. */
|
|
27
43
|
exec: string;
|
|
@@ -30,7 +46,7 @@ export interface AgentRunOptions {
|
|
|
30
46
|
url?: string;
|
|
31
47
|
/** Machine name shown in the ProhostAI UI. Defaults to the OS hostname. */
|
|
32
48
|
machine?: string;
|
|
33
|
-
/** Per-run execution timeout. */
|
|
49
|
+
/** Per-run execution timeout. ``0`` disables it — see `--timeout 0`. */
|
|
34
50
|
timeoutMs?: number;
|
|
35
51
|
/** Print the reply that would be posted and make no writes. */
|
|
36
52
|
dryRun?: boolean;
|
|
@@ -59,8 +75,26 @@ export interface AgentRunOptions {
|
|
|
59
75
|
maxConnections?: number;
|
|
60
76
|
/** Injectable sleep so tests don't wait out the reconnect backoff. */
|
|
61
77
|
sleepImpl?: (ms: number) => Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* Injectable short-command runner, used before Codex runs to ask which MCP
|
|
80
|
+
* servers the operator's current config defines. Separate from
|
|
81
|
+
* {@link spawnImpl} because it is synchronous and captures stdout, and
|
|
82
|
+
* because a test wants to answer this question without answering the other.
|
|
83
|
+
* The second argument is the exact cwd the eventual Codex process receives;
|
|
84
|
+
* Codex discovers project-local config from it.
|
|
85
|
+
*/
|
|
86
|
+
probeImpl?: (command: string, cwd?: string) => {
|
|
87
|
+
status: number | null;
|
|
88
|
+
stdout: string;
|
|
89
|
+
};
|
|
62
90
|
/** Environment used to locate the run ledger (tests point this at a temp dir). */
|
|
63
91
|
env?: NodeJS.ProcessEnv;
|
|
92
|
+
/**
|
|
93
|
+
* How often open runs are heartbeated. Defaults to
|
|
94
|
+
* {@link HEARTBEAT_INTERVAL_MS}; tests shorten it so a cancellation arrives
|
|
95
|
+
* inside a test's lifetime.
|
|
96
|
+
*/
|
|
97
|
+
heartbeatIntervalMs?: number;
|
|
64
98
|
}
|
|
65
99
|
export interface AgentRunMetrics {
|
|
66
100
|
connections: number;
|
|
@@ -81,6 +115,26 @@ export interface AgentRunMetrics {
|
|
|
81
115
|
*/
|
|
82
116
|
fatal?: string;
|
|
83
117
|
}
|
|
118
|
+
interface PreparedMcp {
|
|
119
|
+
/** Claude Code: path of the strict generated MCP config. */
|
|
120
|
+
mcpConfigPath?: string;
|
|
121
|
+
/** Codex: entries in the single verified `mcp_servers` override. */
|
|
122
|
+
codexMcpEntries?: string[];
|
|
123
|
+
/** Credentials moved out of Codex's process argv. */
|
|
124
|
+
execEnv?: NodeJS.ProcessEnv;
|
|
125
|
+
/** ProhostAI tools are actually reachable from this invocation. */
|
|
126
|
+
mcpWired: boolean;
|
|
127
|
+
/** A fail-closed preparation error. When set, the agent is never spawned. */
|
|
128
|
+
error?: string;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Prepare a fresh Codex MCP override immediately before each invocation.
|
|
132
|
+
*
|
|
133
|
+
* No cache is safe here: Codex rereads its config per invocation and the
|
|
134
|
+
* extras file is the operator's live grant list. Fresh reads make both a new
|
|
135
|
+
* configured server and a revoked extra visible to the very next run.
|
|
136
|
+
*/
|
|
137
|
+
export declare function codexMcpEntryProvider(options: AgentRunOptions, log: (line: string) => void, workdir?: string): () => PreparedMcp;
|
|
84
138
|
/**
|
|
85
139
|
* Run the harness until the abort signal fires (or ``maxConnections`` is hit).
|
|
86
140
|
*
|
|
@@ -88,3 +142,4 @@ export interface AgentRunMetrics {
|
|
|
88
142
|
* is fatal, because retrying a rejected credential just hammers the server.
|
|
89
143
|
*/
|
|
90
144
|
export declare function runAgentHarness(options: AgentRunOptions): Promise<AgentRunMetrics>;
|
|
145
|
+
export {};
|