@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
package/dist/agent/command.js
CHANGED
|
@@ -35,14 +35,23 @@ const INVALID_TIMEOUT = Symbol('invalid-timeout');
|
|
|
35
35
|
*
|
|
36
36
|
* Returns ``undefined`` when the flag was absent (the harness applies its own
|
|
37
37
|
* default) and {@link INVALID_TIMEOUT} after printing the error.
|
|
38
|
+
*
|
|
39
|
+
* ``0`` is legal and means "no local cap": the run ends when the agent ends it,
|
|
40
|
+
* when somebody cancels it, or when the server stops renewing its lease. It was
|
|
41
|
+
* rejected until long runs existed, because before heartbeats an uncapped run
|
|
42
|
+
* on a laptop that went to sleep was a row that span forever.
|
|
38
43
|
*/
|
|
39
44
|
function parseTimeoutSeconds(flags) {
|
|
40
45
|
const raw = flagString(flags, 'timeout');
|
|
41
46
|
if (raw === undefined)
|
|
42
47
|
return undefined;
|
|
43
48
|
const seconds = Number(raw);
|
|
44
|
-
if (!Number.isFinite(seconds) || seconds
|
|
45
|
-
|
|
49
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
50
|
+
// Keeps the phrase "--timeout must be a positive number" verbatim: the
|
|
51
|
+
// desktop app's supervisor matches on it to tell a misconfigured child from
|
|
52
|
+
// a recoverable crash (`desktop/src/main/agent/supervisor-policy.ts`), and
|
|
53
|
+
// it reads this CLI's stderr rather than any structured output.
|
|
54
|
+
process.stderr.write('--timeout must be a positive number of seconds, or 0 for no local limit.\n');
|
|
46
55
|
return INVALID_TIMEOUT;
|
|
47
56
|
}
|
|
48
57
|
return seconds;
|
package/dist/agent/contract.d.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* Server-side sources of truth:
|
|
14
14
|
* - payload shape — `shared/events/payloads/agent_runs.py`
|
|
15
15
|
* - `POST /v1/agent-runs/{id}/complete` — `api_public/routers/agent_runs.py`
|
|
16
|
+
* - `POST /v1/agent-runs/{id}/heartbeat` — `api_public/routers/agent_runs.py`
|
|
16
17
|
* - `POST /v1/agent-pairing/redeem` — `api_public/routers/agent_pairing.py`
|
|
17
18
|
* - `POST /v1/conversations/{id}/messages` — `api_public/routers/conversations.py`
|
|
18
19
|
*/
|
|
@@ -28,12 +29,50 @@ export declare const RUN_STATUS_SUCCEEDED = "succeeded";
|
|
|
28
29
|
export declare const RUN_STATUS_FAILED = "failed";
|
|
29
30
|
/** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
|
|
30
31
|
export declare const MAX_ERROR_CHARS = 1000;
|
|
32
|
+
/**
|
|
33
|
+
* The completion `error` that means "a person asked for this run to stop".
|
|
34
|
+
*
|
|
35
|
+
* A magic string on purpose, and it must be sent **exactly**: the server
|
|
36
|
+
* matches on it to word the notice it drops in the thread ("cancelled" rather
|
|
37
|
+
* than "failed"). Prefixing it with a reason, or sending
|
|
38
|
+
* `cancelled_by_user: …`, makes a deliberate stop read as a crash.
|
|
39
|
+
*/
|
|
40
|
+
export declare const RUN_ERROR_CANCELLED_BY_USER = "cancelled_by_user";
|
|
41
|
+
/**
|
|
42
|
+
* Cap on the `progress` string sent with a heartbeat.
|
|
43
|
+
*
|
|
44
|
+
* Progress is a hint for the UI — "using Bash", the first line of what the
|
|
45
|
+
* agent is drafting — not a transcript. Bounded here so a brain that emits one
|
|
46
|
+
* enormous block of text can't turn a keep-alive into a payload.
|
|
47
|
+
*/
|
|
48
|
+
export declare const MAX_PROGRESS_CHARS = 200;
|
|
31
49
|
/** `POST` — exchange a one-time pairing code for a scoped credential. */
|
|
32
50
|
export declare const REDEEM_PATH = "/v1/agent-pairing/redeem";
|
|
33
51
|
/** `POST` — send a message into a conversation as the paired agent. */
|
|
34
52
|
export declare function conversationMessagesPath(conversationId: string): string;
|
|
35
53
|
/** `POST` — close out a run. Must be called for every run, success or not. */
|
|
36
54
|
export declare function agentRunCompletePath(runId: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* `POST` — say a run is still alive, and hear back whether it was cancelled.
|
|
57
|
+
*
|
|
58
|
+
* The server holds a lease on every running row and reaps the ones that stop
|
|
59
|
+
* being heard from; a run that legitimately takes four hours has to say so on a
|
|
60
|
+
* cadence or it is reaped mid-work. The response also carries
|
|
61
|
+
* `cancel_requested`, which is the only way a laptop behind NAT learns that
|
|
62
|
+
* somebody pressed stop.
|
|
63
|
+
*/
|
|
64
|
+
export declare function agentRunHeartbeatPath(runId: string): string;
|
|
65
|
+
/**
|
|
66
|
+
* Where this run's heartbeats go.
|
|
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.
|
|
74
|
+
*/
|
|
75
|
+
export declare function heartbeatPathFor(run: AgentRunRequest): string;
|
|
37
76
|
/**
|
|
38
77
|
* A file or image hanging off a message.
|
|
39
78
|
*
|
package/dist/agent/contract.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* Server-side sources of truth:
|
|
14
14
|
* - payload shape — `shared/events/payloads/agent_runs.py`
|
|
15
15
|
* - `POST /v1/agent-runs/{id}/complete` — `api_public/routers/agent_runs.py`
|
|
16
|
+
* - `POST /v1/agent-runs/{id}/heartbeat` — `api_public/routers/agent_runs.py`
|
|
16
17
|
* - `POST /v1/agent-pairing/redeem` — `api_public/routers/agent_pairing.py`
|
|
17
18
|
* - `POST /v1/conversations/{id}/messages` — `api_public/routers/conversations.py`
|
|
18
19
|
*/
|
|
@@ -28,6 +29,23 @@ export const RUN_STATUS_SUCCEEDED = 'succeeded';
|
|
|
28
29
|
export const RUN_STATUS_FAILED = 'failed';
|
|
29
30
|
/** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
|
|
30
31
|
export const MAX_ERROR_CHARS = 1000;
|
|
32
|
+
/**
|
|
33
|
+
* The completion `error` that means "a person asked for this run to stop".
|
|
34
|
+
*
|
|
35
|
+
* A magic string on purpose, and it must be sent **exactly**: the server
|
|
36
|
+
* matches on it to word the notice it drops in the thread ("cancelled" rather
|
|
37
|
+
* than "failed"). Prefixing it with a reason, or sending
|
|
38
|
+
* `cancelled_by_user: …`, makes a deliberate stop read as a crash.
|
|
39
|
+
*/
|
|
40
|
+
export const RUN_ERROR_CANCELLED_BY_USER = 'cancelled_by_user';
|
|
41
|
+
/**
|
|
42
|
+
* Cap on the `progress` string sent with a heartbeat.
|
|
43
|
+
*
|
|
44
|
+
* Progress is a hint for the UI — "using Bash", the first line of what the
|
|
45
|
+
* agent is drafting — not a transcript. Bounded here so a brain that emits one
|
|
46
|
+
* enormous block of text can't turn a keep-alive into a payload.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_PROGRESS_CHARS = 200;
|
|
31
49
|
/** `POST` — exchange a one-time pairing code for a scoped credential. */
|
|
32
50
|
export const REDEEM_PATH = '/v1/agent-pairing/redeem';
|
|
33
51
|
/** `POST` — send a message into a conversation as the paired agent. */
|
|
@@ -38,6 +56,35 @@ export function conversationMessagesPath(conversationId) {
|
|
|
38
56
|
export function agentRunCompletePath(runId) {
|
|
39
57
|
return `/v1/agent-runs/${encodeURIComponent(runId)}/complete`;
|
|
40
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* `POST` — say a run is still alive, and hear back whether it was cancelled.
|
|
61
|
+
*
|
|
62
|
+
* The server holds a lease on every running row and reaps the ones that stop
|
|
63
|
+
* being heard from; a run that legitimately takes four hours has to say so on a
|
|
64
|
+
* cadence or it is reaped mid-work. The response also carries
|
|
65
|
+
* `cancel_requested`, which is the only way a laptop behind NAT learns that
|
|
66
|
+
* somebody pressed stop.
|
|
67
|
+
*/
|
|
68
|
+
export function agentRunHeartbeatPath(runId) {
|
|
69
|
+
return `/v1/agent-runs/${encodeURIComponent(runId)}/heartbeat`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Where this run's heartbeats go.
|
|
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.
|
|
80
|
+
*/
|
|
81
|
+
export function heartbeatPathFor(run) {
|
|
82
|
+
const completion = run.completion_path;
|
|
83
|
+
const suffix = '/complete';
|
|
84
|
+
return completion.endsWith(suffix)
|
|
85
|
+
? `${completion.slice(0, -suffix.length)}/heartbeat`
|
|
86
|
+
: agentRunHeartbeatPath(run.run_id);
|
|
87
|
+
}
|
|
41
88
|
/**
|
|
42
89
|
* Ceiling on how many context entries are kept from one payload.
|
|
43
90
|
*
|
package/dist/agent/exec.d.ts
CHANGED
|
@@ -17,12 +17,36 @@ export interface ExecOptions {
|
|
|
17
17
|
command: string;
|
|
18
18
|
/** Prompt written to the child's stdin. */
|
|
19
19
|
prompt: string;
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Hard cap on wall-clock runtime.
|
|
22
|
+
*
|
|
23
|
+
* ``0`` disables it: the child then runs until it exits on its own, until
|
|
24
|
+
* {@link cancelSignal} fires, or until the server gives up on the run. That
|
|
25
|
+
* is a real configuration (`--timeout 0`), not a degenerate one — some work
|
|
26
|
+
* genuinely takes hours and a local clock is the wrong thing to bound it by.
|
|
27
|
+
*/
|
|
21
28
|
timeoutMs: number;
|
|
22
29
|
/** Working directory for the child. Defaults to the CLI's cwd. */
|
|
23
30
|
cwd?: string;
|
|
24
31
|
/** Extra environment for the child, merged over ``process.env``. */
|
|
25
32
|
env?: NodeJS.ProcessEnv;
|
|
33
|
+
/**
|
|
34
|
+
* Ends the run early — a person pressed stop, or the server closed the run.
|
|
35
|
+
*
|
|
36
|
+
* Terminated through exactly the same SIGTERM → grace → SIGKILL path a
|
|
37
|
+
* timeout uses, because the problem is identical: the thing being stopped is
|
|
38
|
+
* a process *group* that may be ignoring polite signals.
|
|
39
|
+
*/
|
|
40
|
+
cancelSignal?: AbortSignal;
|
|
41
|
+
/**
|
|
42
|
+
* Each stdout chunk, as it arrives.
|
|
43
|
+
*
|
|
44
|
+
* The captured ``stdout`` is still returned in full; this exists for the
|
|
45
|
+
* things that cannot wait for the process to exit — reading a session id out
|
|
46
|
+
* of the first event, tracking what the agent is doing for the heartbeat, and
|
|
47
|
+
* having *something* to salvage from a run that gets killed.
|
|
48
|
+
*/
|
|
49
|
+
onStdout?: (chunk: string) => void;
|
|
26
50
|
/** Injectable spawn — tests substitute a fake process. */
|
|
27
51
|
spawnImpl?: typeof spawn;
|
|
28
52
|
}
|
|
@@ -32,17 +56,31 @@ export interface ExecResult {
|
|
|
32
56
|
stderr: string;
|
|
33
57
|
exitCode: number | null;
|
|
34
58
|
timedOut: boolean;
|
|
59
|
+
/** The run was stopped through {@link ExecOptions.cancelSignal}. */
|
|
60
|
+
cancelled: boolean;
|
|
35
61
|
/** Populated when the process could not be started at all. */
|
|
36
62
|
error?: string;
|
|
37
63
|
durationMs: number;
|
|
38
64
|
}
|
|
39
65
|
/** Cap on captured output (in characters) so a runaway agent can't exhaust memory. */
|
|
40
66
|
export declare const MAX_OUTPUT_CHARS: number;
|
|
67
|
+
/** ``error`` on a result the caller stopped deliberately. */
|
|
68
|
+
export declare const CANCELLED_ERROR = "cancelled";
|
|
69
|
+
/**
|
|
70
|
+
* What a timed-out run reports.
|
|
71
|
+
*
|
|
72
|
+
* Ends with the way out, because this string does not stay in `agent.log`: the
|
|
73
|
+
* server echoes a failed run's error into the thread, so the person who was
|
|
74
|
+
* waiting reads it. Told "timed out after 7200000ms" they learn only that
|
|
75
|
+
* something broke; told how to raise the ceiling they can fix it themselves.
|
|
76
|
+
* Kept to one line for the same reason.
|
|
77
|
+
*/
|
|
78
|
+
export declare function timeoutMessage(timeoutMs: number): string;
|
|
41
79
|
/**
|
|
42
80
|
* Execute the agent command with ``prompt`` on stdin and return its stdout.
|
|
43
81
|
*
|
|
44
|
-
* Never throws: a spawn failure, a non-zero exit,
|
|
45
|
-
* as ``ok: false`` with the detail filled in, because the caller
|
|
46
|
-
* *some* outcome to the server for every run.
|
|
82
|
+
* Never throws: a spawn failure, a non-zero exit, a timeout, and a cancellation
|
|
83
|
+
* all come back as ``ok: false`` with the detail filled in, because the caller
|
|
84
|
+
* must report *some* outcome to the server for every run.
|
|
47
85
|
*/
|
|
48
86
|
export declare function execAgent(options: ExecOptions): Promise<ExecResult>;
|
package/dist/agent/exec.js
CHANGED
|
@@ -11,11 +11,25 @@
|
|
|
11
11
|
* comes from the host's own flag — but event-derived text never reaches the
|
|
12
12
|
* shell, so a crafted mention cannot inject a command.
|
|
13
13
|
*/
|
|
14
|
-
import { spawn } from 'node:child_process';
|
|
14
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
15
15
|
/** Cap on captured output (in characters) so a runaway agent can't exhaust memory. */
|
|
16
16
|
export const MAX_OUTPUT_CHARS = 512 * 1024;
|
|
17
|
-
/** Grace period between SIGTERM and SIGKILL when a run
|
|
17
|
+
/** Grace period between SIGTERM and SIGKILL when a run is being stopped. */
|
|
18
18
|
const KILL_GRACE_MS = 5_000;
|
|
19
|
+
/** ``error`` on a result the caller stopped deliberately. */
|
|
20
|
+
export const CANCELLED_ERROR = 'cancelled';
|
|
21
|
+
/**
|
|
22
|
+
* What a timed-out run reports.
|
|
23
|
+
*
|
|
24
|
+
* Ends with the way out, because this string does not stay in `agent.log`: the
|
|
25
|
+
* server echoes a failed run's error into the thread, so the person who was
|
|
26
|
+
* waiting reads it. Told "timed out after 7200000ms" they learn only that
|
|
27
|
+
* something broke; told how to raise the ceiling they can fix it themselves.
|
|
28
|
+
* Kept to one line for the same reason.
|
|
29
|
+
*/
|
|
30
|
+
export function timeoutMessage(timeoutMs) {
|
|
31
|
+
return `timed out after ${timeoutMs}ms — raise the ceiling with --timeout <seconds> (0 = no limit)`;
|
|
32
|
+
}
|
|
19
33
|
function clamp(text) {
|
|
20
34
|
if (text.length <= MAX_OUTPUT_CHARS)
|
|
21
35
|
return text;
|
|
@@ -24,9 +38,9 @@ function clamp(text) {
|
|
|
24
38
|
/**
|
|
25
39
|
* Execute the agent command with ``prompt`` on stdin and return its stdout.
|
|
26
40
|
*
|
|
27
|
-
* Never throws: a spawn failure, a non-zero exit,
|
|
28
|
-
* as ``ok: false`` with the detail filled in, because the caller
|
|
29
|
-
* *some* outcome to the server for every run.
|
|
41
|
+
* Never throws: a spawn failure, a non-zero exit, a timeout, and a cancellation
|
|
42
|
+
* all come back as ``ok: false`` with the detail filled in, because the caller
|
|
43
|
+
* must report *some* outcome to the server for every run.
|
|
30
44
|
*/
|
|
31
45
|
export async function execAgent(options) {
|
|
32
46
|
const spawnImpl = options.spawnImpl ?? spawn;
|
|
@@ -35,7 +49,23 @@ export async function execAgent(options) {
|
|
|
35
49
|
let stdout = '';
|
|
36
50
|
let stderr = '';
|
|
37
51
|
let timedOut = false;
|
|
52
|
+
let cancelled = false;
|
|
38
53
|
let settled = false;
|
|
54
|
+
// Nothing to stop if the stop already happened: a run cancelled while it
|
|
55
|
+
// sat in the queue must not spawn the agent at all.
|
|
56
|
+
if (options.cancelSignal?.aborted) {
|
|
57
|
+
resolve({
|
|
58
|
+
ok: false,
|
|
59
|
+
stdout: '',
|
|
60
|
+
stderr: '',
|
|
61
|
+
exitCode: null,
|
|
62
|
+
timedOut: false,
|
|
63
|
+
cancelled: true,
|
|
64
|
+
error: CANCELLED_ERROR,
|
|
65
|
+
durationMs: Date.now() - started,
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
39
69
|
const spawnOptions = {
|
|
40
70
|
shell: true,
|
|
41
71
|
cwd: options.cwd,
|
|
@@ -58,27 +88,52 @@ export async function execAgent(options) {
|
|
|
58
88
|
stderr: '',
|
|
59
89
|
exitCode: null,
|
|
60
90
|
timedOut: false,
|
|
91
|
+
cancelled: false,
|
|
61
92
|
error: err instanceof Error ? err.message : String(err),
|
|
62
93
|
durationMs: Date.now() - started,
|
|
63
94
|
});
|
|
64
95
|
return;
|
|
65
96
|
}
|
|
97
|
+
let timer;
|
|
66
98
|
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;
|
|
67
101
|
const finish = (result) => {
|
|
68
102
|
if (settled)
|
|
69
103
|
return;
|
|
70
104
|
settled = true;
|
|
71
|
-
|
|
105
|
+
if (timer !== undefined)
|
|
106
|
+
clearTimeout(timer);
|
|
72
107
|
if (killTimer !== undefined)
|
|
73
108
|
clearTimeout(killTimer);
|
|
109
|
+
options.cancelSignal?.removeEventListener('abort', onAbort);
|
|
74
110
|
resolve({ ...result, durationMs: Date.now() - started });
|
|
75
111
|
};
|
|
76
112
|
/**
|
|
77
|
-
* Signal the child's whole process
|
|
78
|
-
*
|
|
113
|
+
* Signal the child's whole process tree.
|
|
114
|
+
*
|
|
115
|
+
* POSIX: the child has its own process group (see `detached` above), so one
|
|
116
|
+
* signal reaches the shell and everything it started.
|
|
117
|
+
*
|
|
118
|
+
* Windows has no process groups to signal, and `shell: true` means the
|
|
119
|
+
* child is `cmd.exe` — killing it alone leaves the agent it launched
|
|
120
|
+
* running, which is a run reported as stopped that carries on acting with
|
|
121
|
+
* the operator's credentials. `taskkill /T` walks the tree instead, with
|
|
122
|
+
* `/F` as the SIGKILL half of the escalation. It is best-effort: if
|
|
123
|
+
* `taskkill` is missing or refuses, the fallback below still kills the
|
|
124
|
+
* shell, which is exactly what this did before.
|
|
79
125
|
*/
|
|
80
126
|
const signalTree = (signal) => {
|
|
81
127
|
const pid = child.pid;
|
|
128
|
+
if (pid !== undefined && process.platform === 'win32') {
|
|
129
|
+
try {
|
|
130
|
+
const args = ['/pid', String(pid), '/T', ...(signal === 'SIGKILL' ? ['/F'] : [])];
|
|
131
|
+
spawnSync('taskkill', args, { windowsHide: true });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Fall through — the child itself is still signalled below.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
82
137
|
try {
|
|
83
138
|
if (pid !== undefined && spawnOptions.detached)
|
|
84
139
|
process.kill(-pid, signal);
|
|
@@ -89,11 +144,18 @@ export async function execAgent(options) {
|
|
|
89
144
|
// ESRCH — the group is already gone, which is the outcome we wanted.
|
|
90
145
|
}
|
|
91
146
|
};
|
|
92
|
-
|
|
93
|
-
|
|
147
|
+
/**
|
|
148
|
+
* End the run: ask politely, then insist.
|
|
149
|
+
*
|
|
150
|
+
* The single path for both reasons a run is stopped from outside. They need
|
|
151
|
+
* identical handling — an agent that ignores SIGTERM ignores it whoever
|
|
152
|
+
* sent it — and having one caller escalate and the other not is how a
|
|
153
|
+
* cancelled run wedges the queue behind a child that never died.
|
|
154
|
+
*/
|
|
155
|
+
const terminate = () => {
|
|
156
|
+
if (settled || killTimer !== undefined)
|
|
157
|
+
return;
|
|
94
158
|
signalTree('SIGTERM');
|
|
95
|
-
// Escalate if the agent ignores SIGTERM, so a wedged child can't hold
|
|
96
|
-
// the harness open past its timeout.
|
|
97
159
|
killTimer = setTimeout(() => {
|
|
98
160
|
signalTree('SIGKILL');
|
|
99
161
|
// A descendant can still hold the pipes open after the group is
|
|
@@ -104,14 +166,36 @@ export async function execAgent(options) {
|
|
|
104
166
|
stdout: clamp(stdout),
|
|
105
167
|
stderr: clamp(stderr),
|
|
106
168
|
exitCode: null,
|
|
107
|
-
timedOut
|
|
108
|
-
|
|
169
|
+
timedOut,
|
|
170
|
+
cancelled,
|
|
171
|
+
error: stopReason(),
|
|
109
172
|
});
|
|
110
173
|
}, KILL_GRACE_MS);
|
|
111
|
-
}
|
|
174
|
+
};
|
|
175
|
+
function onAbort() {
|
|
176
|
+
cancelled = true;
|
|
177
|
+
terminate();
|
|
178
|
+
}
|
|
179
|
+
if (options.timeoutMs > 0) {
|
|
180
|
+
timer = setTimeout(() => {
|
|
181
|
+
timedOut = true;
|
|
182
|
+
terminate();
|
|
183
|
+
}, options.timeoutMs);
|
|
184
|
+
}
|
|
185
|
+
options.cancelSignal?.addEventListener('abort', onAbort, { once: true });
|
|
112
186
|
child.stdout?.on('data', (chunk) => {
|
|
187
|
+
const text = chunk.toString();
|
|
188
|
+
// Capture is bounded; the callback is not. A parser that stops being fed
|
|
189
|
+
// at 512 KB would report stale progress and salvage the wrong text for
|
|
190
|
+
// the rest of a long run — and the callback keeps nothing per chunk.
|
|
113
191
|
if (stdout.length < MAX_OUTPUT_CHARS)
|
|
114
|
-
stdout +=
|
|
192
|
+
stdout += text;
|
|
193
|
+
try {
|
|
194
|
+
options.onStdout?.(text);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// A broken observer must not cost the run its output.
|
|
198
|
+
}
|
|
115
199
|
});
|
|
116
200
|
child.stderr?.on('data', (chunk) => {
|
|
117
201
|
if (stderr.length < MAX_OUTPUT_CHARS)
|
|
@@ -124,17 +208,19 @@ export async function execAgent(options) {
|
|
|
124
208
|
stderr: clamp(stderr),
|
|
125
209
|
exitCode: null,
|
|
126
210
|
timedOut,
|
|
211
|
+
cancelled,
|
|
127
212
|
error: err.message,
|
|
128
213
|
});
|
|
129
214
|
});
|
|
130
215
|
child.on('close', (code) => {
|
|
131
216
|
finish({
|
|
132
|
-
ok: !timedOut && code === 0,
|
|
217
|
+
ok: !timedOut && !cancelled && code === 0,
|
|
133
218
|
stdout: clamp(stdout),
|
|
134
219
|
stderr: clamp(stderr),
|
|
135
220
|
exitCode: code,
|
|
136
221
|
timedOut,
|
|
137
|
-
|
|
222
|
+
cancelled,
|
|
223
|
+
error: stopReason(),
|
|
138
224
|
});
|
|
139
225
|
});
|
|
140
226
|
// stdin may already be closed if the command failed to start.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator's opt-in extra MCP servers (`$PROHOST_HOME/extra-mcp.json`).
|
|
3
|
+
*
|
|
4
|
+
* Both brains this harness shapes get exactly one MCP server by default — ours
|
|
5
|
+
* — and that isolation is a security boundary rather than an implementation
|
|
6
|
+
* detail. A paired run executes with permission prompts off, on prompt text
|
|
7
|
+
* written by anyone who can mention the agent in the workspace, so every server
|
|
8
|
+
* reachable from that run is a server a workspace teammate can drive. Claude
|
|
9
|
+
* Code is held to it by `--strict-mcp-config`; Codex is held to it by
|
|
10
|
+
* explicitly disabling the servers its own config defines.
|
|
11
|
+
*
|
|
12
|
+
* That leaves no way at all to give the agent a tool the operator *does* want
|
|
13
|
+
* it to have, which is why this file exists. It is **never created by the
|
|
14
|
+
* CLI** — its presence is the operator's deliberate act, taken after reading
|
|
15
|
+
* what it costs. Nothing here weakens the boundary; it moves the decision to a
|
|
16
|
+
* file whose only purpose is to record it.
|
|
17
|
+
*
|
|
18
|
+
* Everything in this module fails soft, in the sense `claude.ts` states: a
|
|
19
|
+
* malformed file is a warning and a run without extras, never a run that
|
|
20
|
+
* doesn't happen. The operator's agent going quiet is a worse outcome than
|
|
21
|
+
* their extra server being missing, and the missing server is the one they can
|
|
22
|
+
* actually see in the log.
|
|
23
|
+
*/
|
|
24
|
+
/** Filename of the operator-authored extras file, inside `$PROHOST_HOME`. */
|
|
25
|
+
export declare const EXTRA_MCP_FILENAME = "extra-mcp.json";
|
|
26
|
+
/** One server definition, passed through to the brain untouched. */
|
|
27
|
+
export type ExtraServer = Record<string, unknown>;
|
|
28
|
+
export interface ExtraMcpServers {
|
|
29
|
+
/** Accepted definitions, keyed by server name. Empty when there are none. */
|
|
30
|
+
servers: Record<string, ExtraServer>;
|
|
31
|
+
/**
|
|
32
|
+
* Lines for the operator to see when the current grant set is loaded.
|
|
33
|
+
*
|
|
34
|
+
* Both the good news and the bad travel this way: which extras loaded is as
|
|
35
|
+
* worth printing as which were rejected, because "I added a server and the
|
|
36
|
+
* agent still can't use it" is otherwise a silent, unfalsifiable complaint.
|
|
37
|
+
*/
|
|
38
|
+
warnings: string[];
|
|
39
|
+
}
|
|
40
|
+
/** Absolute path of the extras file. */
|
|
41
|
+
export declare function extraMcpPath(env?: NodeJS.ProcessEnv): string;
|
|
42
|
+
export interface LoadExtraOptions {
|
|
43
|
+
env?: NodeJS.ProcessEnv;
|
|
44
|
+
/** Injectable read, so a test can exercise the parse without a filesystem. */
|
|
45
|
+
readImpl?: (file: string) => string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Read and validate `extra-mcp.json`.
|
|
49
|
+
*
|
|
50
|
+
* The file's shape is deliberately the same object Claude Code's
|
|
51
|
+
* `--mcp-config` takes — `{"mcpServers": {"<name>": {...}}}` — so an operator
|
|
52
|
+
* can lift a block straight out of a `.mcp.json` they already have rather than
|
|
53
|
+
* learn a format that exists only here.
|
|
54
|
+
*
|
|
55
|
+
* Four rejections, each returning a warning rather than an exception:
|
|
56
|
+
*
|
|
57
|
+
* * **No file.** Not a warning at all — the overwhelmingly common case, and
|
|
58
|
+
* the CLI never creates one, so its absence is the default state rather
|
|
59
|
+
* than something the operator did wrong.
|
|
60
|
+
* * **Unparseable JSON.** A warning and zero extras. The alternative — refuse
|
|
61
|
+
* to start — would take a working agent offline over a trailing comma in an
|
|
62
|
+
* optional enhancement.
|
|
63
|
+
* * **A server named `prohost`.** Rejected loudly and never merged. The name
|
|
64
|
+
* is how the prompt, the `--allowedTools` pattern and both generated
|
|
65
|
+
* configs refer to the ProhostAI server; letting a file on disk take it
|
|
66
|
+
* over would silently redirect a run's workspace lookups — with the agent
|
|
67
|
+
* still reporting, in good faith, that it looked them up.
|
|
68
|
+
* * **A definition that is not an object.** Rejected per entry, because a
|
|
69
|
+
* `"github": "https://…"` typo would otherwise reach the brain as a config
|
|
70
|
+
* it cannot parse and take the whole config down with it — including our
|
|
71
|
+
* server.
|
|
72
|
+
*/
|
|
73
|
+
export declare function loadExtraMcpServers(options?: LoadExtraOptions): ExtraMcpServers;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator's opt-in extra MCP servers (`$PROHOST_HOME/extra-mcp.json`).
|
|
3
|
+
*
|
|
4
|
+
* Both brains this harness shapes get exactly one MCP server by default — ours
|
|
5
|
+
* — and that isolation is a security boundary rather than an implementation
|
|
6
|
+
* detail. A paired run executes with permission prompts off, on prompt text
|
|
7
|
+
* written by anyone who can mention the agent in the workspace, so every server
|
|
8
|
+
* reachable from that run is a server a workspace teammate can drive. Claude
|
|
9
|
+
* Code is held to it by `--strict-mcp-config`; Codex is held to it by
|
|
10
|
+
* explicitly disabling the servers its own config defines.
|
|
11
|
+
*
|
|
12
|
+
* That leaves no way at all to give the agent a tool the operator *does* want
|
|
13
|
+
* it to have, which is why this file exists. It is **never created by the
|
|
14
|
+
* CLI** — its presence is the operator's deliberate act, taken after reading
|
|
15
|
+
* what it costs. Nothing here weakens the boundary; it moves the decision to a
|
|
16
|
+
* file whose only purpose is to record it.
|
|
17
|
+
*
|
|
18
|
+
* Everything in this module fails soft, in the sense `claude.ts` states: a
|
|
19
|
+
* malformed file is a warning and a run without extras, never a run that
|
|
20
|
+
* doesn't happen. The operator's agent going quiet is a worse outcome than
|
|
21
|
+
* their extra server being missing, and the missing server is the one they can
|
|
22
|
+
* actually see in the log.
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { prohostHome } from './credentials.js';
|
|
27
|
+
import { MCP_SERVER_NAME } from './mcp.js';
|
|
28
|
+
/** Filename of the operator-authored extras file, inside `$PROHOST_HOME`. */
|
|
29
|
+
export const EXTRA_MCP_FILENAME = 'extra-mcp.json';
|
|
30
|
+
/** Absolute path of the extras file. */
|
|
31
|
+
export function extraMcpPath(env = process.env) {
|
|
32
|
+
return path.join(prohostHome(env), EXTRA_MCP_FILENAME);
|
|
33
|
+
}
|
|
34
|
+
function isPlainObject(value) {
|
|
35
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Read and validate `extra-mcp.json`.
|
|
39
|
+
*
|
|
40
|
+
* The file's shape is deliberately the same object Claude Code's
|
|
41
|
+
* `--mcp-config` takes — `{"mcpServers": {"<name>": {...}}}` — so an operator
|
|
42
|
+
* can lift a block straight out of a `.mcp.json` they already have rather than
|
|
43
|
+
* learn a format that exists only here.
|
|
44
|
+
*
|
|
45
|
+
* Four rejections, each returning a warning rather than an exception:
|
|
46
|
+
*
|
|
47
|
+
* * **No file.** Not a warning at all — the overwhelmingly common case, and
|
|
48
|
+
* the CLI never creates one, so its absence is the default state rather
|
|
49
|
+
* than something the operator did wrong.
|
|
50
|
+
* * **Unparseable JSON.** A warning and zero extras. The alternative — refuse
|
|
51
|
+
* to start — would take a working agent offline over a trailing comma in an
|
|
52
|
+
* optional enhancement.
|
|
53
|
+
* * **A server named `prohost`.** Rejected loudly and never merged. The name
|
|
54
|
+
* is how the prompt, the `--allowedTools` pattern and both generated
|
|
55
|
+
* configs refer to the ProhostAI server; letting a file on disk take it
|
|
56
|
+
* over would silently redirect a run's workspace lookups — with the agent
|
|
57
|
+
* still reporting, in good faith, that it looked them up.
|
|
58
|
+
* * **A definition that is not an object.** Rejected per entry, because a
|
|
59
|
+
* `"github": "https://…"` typo would otherwise reach the brain as a config
|
|
60
|
+
* it cannot parse and take the whole config down with it — including our
|
|
61
|
+
* server.
|
|
62
|
+
*/
|
|
63
|
+
export function loadExtraMcpServers(options = {}) {
|
|
64
|
+
const file = extraMcpPath(options.env ?? process.env);
|
|
65
|
+
const read = options.readImpl ?? ((target) => readFileSync(target, 'utf8'));
|
|
66
|
+
let raw;
|
|
67
|
+
try {
|
|
68
|
+
raw = read(file);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
// ENOENT is the normal state and must stay silent. Anything else — a
|
|
72
|
+
// permission problem, a directory where the file should be — the operator
|
|
73
|
+
// wants to hear about, because they put the file there on purpose.
|
|
74
|
+
const code = err.code;
|
|
75
|
+
if (code === 'ENOENT')
|
|
76
|
+
return { servers: {}, warnings: [] };
|
|
77
|
+
return {
|
|
78
|
+
servers: {},
|
|
79
|
+
warnings: [`could not read ${file} (${err instanceof Error ? err.message : String(err)}); no extra MCP servers`],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return {
|
|
88
|
+
servers: {},
|
|
89
|
+
warnings: [`${file} is not valid JSON (${err instanceof Error ? err.message : String(err)}); no extra MCP servers`],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (!isPlainObject(parsed) || !isPlainObject(parsed.mcpServers)) {
|
|
93
|
+
return {
|
|
94
|
+
servers: {},
|
|
95
|
+
warnings: [`${file} has no "mcpServers" object; no extra MCP servers`],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const servers = {};
|
|
99
|
+
const warnings = [];
|
|
100
|
+
for (const [name, definition] of Object.entries(parsed.mcpServers)) {
|
|
101
|
+
if (name === MCP_SERVER_NAME) {
|
|
102
|
+
warnings.push(`${file} defines a server named "${MCP_SERVER_NAME}" — that name is reserved for the ` +
|
|
103
|
+
'ProhostAI server and the entry was ignored. Rename it to use it.');
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!isPlainObject(definition)) {
|
|
107
|
+
warnings.push(`${file}: server "${name}" is not an object; the entry was ignored`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
servers[name] = definition;
|
|
111
|
+
}
|
|
112
|
+
const names = Object.keys(servers);
|
|
113
|
+
if (names.length > 0) {
|
|
114
|
+
warnings.push(`extra MCP servers loaded from ${EXTRA_MCP_FILENAME}: ${names.join(', ')} — ` +
|
|
115
|
+
'anyone who can trigger this agent can use them, with permission prompts off.');
|
|
116
|
+
}
|
|
117
|
+
return { servers, warnings };
|
|
118
|
+
}
|