@prohost/cli 0.2.1 → 0.2.3
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 +211 -0
- package/README.md +198 -17
- package/dist/agent/claude.d.ts +23 -10
- package/dist/agent/claude.js +24 -30
- package/dist/agent/codex.d.ts +365 -0
- package/dist/agent/codex.js +901 -0
- package/dist/agent/contract.d.ts +62 -3
- package/dist/agent/contract.js +66 -3
- package/dist/agent/extras.d.ts +73 -0
- package/dist/agent/extras.js +118 -0
- package/dist/agent/mcp.d.ts +24 -0
- package/dist/agent/mcp.js +24 -0
- package/dist/agent/prompt.d.ts +12 -0
- package/dist/agent/prompt.js +107 -4
- package/dist/agent/run.d.ts +33 -0
- package/dist/agent/run.js +263 -50
- package/dist/agent/shell.d.ts +23 -0
- package/dist/agent/shell.js +27 -0
- package/dist/index.js +10 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
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,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
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
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 both shaped brains spell the wired-in tools.
|
|
16
|
+
*
|
|
17
|
+
* Claude Code's spelling was known from its documentation. Codex's was an open
|
|
18
|
+
* question until 0.2.3 — the prompt named the server and left the pattern out
|
|
19
|
+
* rather than guess — and the answer, measured by registering a stub MCP server
|
|
20
|
+
* against a real `codex-cli 0.144.1` and asking the model what it had just
|
|
21
|
+
* called, is that Codex uses the same `mcp__<server>__<tool>` convention. So
|
|
22
|
+
* both brains now get the pattern. See {@link ../prompt.ts}.
|
|
23
|
+
*/
|
|
24
|
+
export declare const MCP_TOOL_PATTERN = "mcp__prohost__*";
|
|
@@ -0,0 +1,24 @@
|
|
|
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 both shaped brains spell the wired-in tools.
|
|
16
|
+
*
|
|
17
|
+
* Claude Code's spelling was known from its documentation. Codex's was an open
|
|
18
|
+
* question until 0.2.3 — the prompt named the server and left the pattern out
|
|
19
|
+
* rather than guess — and the answer, measured by registering a stub MCP server
|
|
20
|
+
* against a real `codex-cli 0.144.1` and asking the model what it had just
|
|
21
|
+
* called, is that Codex uses the same `mcp__<server>__<tool>` convention. So
|
|
22
|
+
* both brains now get the pattern. See {@link ../prompt.ts}.
|
|
23
|
+
*/
|
|
24
|
+
export const MCP_TOOL_PATTERN = `mcp__${MCP_SERVER_NAME}__*`;
|
package/dist/agent/prompt.d.ts
CHANGED
|
@@ -18,6 +18,18 @@ 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 and, since 0.2.3, Codex, which turned out to
|
|
26
|
+
* use the same convention. Left unset for every other `--exec` command,
|
|
27
|
+
* whose namespacing we have no way to know: the prompt then names the server
|
|
28
|
+
* without claiming a prefix, because a wrong tool name is the same failure as
|
|
29
|
+
* a wrong endpoint. The agent can see its own tool list; it does not need us
|
|
30
|
+
* to guess at it.
|
|
31
|
+
*/
|
|
32
|
+
mcpToolPattern?: string;
|
|
21
33
|
/**
|
|
22
34
|
* Tools are behind permission prompts this run (`--safe-tools`).
|
|
23
35
|
*
|
package/dist/agent/prompt.js
CHANGED
|
@@ -6,6 +6,19 @@
|
|
|
6
6
|
* a shell script that greps it. The reply contract is stated explicitly so an
|
|
7
7
|
* agent that would otherwise narrate its reasoning returns something sendable.
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* Describe one attachment.
|
|
11
|
+
*
|
|
12
|
+
* The URL is the point — it is the only part the agent can act on — so it goes
|
|
13
|
+
* last, where it is easy to copy out of, and it is never abbreviated.
|
|
14
|
+
*/
|
|
15
|
+
function renderAttachment(attachment) {
|
|
16
|
+
// `image/jpeg` and a bare `image` both count; anything else, including a
|
|
17
|
+
// missing type, is described as a file rather than guessed at.
|
|
18
|
+
const kind = attachment.type?.toLowerCase().startsWith('image') ? 'image' : 'file';
|
|
19
|
+
const name = attachment.name ? ` "${attachment.name}"` : '';
|
|
20
|
+
return `attached ${kind}${name}: ${attachment.url}`;
|
|
21
|
+
}
|
|
9
22
|
/**
|
|
10
23
|
* Render one line of thread history.
|
|
11
24
|
*
|
|
@@ -13,12 +26,18 @@
|
|
|
13
26
|
* a row with a role and no author — which is what a system row like
|
|
14
27
|
* `{role: "event", text: "Reservation confirmed"}` looks like — reads as
|
|
15
28
|
* `event: …` rather than the nonsense `someone (event): …`.
|
|
29
|
+
*
|
|
30
|
+
* Attachments render inline, after the text. An image-only message has no text
|
|
31
|
+
* at all, so the parts are joined rather than concatenated — otherwise the line
|
|
32
|
+
* opens with a stray space where the message would have been.
|
|
16
33
|
*/
|
|
17
34
|
function renderMessage(message) {
|
|
18
35
|
const who = message.author ?? message.role ?? 'someone';
|
|
19
36
|
const role = message.author && message.role ? ` (${message.role})` : '';
|
|
20
37
|
const when = message.at ? `[${message.at}] ` : '';
|
|
21
|
-
|
|
38
|
+
const attachments = (message.attachments ?? []).map((file) => `[${renderAttachment(file)}]`);
|
|
39
|
+
const body = [message.text, ...attachments].filter((part) => part.length > 0).join(' ');
|
|
40
|
+
return `${when}${who}${role}: ${body}`;
|
|
22
41
|
}
|
|
23
42
|
/** Render one remembered fact. */
|
|
24
43
|
function renderMemory(memory) {
|
|
@@ -28,8 +47,9 @@ function renderMemory(memory) {
|
|
|
28
47
|
export function buildPrompt(run, context) {
|
|
29
48
|
const name = run.agent_name ?? context.agentName;
|
|
30
49
|
const canReply = Boolean(run.reply_surface !== 'none' && run.reply_path && run.reply_body_key);
|
|
50
|
+
const title = run.agent_title ? ` (${run.agent_title})` : '';
|
|
31
51
|
const lines = [
|
|
32
|
-
`You are "${name}", an AI teammate in a ProhostAI workspace.`,
|
|
52
|
+
`You are "${name}"${title}, an AI teammate in a ProhostAI workspace.`,
|
|
33
53
|
canReply && run.reply_surface === 'conversation'
|
|
34
54
|
? 'You were mentioned in a conversation and someone is waiting on a reply.'
|
|
35
55
|
: run.reply_surface === 'none'
|
|
@@ -39,6 +59,20 @@ export function buildPrompt(run, context) {
|
|
|
39
59
|
: `You have been given work on a ${run.reply_surface}. There is nowhere to post a reply.`,
|
|
40
60
|
'',
|
|
41
61
|
];
|
|
62
|
+
// The operator's own configuration comes first, above everything the server
|
|
63
|
+
// generated. It is who this agent *is* — persona, house rules, escalation
|
|
64
|
+
// policy — and an agent that meets its standing instructions only after four
|
|
65
|
+
// blocks of run context reads them as an afterthought rather than as its
|
|
66
|
+
// brief.
|
|
67
|
+
//
|
|
68
|
+
// Rendered as instructions, not as quoted data: the text is written by the
|
|
69
|
+
// person who runs this machine, in their own agent profile, and reaches us
|
|
70
|
+
// over the same HMAC-verified channel as the rest of the run — the same trust
|
|
71
|
+
// as the `--exec` command they typed. It is fenced anyway, so the agent can
|
|
72
|
+
// always tell where its operator stops talking and the server starts.
|
|
73
|
+
if (run.system_prompt) {
|
|
74
|
+
lines.push('Your operator configured these standing instructions for you, in your', 'ProhostAI agent profile. Follow them — they define how you work here.', '--- begin operator instructions ---', run.system_prompt, '--- end operator instructions ---', 'Everything below this line is generated by ProhostAI for this one run. It', 'describes what is actually true about this turn and your access to it,', 'which the instructions above do not change.', '');
|
|
75
|
+
}
|
|
42
76
|
const facts = [];
|
|
43
77
|
if (run.kind)
|
|
44
78
|
facts.push(`Why you woke up: ${run.kind}`);
|
|
@@ -72,14 +106,26 @@ export function buildPrompt(run, context) {
|
|
|
72
106
|
if (run.recent_messages?.length) {
|
|
73
107
|
lines.push('Conversation so far (oldest first):', ...run.recent_messages.map(renderMessage), '--- end of conversation history ---', '');
|
|
74
108
|
}
|
|
109
|
+
const triggerAttachments = run.trigger_attachments ?? [];
|
|
75
110
|
if (run.trigger_text) {
|
|
76
111
|
// The server caps this excerpt, so say so rather than let an agent assume
|
|
77
112
|
// it has the whole message.
|
|
78
113
|
lines.push('Message that woke you (may be truncated by the server):', run.trigger_text, '');
|
|
79
114
|
}
|
|
115
|
+
else if (triggerAttachments.length > 0) {
|
|
116
|
+
// Distinct from "the server didn't send the text": here the message really
|
|
117
|
+
// had none. An agent told the text was merely missing goes looking for it,
|
|
118
|
+
// or apologises for not having it, when the picture below IS the message.
|
|
119
|
+
lines.push('The message that woke you had no text — what follows is all of it.', '');
|
|
120
|
+
}
|
|
80
121
|
else {
|
|
81
122
|
lines.push('The message text was not included in the event.', '');
|
|
82
123
|
}
|
|
124
|
+
if (triggerAttachments.length > 0) {
|
|
125
|
+
lines.push('Sent with that message:', ...triggerAttachments.map((file) => `- ${renderAttachment(file)}`), '');
|
|
126
|
+
}
|
|
127
|
+
const hasAttachments = triggerAttachments.length > 0 ||
|
|
128
|
+
Boolean(run.recent_messages?.some((message) => message.attachments?.length));
|
|
83
129
|
// An agent that doesn't know it is headless narrates as if it were mid-
|
|
84
130
|
// session — "let me connect to that", "I'll check once the tools load" —
|
|
85
131
|
// and that text is what gets posted to a customer, describing a lookup that
|
|
@@ -98,7 +144,58 @@ export function buildPrompt(run, context) {
|
|
|
98
144
|
// browse and says so to a customer, when one ToolSearch call away it
|
|
99
145
|
// could. Field-observed on the first 0.2.0 deployment; phrased
|
|
100
146
|
// conditionally because other `--exec` brains have no ToolSearch.
|
|
101
|
-
'- If you have a ToolSearch tool, part of your toolkit is deferred and', ' invisible until loaded — web browsing (WebSearch, WebFetch) usually', ' is. Search ToolSearch for a capability before telling anyone you', ' lack it.',
|
|
147
|
+
'- If you have a ToolSearch tool, part of your toolkit is deferred and', ' invisible until loaded — web browsing (WebSearch, WebFetch) usually', ' is. Search ToolSearch for a capability before telling anyone you', ' lack it.',
|
|
148
|
+
// The correction to the bullet above, and it is here because the bullet
|
|
149
|
+
// above caused a real production failure. A paired brain searched
|
|
150
|
+
// ToolSearch for "bash", found nothing — ToolSearch indexes only the
|
|
151
|
+
// *deferred* tools, and Bash is a direct one — and concluded across a
|
|
152
|
+
// whole resumed session that it had no shell, while
|
|
153
|
+
// `--dangerously-skip-permissions` had been handing it the full toolset
|
|
154
|
+
// the entire time. "Search before you claim you lack it" quietly taught
|
|
155
|
+
// the opposite of what it meant: that a ToolSearch miss is evidence of
|
|
156
|
+
// absence. It is not evidence of anything.
|
|
157
|
+
'- A ToolSearch miss proves nothing. ToolSearch indexes only the deferred', ' tools; the tools listed directly to you are the authoritative set, and', ' a tool can be fully available while ToolSearch has never heard of it', ' (Bash is exactly this). Never reason from a ToolSearch miss to "I do', ' not have that tool".',
|
|
158
|
+
// The counterpart to the ToolSearch bullet, and the same failure one level
|
|
159
|
+
// up: a brain that assumes it is boxed in reports "I have no access to the
|
|
160
|
+
// codebase" from a machine with the checkout, the credentials and the CLI
|
|
161
|
+
// all sitting right there. Only true when tools are ungated — under
|
|
162
|
+
// --safe-tools the looking-around itself would sit waiting on an approval
|
|
163
|
+
// nobody is there to give, so the licence is withheld rather than dangled.
|
|
164
|
+
...(context.toolsGated
|
|
165
|
+
? []
|
|
166
|
+
: [
|
|
167
|
+
"- You are running on your operator's own machine, in their shell, with",
|
|
168
|
+
' whatever they have installed — not in an empty sandbox. Before you',
|
|
169
|
+
' tell anyone you have no access to some repository, codebase, log, or',
|
|
170
|
+
' capability, take a QUICK look for it: the usual places a checkout',
|
|
171
|
+
' lives, `gh auth status`, whether the command is on PATH. A quick',
|
|
172
|
+
' look, not an audit — this run has a hard time limit and somebody',
|
|
173
|
+
' waiting at the end of it.',
|
|
174
|
+
'- "Looking" means running the command, not inspecting your own tool',
|
|
175
|
+
' list. Say you have no shell, no file access, or no checkout only',
|
|
176
|
+
' after an attempt failed and you can quote what it said. Introspection',
|
|
177
|
+
' is not evidence here: you were started with your tools unrestricted,',
|
|
178
|
+
' so a capability you cannot find in a list is far more likely to be',
|
|
179
|
+
' one you have not tried.',
|
|
180
|
+
]),
|
|
181
|
+
// Only when there is something to look at. Said unconditionally it is a
|
|
182
|
+
// paragraph of advice about a situation that does not exist, which is how a
|
|
183
|
+
// prompt teaches an agent to mention attachments nobody sent.
|
|
184
|
+
...(!hasAttachments
|
|
185
|
+
? []
|
|
186
|
+
: context.toolsGated
|
|
187
|
+
? [
|
|
188
|
+
'- Attachments above are given as URLs, not as pictures you can see.',
|
|
189
|
+
' Fetching one needs a tool that may not run this turn, so if you',
|
|
190
|
+
' could not view it, say so rather than guessing at what it shows.',
|
|
191
|
+
]
|
|
192
|
+
: [
|
|
193
|
+
'- Attachments above are given as URLs, and a URL is not something you',
|
|
194
|
+
' can see. To actually look at one, download it to a local file first',
|
|
195
|
+
' (`curl -sL "<url>" -o /tmp/attachment.jpg`) and then open that file',
|
|
196
|
+
' with your file-reading tool — your vision works on local files, not',
|
|
197
|
+
' on links. Never describe an image you have not opened.',
|
|
198
|
+
]), "- State limits plainly. If you cannot look something up, say what you don't", ' know and answer with what you do — a short honest answer beats a', ' confident invented one.',
|
|
102
199
|
// Deliberately does not list which domains are reachable. A paired
|
|
103
200
|
// credential holds a narrow, server-chosen scope set — the current default
|
|
104
201
|
// covers conversations, tasks, cleanings and approvals but NOT reservations
|
|
@@ -107,7 +204,13 @@ export function buildPrompt(run, context) {
|
|
|
107
204
|
// domains the credential can't reach is the same failure as naming an MCP
|
|
108
205
|
// endpoint it can't call: the agent reports a lookup it never made.
|
|
109
206
|
context.mcpServerName
|
|
110
|
-
? `- You have ProhostAI tools
|
|
207
|
+
? `- You have ProhostAI tools${
|
|
208
|
+
// Named by pattern only where the harness has verified how this brain
|
|
209
|
+
// spells them; otherwise by server, which the agent can match against
|
|
210
|
+
// its own tool list without being told a name that might be wrong.
|
|
211
|
+
context.mcpToolPattern
|
|
212
|
+
? ` (${context.mcpToolPattern})`
|
|
213
|
+
: `, from the MCP server registered as "${context.mcpServerName}"`}. They are the sanctioned ` +
|
|
111
214
|
'way to look up workspace data, so use them rather than guessing. Your credential is ' +
|
|
112
215
|
'deliberately narrow and does not reach every part of the workspace: if a tool returns ' +
|
|
113
216
|
'a permission error, that is a real limit rather than a glitch — say what you could not ' +
|