@prohost/cli 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,42 @@ 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 counterpart to the ToolSearch bullet, and the same failure one level
149
+ // up: a brain that assumes it is boxed in reports "I have no access to the
150
+ // codebase" from a machine with the checkout, the credentials and the CLI
151
+ // all sitting right there. Only true when tools are ungated — under
152
+ // --safe-tools the looking-around itself would sit waiting on an approval
153
+ // nobody is there to give, so the licence is withheld rather than dangled.
154
+ ...(context.toolsGated
155
+ ? []
156
+ : [
157
+ "- You are running on your operator's own machine, in their shell, with",
158
+ ' whatever they have installed — not in an empty sandbox. Before you',
159
+ ' tell anyone you have no access to some repository, codebase, log, or',
160
+ ' capability, take a QUICK look for it: the usual places a checkout',
161
+ ' lives, `gh auth status`, whether the command is on PATH. A quick',
162
+ ' look, not an audit — this run has a hard time limit and somebody',
163
+ ' waiting at the end of it.',
164
+ ]),
165
+ // Only when there is something to look at. Said unconditionally it is a
166
+ // paragraph of advice about a situation that does not exist, which is how a
167
+ // prompt teaches an agent to mention attachments nobody sent.
168
+ ...(!hasAttachments
169
+ ? []
170
+ : context.toolsGated
171
+ ? [
172
+ '- Attachments above are given as URLs, not as pictures you can see.',
173
+ ' Fetching one needs a tool that may not run this turn, so if you',
174
+ ' could not view it, say so rather than guessing at what it shows.',
175
+ ]
176
+ : [
177
+ '- Attachments above are given as URLs, and a URL is not something you',
178
+ ' can see. To actually look at one, download it to a local file first',
179
+ ' (`curl -sL "<url>" -o /tmp/attachment.jpg`) and then open that file',
180
+ ' with your file-reading tool — your vision works on local files, not',
181
+ ' on links. Never describe an image you have not opened.',
182
+ ]), "- 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
183
  // Deliberately does not list which domains are reachable. A paired
103
184
  // credential holds a narrow, server-chosen scope set — the current default
104
185
  // covers conversations, tasks, cleanings and approvals but NOT reservations
