@prohost/cli 0.7.0 → 0.8.1

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 CHANGED
@@ -4,6 +4,42 @@ Versions follow [semver](https://semver.org/). Publishing is automated: merging
4
4
  a version bump to `main` triggers `.github/workflows/npm-publish-cli.yml`, which
5
5
  builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
+ ## 0.8.1
8
+
9
+ A long message no longer reaches a paired agent as a fragment it can't recover.
10
+
11
+ - **Clipped trigger text is called out.** When the server reports that the
12
+ message that woke the agent was clipped (`trigger.text_truncated: true`, new
13
+ in the run event), the prompt says so and, when the agent has ProhostAI tools
14
+ and a conversation, tells it to read the full message with
15
+ `get_conversation_messages` before answering. An agent without tools is told
16
+ to say the message reached it truncated instead of answering the fragment.
17
+ Keyed on the explicit flag, never on a trailing `…` a person could have typed;
18
+ a server that predates the flag reads as "not clipped". Pairs with the server raising the excerpt
19
+ cap from 500 to 16,000 characters and returning full message bodies from
20
+ `get_conversation_messages`, so this path is now rare.
21
+
22
+ ## 0.8.0
23
+
24
+ A paired agent woken as a bystander can now stay silent.
25
+
26
+ - **`reply_expected: false`.** When a team-chat message lands in a thread the
27
+ agent belongs to but is addressed to someone else (ProhostAI's participation
28
+ walk), the run event now says so. The prompt tells the agent the message was
29
+ not addressed to it and nobody is waiting on it — instead of "someone is
30
+ waiting on a reply" — and renders the server's `trigger.brief`, the same
31
+ bystander guidance ProhostAI's in-product agents get. Absent (every older
32
+ server) keeps today's prompt.
33
+ - **`[NO_REPLY]`.** An agent whose whole answer is `[NO_REPLY]` (any case,
34
+ surrounding whitespace ignored) posts nothing, and the run is completed
35
+ `{status: "succeeded", outcome: "silent"}` so ProhostAI records a deliberate
36
+ silence and clears the thinking indicator as one. Checked on every run, so
37
+ the literal token can never reach a thread. A server that predates `outcome`
38
+ ignores it and records a plain success.
39
+ - **`trigger.brief` is rendered** whenever the server sends one that is not just
40
+ the message restated — a work order used to be dropped on any run that also
41
+ had a message.
42
+
7
43
  ## 0.7.0
8
44
 
9
45
  Machines, accounts and agents: a paired AI employee now runs on a named
package/README.md CHANGED
@@ -117,6 +117,10 @@ nothing is posted and the run is left open.
117
117
  4. Your command's **stdout** is the reply. It's posted into the conversation
118
118
  verbatim, so print the message and nothing else — no preamble, no reasoning.
119
119
  (Claude Code and Codex are the exceptions: see below.)
120
+ To reply with nothing, print exactly `[NO_REPLY]`: nothing is posted and the
121
+ run is completed as a deliberate silence. That is the expected answer when
122
+ your agent is woken only because it is in a thread where someone else was
123
+ addressed — the prompt says so when that is the case.
120
124
  5. The run is completed either way. A command that crashes, times out, or prints
121
125
  nothing reports `failed`; a run is never left hanging.
122
126
 
