@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.
@@ -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,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}__*`;
@@ -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
  *
@@ -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
- return `${when}${who}${role}: ${message.text}`;
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.', "- 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.',
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 (mcp__${context.mcpServerName}__*). They are the sanctioned ` +
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 ' +