@@ -107,7 +188,13 @@ export function buildPrompt(run, context) {
107
188
  // domains the credential can't reach is the same failure as naming an MCP
108
189
  // endpoint it can't call: the agent reports a lookup it never made.
109
190
  context.mcpServerName
110
- ? `- You have ProhostAI tools (mcp__${context.mcpServerName}__*). They are the sanctioned ` +
191
+ ? `- You have ProhostAI tools${
192
+ // Named by pattern only where the harness has verified how this brain
193
+ // spells them; otherwise by server, which the agent can match against
194
+ // its own tool list without being told a name that might be wrong.
195
+ context.mcpToolPattern
196
+ ? ` (${context.mcpToolPattern})`
197
+ : `, from the MCP server registered as "${context.mcpServerName}"`}. They are the sanctioned ` +
111
198
  'way to look up workspace data, so use them rather than guessing. Your credential is ' +
112
199
  'deliberately narrow and does not reach every part of the workspace: if a tool returns ' +
113
200
  'a permission error, that is a real limit rather than a glitch — say what you could not ' +
package/dist/agent/run.js CHANGED
@@ -20,7 +20,9 @@
20
20
  import os from 'node:os';
21
21
  import WebSocket from 'ws';
22
22
  import { completeRun, postReply } from './api.js';
23
- import { MCP_SERVER_NAME, claudeCommand, isClaudeExec, looksLikeResumeFailure, parseClaudeOutput, writeMcpConfig, } from './claude.js';
23
+ import { claudeCommand, isClaudeExec, looksLikeResumeFailure, parseClaudeOutput, writeMcpConfig, } from './claude.js';
24
+ import { MCP_API_KEY_ENV_VAR, codexCommand, isCodexExec, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
25
+ import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
24
26
  import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, eventData, parseRunRequest, } from './contract.js';
25
27
  import { ensureWorkspace, streamUrlFor, workspacePath } from './credentials.js';
26
28
  import { execAgent } from './exec.js';
@@ -125,13 +127,23 @@ function resolveWorkdir(options, log) {
125
127
  function buildRuntime(options, log) {
126
128
  const workdir = resolveWorkdir(options, log);
127
129
  const sessions = SessionStore.load(options.env, log);
128
- const claude = isClaudeExec(options.exec);
130
+ const brain = isClaudeExec(options.exec)
131
+ ? 'claude'
132
+ : isCodexExec(options.exec)
133
+ ? 'codex'
134
+ : 'plain';
129
135
  const skipPermissions = options.safeTools !== true;
130
- if (!claude)
131
- return { workdir, sessions, claude, skipPermissions };
132
- let mcpConfigPath;
136
+ if (brain === 'plain') {
137
+ return { workdir, sessions, brain, skipPermissions, mcpWired: false };
138
+ }
133
139
  const mcpUrl = options.credentials.mcp_url;
134
- if (mcpUrl) {
140
+ let mcpConfigPath;
141
+ let codexMcpUrl;
142
+ let execEnv;
143
+ if (!mcpUrl) {
144
+ log(`${timestamp()} ! this pairing carries no MCP endpoint; the agent will run without ProhostAI tools`);
145
+ }
146
+ else if (brain === 'claude') {
135
147
  try {
136
148
  mcpConfigPath = writeMcpConfig({
137
149
  mcpUrl,
@@ -149,21 +161,37 @@ function buildRuntime(options, log) {
149
161
  }
150
162
  }
151
163
  else {
152
- log(`${timestamp()} ! this pairing carries no MCP endpoint; the agent will run without ProhostAI tools`);
164
+ // Codex needs no file: the server is a `-c` override on each invocation and
165
+ // the key is read from the child's environment. Nothing to fail, so nothing
166
+ // to fall back from.
167
+ codexMcpUrl = mcpUrl;
168
+ execEnv = { [MCP_API_KEY_ENV_VAR]: options.credentials.api_key };
169
+ }
170
+ const mcpWired = Boolean(mcpConfigPath ?? codexMcpUrl);
171
+ const tools = mcpWired ? `, ProhostAI tools wired in as ${MCP_SERVER_NAME}` : '';
172
+ const where = workdir ? ` (workspace: ${workdir})` : '';
173
+ log(brain === 'claude'
174
+ ? `${timestamp()} ⚙ Claude Code detected — resuming a session per conversation${tools}${where}`
175
+ : `${timestamp()} ⚙ Codex detected — resuming a thread per conversation${tools}${where}`);
176
+ if (brain === 'codex' && mcpWired) {
177
+ // The one asymmetry worth an operator knowing about, because it is a
178
+ // security property they may be assuming they have. Claude Code is told to
179
+ // load our config and nothing else; Codex has no equivalent switch, so its
180
+ // own `~/.codex/config.toml` servers come along.
181
+ log(`${timestamp()} ! Codex has no --strict-mcp-config equivalent: this run also holds ` +
182
+ 'whatever MCP servers your ~/.codex/config.toml defines.');
153
183
  }
154
- log(`${timestamp()} ⚙ Claude Code detected — resuming a session per conversation` +
155
- (mcpConfigPath ? `, ProhostAI tools wired in as ${MCP_SERVER_NAME}` : '') +
156
- `${workdir ? ` (workspace: ${workdir})` : ''}`);
157
184
  // Said out loud every start-up, not buried in --help. This is the line that
158
185
  // tells an operator their machine will run tool calls, unprompted, on text
159
186
  // written by someone else in their workspace.
187
+ const bypassFlag = brain === 'claude' ? '--dangerously-skip-permissions' : '--yolo';
160
188
  log(skipPermissions
161
189
  ? `${timestamp()} ⚠ tools run WITHOUT permission prompts ` +
162
- '(--dangerously-skip-permissions). The agent acts as you, with your local ' +
190
+ `(${bypassFlag}). The agent acts as you, with your local ` +
163
191
  'access, on instructions from your workspace. Use --safe-tools to gate them.'
164
192
  : `${timestamp()} ⚙ --safe-tools: tools are permission-gated; anything needing ` +
165
193
  'approval will not run (nobody is at a terminal to approve it).');
166
- return { workdir, sessions, claude, mcpConfigPath, skipPermissions };
194
+ return { workdir, sessions, brain, mcpConfigPath, codexMcpUrl, execEnv, mcpWired, skipPermissions };
167
195
  }
168
196
  /**
169
197
  * Run the agent command once and turn its output into a reply.
@@ -187,6 +215,7 @@ async function runTurn(run, prompt, options, runtime, log) {
187
215
  prompt,
188
216
  timeoutMs: remainingMs(),
189
217
  cwd: runtime.workdir,
218
+ env: runtime.execEnv,
190
219
  spawnImpl: options.spawnImpl,
191
220
  });
192
221
  // Wall clock for the whole turn, retry included — what the operator waited,
@@ -199,7 +228,7 @@ async function runTurn(run, prompt, options, runtime, log) {
199
228
  detail: result.stderr.trim().slice(-500),
200
229
  durationMs: elapsed(),
201
230
  });
202
- if (!runtime.claude) {
231
+ if (runtime.brain === 'plain') {
203
232
  const result = await exec(options.exec);
204
233
  if (!result.ok)
205
234
  return failure(result);
@@ -209,29 +238,55 @@ async function runTurn(run, prompt, options, runtime, log) {
209
238
  : { ok: false, reason: 'agent produced no output', durationMs: elapsed() };
210
239
  }
211
240
  // No session bookkeeping on a dry run. `--dry-run` promises to make no
212
- // writes, and a resumed turn is a write: Claude Code records it in the
241
+ // writes, and a resumed turn is a write: both brains record it in their
213
242
  // transcript whether or not we post the reply, so remembering the session
214
243
  // would leave the next *real* run continuing from an answer nobody saw.
215
244
  const key = options.dryRun ? undefined : sessionKeyFor(run);
216
245
  const sessionId = runtime.sessions.get(key);
217
- const shape = { mcpConfigPath: runtime.mcpConfigPath, skipPermissions: runtime.skipPermissions };
218
- let result = await exec(claudeCommand(options.exec, { ...shape, sessionId }));
246
+ const codex = runtime.brain === 'codex';
247
+ // Same three questions for both brains — resume this thread, wire these
248
+ // tools, gate or don't — asked with each one's own flags.
249
+ const build = (resume) => codex
250
+ ? codexCommand(options.exec, {
251
+ sessionId: resume,
252
+ mcpUrl: runtime.codexMcpUrl,
253
+ skipPermissions: runtime.skipPermissions,
254
+ })
255
+ : claudeCommand(options.exec, {
256
+ sessionId: resume,
257
+ mcpConfigPath: runtime.mcpConfigPath,
258
+ skipPermissions: runtime.skipPermissions,
259
+ });
260
+ let result = await exec(build(sessionId));
219
261
  // A remembered session may simply be gone — transcripts are cleaned up and
220
262
  // the ledger can't know. One retry without it, because the alternative is a
221
263
  // surface that fails every run forever over an optimisation.
264
+ const resumeFailed = codex ? looksLikeCodexResumeFailure : looksLikeResumeFailure;
222
265
  if (!result.ok &&
223
266
  sessionId &&
224
- looksLikeResumeFailure(`${result.stderr}\n${result.stdout}`, sessionId) &&
267
+ resumeFailed(`${result.stderr}\n${result.stdout}`, sessionId) &&
225
268
  // Skipped rather than squeezed into a token budget: a retry with no time
226
269
  // left would be killed on the spot and reported as a timeout, which is a
227
270
  // worse error message than the resume failure it replaced.
228
271
  remainingMs() > 0) {
229
272
  log(` … session ${sessionId.slice(0, 12)} could not be resumed; starting a fresh one`);
230
273
  runtime.sessions.forget(key);
231
- result = await exec(claudeCommand(options.exec, shape));
274
+ result = await exec(build());
232
275
  }
233
276
  if (!result.ok)
234
277
  return failure(result);
278
+ if (codex) {
279
+ const output = parseCodexOutput(result.stdout);
280
+ if (!output.ok) {
281
+ return {
282
+ ok: false,
283
+ reason: 'the agent did not produce a usable reply',
284
+ detail: output.error,
285
+ durationMs: elapsed(),
286
+ };
287
+ }
288
+ return { ok: true, reply: output.text, durationMs: elapsed(), sessionId: output.sessionId };
289
+ }
235
290
  const parsed = parseClaudeOutput(result.stdout);
236
291
  // A denied tool is not an error the agent reports — the turn succeeds and the
237
292
  // reply just quietly lacks whatever the tool would have found. Printing it is
@@ -265,7 +320,10 @@ async function handleRun(run, options, runtime, api, log, sleep) {
265
320
  // Named only when the tools are actually reachable from this invocation.
266
321
  // Telling an agent about an endpoint it has no client for is what produced
267
322
  // replies describing lookups that never happened.
268
- mcpServerName: runtime.mcpConfigPath ? MCP_SERVER_NAME : undefined,
323
+ mcpServerName: runtime.mcpWired ? MCP_SERVER_NAME : undefined,
324
+ // Only Claude Code's spelling has been verified against the binary, so only
325
+ // Claude Code gets told a pattern. See PromptContext.mcpToolPattern.
326
+ mcpToolPattern: runtime.brain === 'claude' && runtime.mcpWired ? MCP_TOOL_PATTERN : undefined,
269
327
  toolsGated: !runtime.skipPermissions,
270
328
  });
271
329
  const result = await runTurn(run, prompt, options, runtime, log);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Quoting for the `shell: true` command lines the brain harnesses build.
3
+ *
4
+ * Shared by `claude.ts` and `codex.ts` because both append values — a session
5
+ * id we stored, a path under `$PROHOST_HOME`, a TOML config override — to a
6
+ * string that a shell will parse.
7
+ */
8
+ /**
9
+ * Quote a value for a `shell: true` command line on this platform.
10
+ *
11
+ * Every value that goes through here is ours or the operator's — never
12
+ * event-derived text. It is quoted anyway: a home directory with a space in it
13
+ * is ordinary, and "the inputs are trusted" is the assumption that stops being
14
+ * true the first time someone extends this.
15
+ *
16
+ * Windows needs its own branch. `shell: true` there runs `cmd.exe`, where a
17
+ * single quote is an ordinary character rather than a quoting one — POSIX
18
+ * quoting would hand the child a config path with literal apostrophes around
19
+ * it, and the tools would simply never load.
20
+ */
21
+ export declare function shellQuote(value: string, platform: NodeJS.Platform): string;
22
+ /** Pipes, lists, redirections, subshells — anything that makes "append to the end" wrong. */
23
+ export declare const SHELL_CONTROL_OPERATORS: RegExp;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Quoting for the `shell: true` command lines the brain harnesses build.
3
+ *
4
+ * Shared by `claude.ts` and `codex.ts` because both append values — a session
5
+ * id we stored, a path under `$PROHOST_HOME`, a TOML config override — to a
6
+ * string that a shell will parse.
7
+ */
8
+ /**
9
+ * Quote a value for a `shell: true` command line on this platform.
10
+ *
11
+ * Every value that goes through here is ours or the operator's — never
12
+ * event-derived text. It is quoted anyway: a home directory with a space in it
13
+ * is ordinary, and "the inputs are trusted" is the assumption that stops being
14
+ * true the first time someone extends this.
15
+ *
16
+ * Windows needs its own branch. `shell: true` there runs `cmd.exe`, where a
17
+ * single quote is an ordinary character rather than a quoting one — POSIX
18
+ * quoting would hand the child a config path with literal apostrophes around
19
+ * it, and the tools would simply never load.
20
+ */
21
+ export function shellQuote(value, platform) {
22
+ if (platform === 'win32')
23
+ return `"${value.replace(/"/g, '""')}"`;
24
+ return `'${value.replace(/'/g, `'\\''`)}'`;
25
+ }
26
+ /** Pipes, lists, redirections, subshells — anything that makes "append to the end" wrong. */
27
+ export const SHELL_CONTROL_OPERATORS = /[|&;<>(){}`]|\$\(/;
package/dist/version.d.ts CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export declare const CLI_VERSION = "0.2.1";
10
+ export declare const CLI_VERSION = "0.2.2";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.2.1";
12
+ export declare const USER_AGENT = "prohost-cli/0.2.2";
package/dist/version.js CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export const CLI_VERSION = '0.2.1';
10
+ export const CLI_VERSION = '0.2.2';
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
12
  export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",