@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.
@@ -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 <= 0) {
45
- process.stderr.write('--timeout must be a positive number of seconds.\n');
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;
@@ -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
  *
@@ -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
  *
@@ -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
- /** Hard cap on wall-clock runtime. */
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, and a timeout all come back
45
- * as ``ok: false`` with the detail filled in, because the caller must report
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>;
@@ -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 overruns its timeout. */
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, and a timeout all come back
28
- * as ``ok: false`` with the detail filled in, because the caller must report
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
- clearTimeout(timer);
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 group, falling back to the child alone
78
- * when there is no group (Windows, or a spawn that never got a pid).
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
- const timer = setTimeout(() => {
93
- timedOut = true;
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: true,
108
- error: `timed out after ${options.timeoutMs}ms`,
169
+ timedOut,
170
+ cancelled,
171
+ error: stopReason(),
109
172
  });
110
173
  }, KILL_GRACE_MS);
111
- }, options.timeoutMs);
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 += chunk.toString();
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
- error: timedOut ? `timed out after ${options.timeoutMs}ms` : undefined,
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
+ }