@prohost/cli 0.7.0 → 0.8.0

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,27 @@ 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.0
8
+
9
+ A paired agent woken as a bystander can now stay silent.
10
+
11
+ - **`reply_expected: false`.** When a team-chat message lands in a thread the
12
+ agent belongs to but is addressed to someone else (ProhostAI's participation
13
+ walk), the run event now says so. The prompt tells the agent the message was
14
+ not addressed to it and nobody is waiting on it — instead of "someone is
15
+ waiting on a reply" — and renders the server's `trigger.brief`, the same
16
+ bystander guidance ProhostAI's in-product agents get. Absent (every older
17
+ server) keeps today's prompt.
18
+ - **`[NO_REPLY]`.** An agent whose whole answer is `[NO_REPLY]` (any case,
19
+ surrounding whitespace ignored) posts nothing, and the run is completed
20
+ `{status: "succeeded", outcome: "silent"}` so ProhostAI records a deliberate
21
+ silence and clears the thinking indicator as one. Checked on every run, so
22
+ the literal token can never reach a thread. A server that predates `outcome`
23
+ ignores it and records a plain success.
24
+ - **`trigger.brief` is rendered** whenever the server sends one that is not just
25
+ the message restated — a work order used to be dropped on any run that also
26
+ had a message.
27
+
7
28
  ## 0.7.0
8
29
 
9
30
  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;
@@ -220,6 +243,21 @@ export interface AgentRunRequest {
220
243
  */
221
244
  trigger_attachments?: RunAttachment[];
222
245
  trigger_user_id?: string;
246
+ /**
247
+ * The server's work order for this run, when it wrote one: a routine's
248
+ * instructions, a heartbeat's standing assignment, or — on a team-chat
249
+ * participant wakeup — the guidance for a message not addressed to the agent.
250
+ * Absent on older servers and on plain mentions.
251
+ */
252
+ trigger_brief?: string;
253
+ /**
254
+ * `false` when the message was NOT addressed to this agent: it was woken only
255
+ * because it is a member of the thread. Nobody is waiting on it, and the
256
+ * right answer is usually silence. `true` for everything else — including
257
+ * every event from a server that predates the field (parsed events always
258
+ * carry it; absent means `true`).
259
+ */
260
+ reply_expected?: boolean;
223
261
  /** For a trigger fan-out with no surface: the event type that fired it. */
224
262
  trigger_type?: string;
225
263
  /** 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
@@ -304,6 +339,11 @@ export function parseRunRequest(data) {
304
339
  trigger_text: str(trigger.text_excerpt),
305
340
  trigger_attachments: attachmentList(trigger.attachments ?? data.trigger_attachments),
306
341
  trigger_user_id: str(trigger.user_id),
342
+ trigger_brief: brief(trigger.brief),
343
+ // Only an explicit `false` opts out. Absent, null, or anything malformed
344
+ // keeps today's behaviour — an agent that stays quiet when it was asked
345
+ // something is a worse failure than one that answers an aside.
346
+ reply_expected: data.reply_expected !== false,
307
347
  trigger_type: str(trigger.type),
308
348
  trigger_entity_id: str(trigger.entity_id),
309
349
  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,6 +196,12 @@ 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
@@ -305,6 +320,13 @@ export function buildPrompt(run, context) {
305
320
  // Postability is a separate question from what the run is about: a task run
306
321
  // has no comment route in the external API yet, so the agent must be told its
307
322
  // output won't be posted rather than left to assume it will.
323
+ if (bystander) {
324
+ // Silence is stated as the default and given a concrete spelling, because
325
+ // "reply only if useful" without a way to NOT reply still ends in stdout
326
+ // that gets posted.
327
+ 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}.`);
328
+ return `${lines.join('\n')}\n`;
329
+ }
308
330
  lines.push(canReply
309
331
  ? 'Write your reply to that message. Reply with the message text only — no'
310
332
  : '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.0";
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.0";
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.0';
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.0",
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",