@prohost/cli 0.2.0 → 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.
- package/CHANGELOG.md +102 -0
- package/README.md +71 -17
- package/dist/agent/claude.d.ts +2 -7
- package/dist/agent/claude.js +17 -29
- package/dist/agent/codex.d.ts +161 -0
- package/dist/agent/codex.js +369 -0
- package/dist/agent/contract.d.ts +62 -3
- package/dist/agent/contract.js +66 -3
- package/dist/agent/mcp.d.ts +23 -0
- package/dist/agent/mcp.js +23 -0
- package/dist/agent/prompt.d.ts +11 -0
- package/dist/agent/prompt.js +97 -4
- package/dist/agent/run.js +77 -19
- package/dist/agent/shell.d.ts +23 -0
- package/dist/agent/shell.js +27 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/agent/contract.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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;
|
package/dist/agent/contract.js
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
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}__*`;
|
package/dist/agent/prompt.d.ts
CHANGED
|
@@ -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
|
*
|