@@ -136,6 +136,7 @@ export declare function heartbeatRun(options: ApiOptions, path: string, progress
136
136
  */
137
137
  export declare function completeRun(options: ApiOptions, path: string, outcome: {
138
138
  ok: true;
139
+ silent?: boolean;
139
140
  } | {
140
141
  ok: false;
141
142
  error: string;
package/dist/agent/api.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * `completion.path`), which is why these take a path rather than building one.
11
11
  */
12
12
  import { USER_AGENT } from '../version.js';
13
- import { MAX_ERROR_CHARS, MAX_MODEL_CHARS, MAX_PROGRESS_CHARS, MAX_TOOL_EVENTS_PER_BATCH, RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from './contract.js';
13
+ import { MAX_ERROR_CHARS, MAX_MODEL_CHARS, MAX_PROGRESS_CHARS, MAX_TOOL_EVENTS_PER_BATCH, RUN_OUTCOME_SILENT, RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from './contract.js';
14
14
  /** Deadline for the cheap calls — a heartbeat answers in milliseconds. */
15
15
  export const DEFAULT_TIMEOUT_MS = 30_000;
16
16
  /**
@@ -176,7 +176,7 @@ export async function heartbeatRun(options, path, progress) {
176
176
  */
177
177
  export async function completeRun(options, path, outcome, model) {
178
178
  const body = outcome.ok
179
- ? { status: RUN_STATUS_SUCCEEDED }
179
+ ? { status: RUN_STATUS_SUCCEEDED, ...(outcome.silent ? { outcome: RUN_OUTCOME_SILENT } : {}) }
180
180
  : { status: RUN_STATUS_FAILED, error: outcome.error.slice(0, MAX_ERROR_CHARS) };
181
181
  if (model)
182
182
  body.model = model.slice(0, MAX_MODEL_CHARS);
@@ -28,6 +28,27 @@ export declare const EVENT_MESSAGE_TEAM_CHAT = "message.team_chat";
28
28
  /** Terminal statuses accepted by the run-completion endpoint. */
29
29
  export declare const RUN_STATUS_SUCCEEDED = "succeeded";
30
30
  export declare const RUN_STATUS_FAILED = "failed";
31
+ /**
32
+ * Completion `outcome` for a run that deliberately posted nothing.
33
+ *
34
+ * Sent with `status: succeeded`: declining is a finished run, not a failure.
35
+ * The server records it the way an in-product `[NO_REPLY]` run is recorded and
36
+ * clears the thinking indicator as a "chose silence" rather than implying a
37
+ * reply landed. A server that predates the field ignores it, so it needs no
38
+ * version negotiation.
39
+ */
40
+ export declare const RUN_OUTCOME_SILENT = "silent";
41
+ /**
42
+ * What the agent writes as its WHOLE answer to say "no reply".
43
+ *
44
+ * The same token the in-product brain uses, so an agent's instructions read the
45
+ * same on either runtime. Matched on the trimmed output, case-insensitively —
46
+ * and never posted: the literal token landing in a thread is the one outcome
47
+ * that is worse than either replying or not.
48
+ */
49
+ export declare const NO_REPLY_TOKEN = "[NO_REPLY]";
50
+ /** Whether the agent's output is the no-reply token and nothing else. */
51
+ export declare function isNoReply(output: string): boolean;
31
52
  /** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
32
53
  export declare const MAX_ERROR_CHARS = 1000;
33
54
  /** Server-side cap on the `model` field of a completion (`MAX_MODEL_CHARS`). */
@@ -180,6 +201,8 @@ export declare const MAX_ATTACHMENTS = 10;
180
201
  * quietly stop half-way through with nothing to explain it.
181
202
  */
182
203
  export declare const MAX_SYSTEM_PROMPT_CHARS = 20000;
204
+ /** Ceiling on `trigger.brief` — the server's `MAX_TRIGGER_BRIEF_CHARS`. */
205
+ export declare const MAX_TRIGGER_BRIEF_CHARS = 32000;
183
206
  /** The `agent.run_requested` payload, as far as the harness cares about it. */
184
207
  export interface AgentRunRequest {
185
208
  run_id: string;
@@ -212,6 +235,12 @@ export interface AgentRunRequest {
212
235
  trigger_message_id?: string;
213
236
  /** Capped inline excerpt of the message that woke the agent. */
214
237
  trigger_text?: string;
238
+ /**
239
+ * The server clipped `trigger_text` (`trigger.text_truncated`). Explicit
240
+ * metadata rather than a guess from a trailing ellipsis, which a person can
241
+ * also type. Absent on servers that predate it — read as "not clipped".
242
+ */
243
+ trigger_text_truncated?: boolean;
215
244
  /**
216
245
  * Images and files on the message that woke the agent.
217
246
  *
@@ -220,6 +249,21 @@ export interface AgentRunRequest {
220
249
  */
221
250
  trigger_attachments?: RunAttachment[];
222
251
  trigger_user_id?: string;
252
+ /**
253
+ * The server's work order for this run, when it wrote one: a routine's
254
+ * instructions, a heartbeat's standing assignment, or — on a team-chat
255
+ * participant wakeup — the guidance for a message not addressed to the agent.
256
+ * Absent on older servers and on plain mentions.
257
+ */
258
+ trigger_brief?: string;
259
+ /**
260
+ * `false` when the message was NOT addressed to this agent: it was woken only
261
+ * because it is a member of the thread. Nobody is waiting on it, and the
262
+ * right answer is usually silence. `true` for everything else — including
263
+ * every event from a server that predates the field (parsed events always
264
+ * carry it; absent means `true`).
265
+ */
266
+ reply_expected?: boolean;
223
267
  /** For a trigger fan-out with no surface: the event type that fired it. */
224
268
  trigger_type?: string;
225
269
  /** For a trigger fan-out with no surface: the entity that event was about. */
@@ -28,6 +28,29 @@ export const EVENT_MESSAGE_TEAM_CHAT = 'message.team_chat';
28
28
  /** Terminal statuses accepted by the run-completion endpoint. */
29
29
  export const RUN_STATUS_SUCCEEDED = 'succeeded';
30
30
  export const RUN_STATUS_FAILED = 'failed';
31
+ /**
32
+ * Completion `outcome` for a run that deliberately posted nothing.
33
+ *
34
+ * Sent with `status: succeeded`: declining is a finished run, not a failure.
35
+ * The server records it the way an in-product `[NO_REPLY]` run is recorded and
36
+ * clears the thinking indicator as a "chose silence" rather than implying a
37
+ * reply landed. A server that predates the field ignores it, so it needs no
38
+ * version negotiation.
39
+ */
40
+ export const RUN_OUTCOME_SILENT = 'silent';
41
+ /**
42
+ * What the agent writes as its WHOLE answer to say "no reply".
43
+ *
44
+ * The same token the in-product brain uses, so an agent's instructions read the
45
+ * same on either runtime. Matched on the trimmed output, case-insensitively —
46
+ * and never posted: the literal token landing in a thread is the one outcome
47
+ * that is worse than either replying or not.
48
+ */
49
+ export const NO_REPLY_TOKEN = '[NO_REPLY]';
50
+ /** Whether the agent's output is the no-reply token and nothing else. */
51
+ export function isNoReply(output) {
52
+ return output.trim().toUpperCase() === NO_REPLY_TOKEN;
53
+ }
31
54
  /** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
32
55
  export const MAX_ERROR_CHARS = 1000;
33
56
  /** Server-side cap on the `model` field of a completion (`MAX_MODEL_CHARS`). */
@@ -125,6 +148,8 @@ export const MAX_ATTACHMENTS = 10;
125
148
  * quietly stop half-way through with nothing to explain it.
126
149
  */
127
150
  export const MAX_SYSTEM_PROMPT_CHARS = 20_000;
151
+ /** Ceiling on `trigger.brief` — the server's `MAX_TRIGGER_BRIEF_CHARS`. */
152
+ export const MAX_TRIGGER_BRIEF_CHARS = 32_000;
128
153
  /**
129
154
  * Fallback for a server that predates `reply_instructions.body_field`.
130
155
  *
@@ -229,6 +254,16 @@ function systemPrompt(value) {
229
254
  return undefined;
230
255
  return text.length > MAX_SYSTEM_PROMPT_CHARS ? text.slice(0, MAX_SYSTEM_PROMPT_CHARS) : text;
231
256
  }
257
+ /**
258
+ * Bound the server's work order. Same backstop as {@link systemPrompt}, at the
259
+ * server's own ceiling for the field (`MAX_TRIGGER_BRIEF_CHARS`).
260
+ */
261
+ function brief(value) {
262
+ const text = str(value);
263
+ if (!text)
264
+ return undefined;
265
+ return text.length > MAX_TRIGGER_BRIEF_CHARS ? text.slice(0, MAX_TRIGGER_BRIEF_CHARS) : text;
266
+ }
232
267
  function parseMessage(entry) {
233
268
  const attachments = attachmentList(entry.attachments);
234
269
  // Not `str()`: an image-only message carries an empty string here and is a
@@ -302,8 +337,15 @@ export function parseRunRequest(data) {
302
337
  trigger_source: str(trigger.source) ?? str(data.trigger_source),
303
338
  trigger_message_id: str(trigger.message_id),
304
339
  trigger_text: str(trigger.text_excerpt),
340
+ // Only an explicit `true` counts: anything else is an unclipped message.
341
+ trigger_text_truncated: trigger.text_truncated === true,
305
342
  trigger_attachments: attachmentList(trigger.attachments ?? data.trigger_attachments),
306
343
  trigger_user_id: str(trigger.user_id),
344
+ trigger_brief: brief(trigger.brief),
345
+ // Only an explicit `false` opts out. Absent, null, or anything malformed
346
+ // keeps today's behaviour — an agent that stays quiet when it was asked
347
+ // something is a worse failure than one that answers an aside.
348
+ reply_expected: data.reply_expected !== false,
307
349
  trigger_type: str(trigger.type),
308
350
  trigger_entity_id: str(trigger.entity_id),
309
351
  reply_surface: surface,
@@ -6,6 +6,7 @@
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
+ import { NO_REPLY_TOKEN } from './contract.js';
9
10
  /**
10
11
  * Say the run's budget in a unit a reader thinks in.
11
12
  *
@@ -129,15 +130,23 @@ export function buildPrompt(run, context) {
129
130
  const name = run.agent_name ?? context.agentName;
130
131
  const canReply = Boolean(run.reply_surface !== 'none' && run.reply_path && run.reply_body_key);
131
132
  const title = run.agent_title ? ` (${run.agent_title})` : '';
133
+ // Woken as a bystander: a message landed in a thread the agent belongs to,
134
+ // addressed to somebody else. Telling it "someone is waiting on a reply"
135
+ // here is what made a paired agent post a long, unrequested summary onto a
136
+ // message that @-mentioned three people — so the opening says the opposite.
137
+ const bystander = canReply && run.reply_expected === false;
132
138
  const lines = [
133
139
  `You are "${name}"${title}, an AI teammate in a ProhostAI workspace.`,
134
- canReply && run.reply_surface === 'conversation'
135
- ? 'You were mentioned in a conversation and someone is waiting on a reply.'
136
- : run.reply_surface === 'none'
137
- ? 'You have been given work to do. There is nowhere to reply.'
138
- : canReply
139
- ? `You were mentioned on a ${run.reply_surface} and someone is waiting on a reply.`
140
- : `You have been given work on a ${run.reply_surface}. There is nowhere to post a reply.`,
140
+ bystander
141
+ ? `A new message was posted in a ${run.reply_surface} you are a member of. It was not ` +
142
+ 'addressed to you, and nobody is waiting on a reply from you.'
143
+ : canReply && run.reply_surface === 'conversation'
144
+ ? 'You were mentioned in a conversation and someone is waiting on a reply.'
145
+ : run.reply_surface === 'none'
146
+ ? 'You have been given work to do. There is nowhere to reply.'
147
+ : canReply
148
+ ? `You were mentioned on a ${run.reply_surface} and someone is waiting on a reply.`
149
+ : `You have been given work on a ${run.reply_surface}. There is nowhere to post a reply.`,
141
150
  '',
142
151
  ];
143
152
  // The operator's own configuration comes first, above everything the server
@@ -187,11 +196,34 @@ export function buildPrompt(run, context) {
187
196
  if (run.recent_messages?.length) {
188
197
  lines.push('Conversation so far (oldest first):', ...run.recent_messages.map(renderMessage), '--- end of conversation history ---', '');
189
198
  }
199
+ // The server's own work order, when it wrote one that is not simply the
200
+ // message restated (a run with no message of its own already received the
201
+ // brief as its excerpt).
202
+ if (run.trigger_brief && run.trigger_brief !== run.trigger_text) {
203
+ lines.push('Your instructions for this run, from ProhostAI:', run.trigger_brief, '');
204
+ }
190
205
  const triggerAttachments = run.trigger_attachments ?? [];
191
206
  if (run.trigger_text) {
192
207
  // The server caps this excerpt, so say so rather than let an agent assume
193
208
  // it has the whole message.
194
209
  lines.push('Message that woke you (may be truncated by the server):', run.trigger_text, '');
210
+ // An agent that is only told the text "may be" truncated answers the
211
+ // fragment — or asks the sender to repost — when the whole message is one
212
+ // tool call away. Keyed on the server's explicit flag, never on a trailing
213
+ // ellipsis a person could have typed.
214
+ if (run.trigger_text_truncated) {
215
+ lines.push(...(context.mcpServerName && run.conversation_id
216
+ ? [
217
+ 'ProhostAI cut that message short. Before answering, read it in full with',
218
+ `the ProhostAI get_conversation_messages tool (conversation ${run.conversation_id})`,
219
+ 'and answer the whole message, not the excerpt above.',
220
+ ]
221
+ : [
222
+ 'ProhostAI cut that message short, and you have no tool to read the rest.',
223
+ 'Answer what you can see and say plainly that the message reached you',
224
+ 'truncated.',
225
+ ]), '');
226
+ }
195
227
  }
196
228
  else if (triggerAttachments.length > 0) {
197
229
  // Distinct from "the server didn't send the text": here the message really
@@ -305,6 +337,13 @@ export function buildPrompt(run, context) {
305
337
  // Postability is a separate question from what the run is about: a task run
306
338
  // has no comment route in the external API yet, so the agent must be told its
307
339
  // output won't be posted rather than left to assume it will.
340
+ if (bystander) {
341
+ // Silence is stated as the default and given a concrete spelling, because
342
+ // "reply only if useful" without a way to NOT reply still ends in stdout
343
+ // that gets posted.
344
+ lines.push('Staying silent is the expected outcome here. Reply only if you can add', 'something clearly distinct that nobody else in the thread was asked for. If', `you decide not to reply, output ONLY the token ${NO_REPLY_TOKEN} — nothing is`, 'posted and the run is recorded as a deliberate silence. Never write a message', 'explaining that you are staying quiet.', 'If you do reply, write the message text only — no preamble, no explanation of', `your reasoning, no surrounding quotes. Anything other than ${NO_REPLY_TOKEN} is`, `posted verbatim as your reply on the ${run.reply_surface}.`);
345
+ return `${lines.join('\n')}\n`;
346
+ }
308
347
  lines.push(canReply
309
348
  ? 'Write your reply to that message. Reply with the message text only — no'
310
349
  : 'Summarize what you did, in one short message. Output the message only — no', 'preamble, no explanation of your reasoning, no surrounding quotes. Your', canReply
package/dist/agent/run.js CHANGED
@@ -33,7 +33,7 @@ import { APPS_FEATURE, MCP_API_KEY_ENV_VAR, PLUGINS_DISABLED_OVERRIDE, codexBina
33
33
  import { ToolEventQueue } from './events.js';
34
34
  import { EXTRA_MCP_FILENAME, loadExtraMcpServers } from './extras.js';
35
35
  import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
36
- import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, RUN_ERROR_CANCELLED_BY_USER, eventData, heartbeatPathFor, parseRunRequest, } from './contract.js';
36
+ import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, RUN_ERROR_CANCELLED_BY_USER, eventData, heartbeatPathFor, isNoReply, parseRunRequest, } from './contract.js';
37
37
  import { ensureWorkspace, streamUrlFor, workspacePath } from './credentials.js';
38
38
  import { execAgent } from './exec.js';
39
39
  import { HeartbeatLoop } from './heartbeat.js';
@@ -927,6 +927,23 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
927
927
  log(` [dry-run] nothing was sent and the run was left open`);
928
928
  return 'skipped';
929
929
  }
930
+ // The agent decided not to reply — the usual answer when it was woken as a
931
+ // bystander (`reply_expected: false`). A finished run, not a failure: nothing
932
+ // is posted, and the completion says so, so the server records a deliberate
933
+ // silence and clears the thinking indicator as one. Checked on every run, not
934
+ // only bystander ones: the literal token must never reach a thread.
935
+ if (isNoReply(reply)) {
936
+ log(`${timestamp()} ✓ ${label} chose not to reply (${seconds}s)`);
937
+ const completed = await completeWithRetry(api, run.completion_path, { ok: true, silent: true }, log, sleep, runtime.model());
938
+ if (!completed.ok) {
939
+ log(` ! could not mark the run complete (${describeFailure(completed)})`);
940
+ return 'unreported';
941
+ }
942
+ if (result.sessionId) {
943
+ runtime.sessions.remember(sessionKeyFor(run), result.sessionId);
944
+ }
945
+ return 'succeeded';
946
+ }
930
947
  // A run with no reply surface is a legitimate outcome, not an error: it was
931
948
  // started by a trigger rather than by someone addressing the agent, so there
932
949
  // is nowhere to post. Complete it as succeeded so any thinking indicator
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.7.0";
10
+ export declare const CLI_VERSION = "0.8.1";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.7.0";
12
+ export declare const USER_AGENT = "prohost-cli/0.8.1";
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.7.0';
10
+ export const CLI_VERSION = '0.8.1';
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.7.0",
3
+ "version": "0.8.1",
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",