@prohost/cli 0.2.1 → 0.2.2

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.
@@ -0,0 +1,369 @@
1
+ /**
2
+ * The one place that knows anything about Codex CLI specifically.
3
+ *
4
+ * The sibling of `claude.ts`, and it exists for the same reason: `exec.ts` is
5
+ * deliberately agent-agnostic — prompt on stdin, reply on stdout — and that
6
+ * lowest common denominator costs a Codex run the two things it can actually
7
+ * have. A run has no memory of the previous one, and it has no tools for
8
+ * looking anything up in the workspace it is answering about. Until this
9
+ * module existed, a `codex` brain ran bare while a `claude` brain got both, so
10
+ * the same mention answered by two hosts produced visibly different work.
11
+ *
12
+ * Everything here was verified against a real `codex-cli 0.144.1` rather than
13
+ * taken from documentation; the surprises are called out where they are relied
14
+ * on. As in `claude.ts`, nothing in this module is load-bearing for
15
+ * correctness: if detection misses, if the session is unresumable, if the JSON
16
+ * is not what we expect — the run still happens and still completes.
17
+ */
18
+ import { MCP_SERVER_NAME } from './mcp.js';
19
+ import { SHELL_CONTROL_OPERATORS, shellQuote } from './shell.js';
20
+ /**
21
+ * Environment variable the API key is handed to Codex in.
22
+ *
23
+ * Codex resolves MCP headers from the environment at request time
24
+ * (`env_http_headers`), which is why the key travels this way rather than in
25
+ * the config override. The override is part of the command line, and a command
26
+ * line is world-readable out of `ps`; a child's environment is not. This is the
27
+ * one place the Codex path is meaningfully *safer* than the Claude one, which
28
+ * has to keep the key in a `0600` file on disk.
29
+ */
30
+ export const MCP_API_KEY_ENV_VAR = 'PROHOST_MCP_API_KEY';
31
+ /**
32
+ * Is this exec command Codex CLI in non-interactive mode?
33
+ *
34
+ * A **heuristic**, and deliberately as narrow as {@link isClaudeExec}: it
35
+ * matches when the first word of the command is `codex` (or a path ending in
36
+ * `/codex`) *and* the command uses the `exec` subcommand (or its `e` alias).
37
+ * Both halves matter. Interactive `codex` has no `--json` and no `resume`
38
+ * subcommand, so shaping it would break a command that works today.
39
+ *
40
+ * Three things this refuses to touch, each because appending to it would do
41
+ * harm rather than nothing:
42
+ *
43
+ * * Commands with shell control operators. Flags are appended to the *end* of
44
+ * the string, so for `codex exec | jq .` they would land on `jq`.
45
+ * * `codex exec resume …` and `codex exec review …`. The host already chose a
46
+ * sub-subcommand; appending our own `resume` would produce two of them.
47
+ * * Anything after a `--` sentinel, which is the host saying "stop parsing".
48
+ * * A command carrying its own prompt, e.g. `codex exec 'Be terse'`. Codex
49
+ * takes exactly one prompt positional, so both the `-` this harness appends
50
+ * and a `resume <id>` would be a second one — `error: unexpected argument
51
+ * '-' found`, on a command that works today. Such a host keeps the plain
52
+ * contract; the place to put standing instructions is now the agent's
53
+ * ProhostAI profile, which arrives in the prompt itself.
54
+ */
55
+ export function isCodexExec(command) {
56
+ if (SHELL_CONTROL_OPERATORS.test(command))
57
+ return false;
58
+ const words = command.trim().split(/\s+/);
59
+ const first = words[0] ?? '';
60
+ const base = first.split('/').pop() ?? '';
61
+ if (base !== 'codex' && base !== 'codex.cmd' && base !== 'codex.exe')
62
+ return false;
63
+ if (words.includes('--'))
64
+ return false;
65
+ const rest = words.slice(1);
66
+ if (!rest.includes('exec') && !rest.includes('e'))
67
+ return false;
68
+ // A host who already picked `resume` or `review` gets left alone entirely.
69
+ if (rest.includes('resume') || rest.includes('review'))
70
+ return false;
71
+ return !hasExplicitPrompt(words);
72
+ }
73
+ /**
74
+ * Does the command already carry a prompt of the host's own?
75
+ *
76
+ * Anything after the `exec` token that is not a flag, not a flag's value, and
77
+ * not the bare `-` stdin marker can only be Codex's one `[PROMPT]` positional.
78
+ */
79
+ function hasExplicitPrompt(words) {
80
+ const execIndex = words.findIndex((word, index) => index > 0 && (word === 'exec' || word === 'e'));
81
+ if (execIndex === -1)
82
+ return false;
83
+ for (let i = execIndex + 1; i < words.length; i += 1) {
84
+ const word = words[i] ?? '';
85
+ if (word === '-' || word.startsWith('-'))
86
+ continue;
87
+ if (VALUE_TAKING_FLAGS.has(words[i - 1] ?? ''))
88
+ continue;
89
+ return true;
90
+ }
91
+ return false;
92
+ }
93
+ /**
94
+ * Flags that take a following value, for the bare-`-` scan below.
95
+ *
96
+ * Not a complete model of Codex's command line — just enough to tell the prompt
97
+ * positional `-` from a `-` that is somebody's argument.
98
+ */
99
+ const VALUE_TAKING_FLAGS = new Set([
100
+ '-c',
101
+ '--config',
102
+ '-m',
103
+ '--model',
104
+ '-i',
105
+ '--image',
106
+ '-o',
107
+ '--output-last-message',
108
+ '-s',
109
+ '--sandbox',
110
+ '-C',
111
+ '--cd',
112
+ '-p',
113
+ '--profile',
114
+ '--add-dir',
115
+ '--output-schema',
116
+ '--color',
117
+ '--local-provider',
118
+ '--enable',
119
+ '--disable',
120
+ ]);
121
+ /**
122
+ * Drop the prompt positional from the host's command.
123
+ *
124
+ * The README has recommended `codex exec -` since 0.2.0, and `codex exec -` is
125
+ * the form most people write anyway. We re-add the `-` ourselves at the very
126
+ * end, after any `resume <id>` — leaving the host's in place would make it the
127
+ * `SESSION_ID` positional and push `resume` into being a second one, which
128
+ * Codex rejects outright.
129
+ *
130
+ * A `-` that follows a value-taking flag is that flag's argument, not the
131
+ * prompt, so it stays.
132
+ */
133
+ function withoutPromptPositional(words) {
134
+ return words.filter((word, index) => word !== '-' || VALUE_TAKING_FLAGS.has(words[index - 1] ?? ''));
135
+ }
136
+ /**
137
+ * Escape a value for a TOML basic string.
138
+ *
139
+ * The `-c` override is parsed as TOML, so the URL lands inside double quotes
140
+ * and has to survive that. Control characters are escaped rather than passed
141
+ * through because TOML rejects raw ones and the whole override would fail to
142
+ * parse — taking the agent's tools with it.
143
+ */
144
+ function tomlString(value) {
145
+ const escaped = value
146
+ .replace(/\\/g, '\\\\')
147
+ .replace(/"/g, '\\"')
148
+ .replace(/[\u0000-\u001f\u007f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
149
+ return `"${escaped}"`;
150
+ }
151
+ /**
152
+ * The `-c` override that registers the ProhostAI MCP server.
153
+ *
154
+ * Codex takes a streamable-HTTP MCP server with **arbitrary** headers, which is
155
+ * what makes this possible at all: `codex mcp add --url` offers only
156
+ * `--bearer-token-env-var`, and a bearer token is the *wrong credential* for
157
+ * this endpoint — the server validates `Authorization: Bearer` as an OAuth
158
+ * access token, checks it first, and does not fall through to `X-API-Key` when
159
+ * it fails. A pairing hands out an `X-API-Key`. The `-c` path reaches the same
160
+ * config layer with none of that restriction.
161
+ *
162
+ * `env_http_headers` rather than `http_headers`: both work and both were
163
+ * verified against the running binary, but the former names an environment
164
+ * variable to read the value from instead of carrying the value itself, which
165
+ * keeps a live API key off a command line that anyone on the machine can read
166
+ * out of `ps`.
167
+ */
168
+ export function buildMcpOverride(mcpUrl) {
169
+ return (`mcp_servers.${MCP_SERVER_NAME}={url=${tomlString(mcpUrl)}, ` +
170
+ `env_http_headers={"X-API-Key"=${tomlString(MCP_API_KEY_ENV_VAR)}}}`);
171
+ }
172
+ /** Already present in the host's own command — never add a second one. */
173
+ function has(words, ...flags) {
174
+ return flags.some((flag) => words.includes(flag));
175
+ }
176
+ /**
177
+ * Both spellings of "bypass approvals and the sandbox".
178
+ *
179
+ * `--yolo` is a hidden alias of the long form; Codex rejects the pair outright.
180
+ */
181
+ const BYPASS_FLAGS = ['--yolo', '--dangerously-bypass-approvals-and-sandbox'];
182
+ /**
183
+ * Append the flags this harness needs to a Codex command.
184
+ *
185
+ * Four things get added, and each one is load-bearing:
186
+ *
187
+ * `--json` turns stdout into a JSONL event stream, which is the only way to
188
+ * read the thread id back out (and so the only way sessions can continue).
189
+ *
190
+ * `--skip-git-repo-check` is not optional. The harness runs the agent in
191
+ * `$PROHOST_HOME/workspace`, which is not a git repository, and Codex refuses
192
+ * to start there — `Not inside a trusted directory and --skip-git-repo-check
193
+ * was not specified.` Without this flag **every** Codex run fails before the
194
+ * model is ever reached.
195
+ *
196
+ * `--yolo` mirrors Claude Code's `--dangerously-skip-permissions`, for the same
197
+ * reason: in a non-interactive run nobody is there to approve a command, so a
198
+ * sandboxed brain silently declines the work it was hired for. It is a hidden
199
+ * alias of `--dangerously-bypass-approvals-and-sandbox`, and passing both spells
200
+ * is a hard clap error (`cannot be used multiple times`) — so a host who already
201
+ * wrote either one must not get ours on top, or every run of theirs fails.
202
+ *
203
+ * `resume` goes **after** the host's own flags, not before them. `codex exec
204
+ * resume` accepts a strict subset of `codex exec`'s flags (no `-s`, `-C`,
205
+ * `--add-dir`, `-p`), and a flag placed after `resume` is parsed by `resume`.
206
+ * `codex exec -s workspace-write resume <id>` parses; `codex exec resume <id>
207
+ * -s workspace-write` is rejected. Appending keeps the host's flags where Codex
208
+ * still understands them.
209
+ */
210
+ export function codexCommand(base, flags = {}) {
211
+ const platform = flags.platform ?? process.platform;
212
+ const quote = (value) => shellQuote(value, platform);
213
+ const gated = flags.skipPermissions === false;
214
+ // `--safe-tools` has to *remove* a bypass the host wrote, not merely decline
215
+ // to add one. Until 0.2.2 this README recommended `--exec 'codex exec -
216
+ // --yolo'`, so the flag is sitting in existing installs — and leaving it
217
+ // there would have the harness log "tools are permission-gated" and the
218
+ // prompt tell the agent that approvals will stop it, over a run that is in
219
+ // fact fully unsandboxed. A safety switch that quietly does nothing is worse
220
+ // than not having one.
221
+ const words = withoutPromptPositional(base.trim().split(/\s+/)).filter((word) => !gated || !BYPASS_FLAGS.includes(word));
222
+ const parts = [...words];
223
+ if (flags.sessionId)
224
+ parts.push('resume', quote(flags.sessionId));
225
+ if (!has(words, '--json'))
226
+ parts.push('--json');
227
+ if (!has(words, '--skip-git-repo-check'))
228
+ parts.push('--skip-git-repo-check');
229
+ if (!gated && !has(words, ...BYPASS_FLAGS)) {
230
+ parts.push('--yolo');
231
+ }
232
+ if (flags.mcpUrl)
233
+ parts.push('-c', quote(buildMcpOverride(flags.mcpUrl)));
234
+ // The prompt arrives on stdin, and `-` is how Codex is told to read it.
235
+ // Explicit rather than implied: `codex exec` with no positional does read
236
+ // stdin, but `codex exec resume <id>` treats a missing positional as "no
237
+ // prompt", and it also prints a `Reading prompt from stdin...` banner that
238
+ // the `-` form suppresses.
239
+ parts.push('-');
240
+ return parts.join(' ');
241
+ }
242
+ function asObject(candidate) {
243
+ try {
244
+ const parsed = JSON.parse(candidate);
245
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
246
+ return parsed;
247
+ }
248
+ return null;
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ }
254
+ function text(value) {
255
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
256
+ }
257
+ /** Pull a human-readable complaint out of whatever shape a failure event has. */
258
+ function failureMessage(event) {
259
+ const error = event.error;
260
+ if (typeof error === 'string')
261
+ return text(error);
262
+ if (error && typeof error === 'object') {
263
+ const record = error;
264
+ return text(record.message) ?? text(record.reason);
265
+ }
266
+ return text(event.message);
267
+ }
268
+ /**
269
+ * Read the reply and the thread id out of `--json` stdout.
270
+ *
271
+ * Codex emits **JSONL**, not one object: `thread.started` (carrying
272
+ * `thread_id`), `turn.started`, an `item.completed` per item the turn produced,
273
+ * then `turn.completed`. Only `agent_message` items are the answer — reasoning
274
+ * and command items share the stream — and the last one is what a person would
275
+ * have seen at the end of the run.
276
+ *
277
+ * Lines that are not JSON are skipped rather than treated as corruption,
278
+ * because they are *expected*: an unreachable MCP server puts `ERROR
279
+ * rmcp::transport::worker: …` straight into this stream, and the run around it
280
+ * succeeds normally. This was observed, not guessed at.
281
+ *
282
+ * As in `parseClaudeOutput`, output with no reply in it is a **failure** rather
283
+ * than a fallback to posting the raw text. We asked for JSON; anything else
284
+ * means something went wrong that we do not understand, and the one thing that
285
+ * must not happen is that reaching a customer's conversation.
286
+ */
287
+ export function parseCodexOutput(stdout) {
288
+ let sessionId;
289
+ let reply;
290
+ let failure;
291
+ for (const line of stdout.split('\n')) {
292
+ const trimmed = line.trim();
293
+ if (!trimmed || !trimmed.startsWith('{'))
294
+ continue;
295
+ const event = asObject(trimmed);
296
+ if (!event)
297
+ continue;
298
+ const type = event.type;
299
+ if (type === 'thread.started') {
300
+ sessionId = text(event.thread_id) ?? sessionId;
301
+ continue;
302
+ }
303
+ if (type === 'turn.failed' || type === 'error') {
304
+ failure = failureMessage(event) ?? failure ?? 'codex reported a failed turn';
305
+ continue;
306
+ }
307
+ if (type === 'item.completed') {
308
+ const item = event.item;
309
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
310
+ const record = item;
311
+ // Last one wins: a turn can emit several, and the final one is the
312
+ // answer the run ended on.
313
+ if (record.type === 'agent_message')
314
+ reply = text(record.text) ?? reply;
315
+ }
316
+ }
317
+ }
318
+ // A failed turn that still produced a message is a failure. The message in
319
+ // that case is the explanation ("I ran out of…"), not an answer, and posting
320
+ // it would put the failure in front of a customer as if the agent had chosen
321
+ // to say it.
322
+ if (failure)
323
+ return { ok: false, sessionId, error: failure };
324
+ if (reply)
325
+ return { ok: true, sessionId, text: reply };
326
+ const sample = stdout.trim().slice(0, 200);
327
+ return {
328
+ ok: false,
329
+ sessionId,
330
+ error: sample
331
+ ? `codex produced no agent message (asked for --json). First 200 chars: ${sample}`
332
+ : 'codex returned no output',
333
+ };
334
+ }
335
+ /**
336
+ * Does this look like the resume having been rejected, rather than the agent
337
+ * having failed at its actual work?
338
+ *
339
+ * Two ways a Codex resume can fail where a fresh run would have worked: the
340
+ * thread is gone (rollout files get cleaned up and the ledger cannot know), or
341
+ * the host's own flags are ones `codex exec resume` does not accept, which clap
342
+ * reports as `unexpected argument`. Both must degrade to a fresh session
343
+ * instead of breaking that surface forever.
344
+ *
345
+ * The `thread.started` guard is what keeps this from firing on an ordinary bad
346
+ * turn. Codex echoes the thread id in that event on **success**, so the
347
+ * "output mentions the session id" heuristic — which is all `claude.ts` can
348
+ * do — would match a resumed run that started fine and then failed at its
349
+ * work, and retrying that runs the agent, and its tools, a second time for
350
+ * nothing.
351
+ */
352
+ export function looksLikeCodexResumeFailure(output, sessionId) {
353
+ if (!sessionId)
354
+ return false;
355
+ if (/"type"\s*:\s*"thread\.started"/.test(output))
356
+ return false;
357
+ if (/unexpected argument|unrecognized subcommand|invalid value|error: unexpected/i.test(output)) {
358
+ return true;
359
+ }
360
+ // The first branch is the message the binary actually prints for a thread it
361
+ // no longer has on disk, captured from a real run:
362
+ // Error: thread/resume: thread/resume failed: no rollout found for thread
363
+ // id <uuid> (code -32600)
364
+ // The rest are broader, because the wording is not ours to depend on.
365
+ if (/no rollout found|no (conversation|session|thread)[^\n]{0,40}found|(session|thread)[^\n]{0,40}not found|resume[^\n]{0,20}(failed|invalid|expired)/i.test(output)) {
366
+ return true;
367
+ }
368
+ return output.includes(sessionId);
369
+ }
@@ -34,12 +34,33 @@ export declare const REDEEM_PATH = "/v1/agent-pairing/redeem";
34
34
  export declare function conversationMessagesPath(conversationId: string): string;
35
35
  /** `POST` — close out a run. Must be called for every run, success or not. */
36
36
  export declare function agentRunCompletePath(runId: string): string;
37
+ /**
38
+ * A file or image hanging off a message.
39
+ *
40
+ * Only `url` is required, and it is the only field that does any work: it is
41
+ * what an agent can actually fetch. The type and name are labels for the
42
+ * prompt.
43
+ */
44
+ export interface RunAttachment {
45
+ /** What the server called it — `image/jpeg`, `image`, `application/pdf`, … */
46
+ type?: string;
47
+ /** Where the bytes are. The agent downloads this to look at it. */
48
+ url: string;
49
+ /** Original filename, when the server kept one. */
50
+ name?: string;
51
+ }
37
52
  /**
38
53
  * One earlier message on the surface the run is about.
39
54
  *
40
- * Only `text` is required. Everything else is decoration the server may or may
41
- * not have — an older server sends none of this at all, and a newer one may add
42
- * fields this build has never heard of.
55
+ * Everything except `text` is decoration the server may or may not have — an
56
+ * older server sends none of this at all, and a newer one may add fields this
57
+ * build has never heard of.
58
+ *
59
+ * `text` may be **empty**, which is new: a guest who sends only a photo
60
+ * produces a real message with nothing in it but `attachments`. Treating that
61
+ * as a malformed row (which is what requiring text amounts to) would delete the
62
+ * picture from the thread and leave the agent answering a message it cannot see
63
+ * any trace of.
43
64
  */
44
65
  export interface RunMessage {
45
66
  /** Display name of whoever wrote it. */
@@ -49,6 +70,8 @@ export interface RunMessage {
49
70
  /** ISO-8601 timestamp. */
50
71
  at?: string;
51
72
  text: string;
73
+ /** Images and files sent with it. Absent on a server that predates the field. */
74
+ attachments?: RunAttachment[];
52
75
  }
53
76
  /** One memory the account has about this agent's work. */
54
77
  export interface RunMemory {
@@ -66,12 +89,41 @@ export interface RunMemory {
66
89
  * the part worth spending prompt on.
67
90
  */
68
91
  export declare const MAX_CONTEXT_ENTRIES = 50;
92
+ /** Ceiling on attachments kept from any one message. */
93
+ export declare const MAX_ATTACHMENTS = 10;
94
+ /**
95
+ * Ceiling on the operator-configured system prompt.
96
+ *
97
+ * Deliberately well above the server's own ~8000-character bound rather than
98
+ * equal to it. This is a backstop against a pathological payload, not a second
99
+ * copy of the product rule: matching the server's number exactly would mean a
100
+ * server that later raises its limit gets silently truncated by every CLI
101
+ * already on someone's laptop, and the operator would see their instructions
102
+ * quietly stop half-way through with nothing to explain it.
103
+ */
104
+ export declare const MAX_SYSTEM_PROMPT_CHARS = 20000;
69
105
  /** The `agent.run_requested` payload, as far as the harness cares about it. */
70
106
  export interface AgentRunRequest {
71
107
  run_id: string;
72
108
  agent_id?: string;
73
109
  agent_user_id?: string;
74
110
  agent_name?: string;
111
+ /**
112
+ * The agent's job title, as the operator wrote it — "Guest Experience Lead".
113
+ * Rendered alongside the name so the agent knows what it was hired as.
114
+ */
115
+ agent_title?: string;
116
+ /**
117
+ * The operator-configured system prompt for this agent — its "soul".
118
+ *
119
+ * Written by the person who runs this machine, in the ProhostAI agent
120
+ * profile, and bounded server-side. Trusted to the same degree as the
121
+ * `--exec` command the host typed: it configures the agent rather than asking
122
+ * it for anything, and it arrives over the same authenticated, HMAC-verified
123
+ * channel as the rest of the run. Absent on an older server, and absent for
124
+ * an agent whose operator never wrote one.
125
+ */
126
+ system_prompt?: string;
75
127
  account_id?: string;
76
128
  /** Why the agent woke — `team_chat_mention`, `task_comment`, … */
77
129
  kind?: string;
@@ -82,6 +134,13 @@ export interface AgentRunRequest {
82
134
  trigger_message_id?: string;
83
135
  /** Capped inline excerpt of the message that woke the agent. */
84
136
  trigger_text?: string;
137
+ /**
138
+ * Images and files on the message that woke the agent.
139
+ *
140
+ * Can be the *whole* message: a guest who sends a photo of a broken boiler
141
+ * and nothing else produces an empty `trigger_text` and one attachment here.
142
+ */
143
+ trigger_attachments?: RunAttachment[];
85
144
  trigger_user_id?: string;
86
145
  /** For a trigger fan-out with no surface: the event type that fired it. */
87
146
  trigger_type?: string;
@@ -48,6 +48,19 @@ export function agentRunCompletePath(runId) {
48
48
  * the part worth spending prompt on.
49
49
  */
50
50
  export const MAX_CONTEXT_ENTRIES = 50;
51
+ /** Ceiling on attachments kept from any one message. */
52
+ export const MAX_ATTACHMENTS = 10;
53
+ /**
54
+ * Ceiling on the operator-configured system prompt.
55
+ *
56
+ * Deliberately well above the server's own ~8000-character bound rather than
57
+ * equal to it. This is a backstop against a pathological payload, not a second
58
+ * copy of the product rule: matching the server's number exactly would mean a
59
+ * server that later raises its limit gets silently truncated by every CLI
60
+ * already on someone's laptop, and the operator would see their instructions
61
+ * quietly stop half-way through with nothing to explain it.
62
+ */
63
+ export const MAX_SYSTEM_PROMPT_CHARS = 20_000;
51
64
  /**
52
65
  * Fallback for a server that predates `reply_instructions.body_field`.
53
66
  *
@@ -112,11 +125,55 @@ function bodyExtra(value) {
112
125
  const entries = value;
113
126
  return Object.keys(entries).length > 0 ? entries : undefined;
114
127
  }
115
- function parseMessage(entry) {
116
- const text = str(entry.text);
128
+ /**
129
+ * Validate an attachment list.
130
+ *
131
+ * An entry with no `url` is dropped: the URL is the only part an agent can act
132
+ * on, so a nameless, typeless, linkless attachment is a line of prompt telling
133
+ * the agent something exists that it has no way to look at — worse than saying
134
+ * nothing.
135
+ *
136
+ * Trimmed from the front, unlike the two context lists: attachments on one
137
+ * message have no useful order, so the first few are as good as the last few.
138
+ */
139
+ function attachmentList(value) {
140
+ if (!Array.isArray(value))
141
+ return undefined;
142
+ const items = [];
143
+ for (const raw of value) {
144
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
145
+ continue;
146
+ const entry = raw;
147
+ const url = str(entry.url);
148
+ if (!url)
149
+ continue;
150
+ items.push({ url, type: str(entry.type), name: str(entry.name) });
151
+ }
152
+ return items.length > 0 ? items.slice(0, MAX_ATTACHMENTS) : undefined;
153
+ }
154
+ /**
155
+ * Bound the operator's system prompt.
156
+ *
157
+ * Truncated rather than dropped: half a set of standing instructions still
158
+ * describes the agent far better than none, and the alternative — an agent that
159
+ * silently loses its whole configured persona because someone pasted a novel
160
+ * into the box — is the worse failure of the two.
161
+ */
162
+ function systemPrompt(value) {
163
+ const text = str(value);
117
164
  if (!text)
165
+ return undefined;
166
+ return text.length > MAX_SYSTEM_PROMPT_CHARS ? text.slice(0, MAX_SYSTEM_PROMPT_CHARS) : text;
167
+ }
168
+ function parseMessage(entry) {
169
+ const attachments = attachmentList(entry.attachments);
170
+ // Not `str()`: an image-only message carries an empty string here and is a
171
+ // real message, so emptiness disqualifies a row only when there is also
172
+ // nothing attached to it.
173
+ const text = typeof entry.text === 'string' ? entry.text : '';
174
+ if (!text && !attachments)
118
175
  return null;
119
- return { text, author: str(entry.author), role: str(entry.role), at: str(entry.at) };
176
+ return { text, author: str(entry.author), role: str(entry.role), at: str(entry.at), attachments };
120
177
  }
121
178
  function parseMemory(entry) {
122
179
  const content = str(entry.content);
@@ -166,6 +223,11 @@ export function parseRunRequest(data) {
166
223
  agent_id: str(data.agent_id),
167
224
  agent_user_id: str(data.agent_user_id),
168
225
  agent_name: str(data.agent_name),
226
+ agent_title: str(data.agent_title),
227
+ // Accepted at the top level and under `agent`, on the same reasoning as the
228
+ // context lists below: the server can move it without a CLI release, and a
229
+ // published CLI cannot be redeployed to follow it.
230
+ system_prompt: systemPrompt(data.system_prompt ?? obj(data.agent).system_prompt),
169
231
  account_id: str(data.account_id),
170
232
  kind: str(data.kind),
171
233
  conversation_id: conversationId,
@@ -174,6 +236,7 @@ export function parseRunRequest(data) {
174
236
  trigger_source: str(trigger.source) ?? str(data.trigger_source),
175
237
  trigger_message_id: str(trigger.message_id),
176
238
  trigger_text: str(trigger.text_excerpt),
239
+ trigger_attachments: attachmentList(trigger.attachments ?? data.trigger_attachments),
177
240
  trigger_user_id: str(trigger.user_id),
178
241
  trigger_type: str(trigger.type),
179
242
  trigger_entity_id: str(trigger.entity_id),
@@ -0,0 +1,23 @@
1
+ /**
2
+ * What the ProhostAI MCP server is called, in one place.
3
+ *
4
+ * Both brain harnesses register the same server under the same name, and the
5
+ * prompt describes it by that name — so a rename here is a rename everywhere
6
+ * rather than three edits that can drift apart.
7
+ */
8
+ /**
9
+ * Name the ProhostAI MCP server is registered under, which is also what makes
10
+ * its tools appear as `mcp__prohost__<tool>` in Claude Code. Referenced in the
11
+ * prompt, in the `--allowedTools` pattern, and in both generated configs.
12
+ */
13
+ export declare const MCP_SERVER_NAME = "prohost";
14
+ /**
15
+ * How Claude Code spells the wired-in tools.
16
+ *
17
+ * Deliberately **not** used for Codex: that binary namespaces MCP tools its own
18
+ * way, and this harness has not verified the spelling against it. Telling an
19
+ * agent a tool is called something it isn't is the same class of failure as
20
+ * naming an endpoint it cannot call — so the prompt names the server instead
21
+ * and leaves the pattern out. See {@link ../prompt.ts}.
22
+ */
23
+ export declare const MCP_TOOL_PATTERN = "mcp__prohost__*";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * What the ProhostAI MCP server is called, in one place.
3
+ *
4
+ * Both brain harnesses register the same server under the same name, and the
5
+ * prompt describes it by that name — so a rename here is a rename everywhere
6
+ * rather than three edits that can drift apart.
7
+ */
8
+ /**
9
+ * Name the ProhostAI MCP server is registered under, which is also what makes
10
+ * its tools appear as `mcp__prohost__<tool>` in Claude Code. Referenced in the
11
+ * prompt, in the `--allowedTools` pattern, and in both generated configs.
12
+ */
13
+ export const MCP_SERVER_NAME = 'prohost';
14
+ /**
15
+ * How Claude Code spells the wired-in tools.
16
+ *
17
+ * Deliberately **not** used for Codex: that binary namespaces MCP tools its own
18
+ * way, and this harness has not verified the spelling against it. Telling an
19
+ * agent a tool is called something it isn't is the same class of failure as
20
+ * naming an endpoint it cannot call — so the prompt names the server instead
21
+ * and leaves the pattern out. See {@link ../prompt.ts}.
22
+ */
23
+ export const MCP_TOOL_PATTERN = `mcp__${MCP_SERVER_NAME}__*`;
@@ -18,6 +18,17 @@ export interface PromptContext {
18
18
  * nothing: the agent reports having looked something up when it did not.
19
19
  */
20
20
  mcpServerName?: string;
21
+ /**
22
+ * How this particular brain spells the wired-in tools, e.g. `mcp__prohost__*`.
23
+ *
24
+ * Set only when the harness has actually verified the spelling for the brain
25
+ * it is driving — Claude Code, today. Left unset for Codex, whose MCP tool
26
+ * namespacing this harness has not confirmed: the prompt then names the
27
+ * server without claiming a prefix, because a wrong tool name is the same
28
+ * failure as a wrong endpoint. The agent can see its own tool list; it does
29
+ * not need us to guess at it.
30
+ */
31
+ mcpToolPattern?: string;
21
32
  /**
22
33
  * Tools are behind permission prompts this run (`--safe-tools`).
23
34
  *