@prohost/cli 0.6.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.
@@ -14,9 +14,11 @@
14
14
  * session is unresumable, if the JSON is not what we expect — the run still
15
15
  * happens and still completes. These are enhancements that must fail soft.
16
16
  */
17
+ import type { ToolEvent } from './contract.js';
17
18
  import { MCP_SERVER_NAME } from './mcp.js';
18
19
  import type { RuntimeObservation } from './runtime_status.js';
19
20
  export { MCP_SERVER_NAME };
21
+ export type { ToolEvent };
20
22
  /**
21
23
  * What goes to `--allowedTools` so the ProhostAI tools are callable.
22
24
  *
@@ -126,6 +128,13 @@ export declare function parseClaudeOutput(stdout: string): ClaudeOutput;
126
128
  export declare const MAX_ACTIVITY_CHARS = 120;
127
129
  /** Cap on the partial answer salvaged from a run that was killed. */
128
130
  export declare const MAX_SALVAGE_CHARS = 500;
131
+ /**
132
+ * Cap on the one-line previews a tool event carries — a headline argument,
133
+ * the head of a result. The server clips again and redacts before showing
134
+ * anything to a person; this bound just keeps a `Read` of a large file from
135
+ * putting the file on the wire.
136
+ */
137
+ export declare const MAX_TOOL_EVENT_PREVIEW_CHARS = 200;
129
138
  /**
130
139
  * A run's stdout, read as it is produced.
131
140
  *
@@ -152,6 +161,16 @@ export interface ClaudeStream {
152
161
  activity(): string | undefined;
153
162
  /** The tail of the last thing the agent wrote, for a run that was killed. */
154
163
  salvage(): string | undefined;
164
+ /**
165
+ * Tool-call starts and ends seen since the last drain, in stream order.
166
+ *
167
+ * Each `tool_use` block becomes a `calling` event and each `tool_result`
168
+ * block the matching `completed` / `failed`, correlated by Claude Code's
169
+ * own `tool_use.id`. Taken, not copied: a second call returns only what
170
+ * arrived in between. Empty for a stream with no tool calls, and for one
171
+ * that isn't Claude Code's at all.
172
+ */
173
+ drainToolEvents(): ToolEvent[];
155
174
  /**
156
175
  * Model, Claude Code version and capacity windows the stream announced —
157
176
  * `system/init.{model, claude_code_version}` and the last
@@ -165,6 +184,22 @@ export interface ClaudeStream {
165
184
  */
166
185
  finish(stdout: string): ClaudeOutput;
167
186
  }
187
+ /**
188
+ * What a tool was called with, as a small JSON object the server can render.
189
+ *
190
+ * For the tools whose input has an obvious headline — the command, the path,
191
+ * the pattern, the URL — that field alone; the rest of the input (a `Write`'s
192
+ * whole file content, an `Edit`'s old and new strings) stays on this machine.
193
+ * Anything else is a shallow projection of the input: the first few keys,
194
+ * scalar values capped, lists and objects reduced to their shape — which for
195
+ * an MCP tool is a handful of ids and short strings.
196
+ *
197
+ * Always a JSON **object** string, never bare text: the run-detail page
198
+ * parses the preview as JSON and renders it `key=value` through a key-aware
199
+ * redactor (so a `token` key is masked outright), and a preview it cannot
200
+ * parse is shown as nothing at all.
201
+ */
202
+ export declare function toolArgsPreview(name: string, input: unknown): string | undefined;
168
203
  /**
169
204
  * Parse `--output-format stream-json` incrementally.
170
205
  *
@@ -269,6 +269,21 @@ export function parseClaudeOutput(stdout) {
269
269
  export const MAX_ACTIVITY_CHARS = 120;
270
270
  /** Cap on the partial answer salvaged from a run that was killed. */
271
271
  export const MAX_SALVAGE_CHARS = 500;
272
+ /**
273
+ * Cap on the one-line previews a tool event carries — a headline argument,
274
+ * the head of a result. The server clips again and redacts before showing
275
+ * anything to a person; this bound just keeps a `Read` of a large file from
276
+ * putting the file on the wire.
277
+ */
278
+ export const MAX_TOOL_EVENT_PREVIEW_CHARS = 200;
279
+ /**
280
+ * Cap on tool events held between drains.
281
+ *
282
+ * The reader is drained on every stdout chunk, so this only ever fills if
283
+ * nobody is draining — a caller that doesn't report events at all. Oldest
284
+ * dropped, so memory stays bounded for the whole of a long run.
285
+ */
286
+ const MAX_PENDING_TOOL_EVENTS = 500;
272
287
  /**
273
288
  * Cap on a single buffered stdout line.
274
289
  *
@@ -301,6 +316,116 @@ function assistantContent(event) {
301
316
  }
302
317
  return { text: latestText, tool: latestTool };
303
318
  }
319
+ /** Whitespace-collapsed, trimmed, capped — or nothing, for nothing. */
320
+ function preview(text) {
321
+ const collapsed = text?.replace(/\s+/g, ' ').trim();
322
+ if (!collapsed)
323
+ return undefined;
324
+ return collapsed.length > MAX_TOOL_EVENT_PREVIEW_CHARS
325
+ ? collapsed.slice(0, MAX_TOOL_EVENT_PREVIEW_CHARS)
326
+ : collapsed;
327
+ }
328
+ /**
329
+ * Cap on one value inside a JSON args preview, and on how many keys travel.
330
+ * The server's key-aware redactor reads the preview as an object, so the
331
+ * shape must survive intact: capping values (not the JSON) is what keeps a
332
+ * large MCP argument from truncating the object mid-key into something the
333
+ * reader can no longer parse.
334
+ */
335
+ const MAX_PREVIEW_VALUE_CHARS = 120;
336
+ const MAX_PREVIEW_KEYS = 6;
337
+ /**
338
+ * What a tool was called with, as a small JSON object the server can render.
339
+ *
340
+ * For the tools whose input has an obvious headline — the command, the path,
341
+ * the pattern, the URL — that field alone; the rest of the input (a `Write`'s
342
+ * whole file content, an `Edit`'s old and new strings) stays on this machine.
343
+ * Anything else is a shallow projection of the input: the first few keys,
344
+ * scalar values capped, lists and objects reduced to their shape — which for
345
+ * an MCP tool is a handful of ids and short strings.
346
+ *
347
+ * Always a JSON **object** string, never bare text: the run-detail page
348
+ * parses the preview as JSON and renders it `key=value` through a key-aware
349
+ * redactor (so a `token` key is masked outright), and a preview it cannot
350
+ * parse is shown as nothing at all.
351
+ */
352
+ export function toolArgsPreview(name, input) {
353
+ if (!input || typeof input !== 'object' || Array.isArray(input))
354
+ return undefined;
355
+ const args = input;
356
+ const headline = HEADLINE_FIELDS[name];
357
+ if (headline) {
358
+ for (const key of headline) {
359
+ const value = args[key];
360
+ const text = typeof value === 'string' ? preview(value) : undefined;
361
+ if (text)
362
+ return JSON.stringify({ [key]: text });
363
+ }
364
+ // A headline-only tool with no headline shows nothing rather than its
365
+ // whole input — that input is the file content this field exists to keep
366
+ // off the wire.
367
+ return undefined;
368
+ }
369
+ const projected = {};
370
+ for (const key of Object.keys(args).slice(0, MAX_PREVIEW_KEYS)) {
371
+ const value = args[key];
372
+ if (typeof value === 'string') {
373
+ const collapsed = value.replace(/\s+/g, ' ').trim();
374
+ projected[key] =
375
+ collapsed.length > MAX_PREVIEW_VALUE_CHARS ? collapsed.slice(0, MAX_PREVIEW_VALUE_CHARS) : collapsed;
376
+ }
377
+ else if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
378
+ projected[key] = value;
379
+ }
380
+ else if (Array.isArray(value)) {
381
+ projected[key] = `[${value.length} items]`;
382
+ }
383
+ else if (value && typeof value === 'object') {
384
+ projected[key] = `{${Object.keys(value).length} keys}`;
385
+ }
386
+ }
387
+ try {
388
+ return JSON.stringify(projected);
389
+ }
390
+ catch {
391
+ return undefined;
392
+ }
393
+ }
394
+ /**
395
+ * The field that IS the call, per tool, in order of preference. Listed tools
396
+ * never fall through to the projection above.
397
+ */
398
+ const HEADLINE_FIELDS = {
399
+ Bash: ['command'],
400
+ shell: ['command'],
401
+ Read: ['file_path'],
402
+ Edit: ['file_path'],
403
+ MultiEdit: ['file_path'],
404
+ Write: ['file_path'],
405
+ NotebookEdit: ['notebook_path', 'file_path'],
406
+ Grep: ['pattern'],
407
+ Glob: ['pattern'],
408
+ WebFetch: ['url'],
409
+ WebSearch: ['query'],
410
+ Task: ['description'],
411
+ Agent: ['description'],
412
+ };
413
+ /** The text of a `tool_result` block — a string, or its `text` parts joined. */
414
+ function toolResultText(content) {
415
+ if (typeof content === 'string')
416
+ return content;
417
+ if (!Array.isArray(content))
418
+ return undefined;
419
+ const parts = [];
420
+ for (const block of content) {
421
+ if (!block || typeof block !== 'object')
422
+ continue;
423
+ const entry = block;
424
+ if (entry.type === 'text' && typeof entry.text === 'string')
425
+ parts.push(entry.text);
426
+ }
427
+ return parts.length > 0 ? parts.join(' ') : undefined;
428
+ }
304
429
  /**
305
430
  * Parse `--output-format stream-json` incrementally.
306
431
  *
@@ -316,6 +441,74 @@ export function readClaudeStream() {
316
441
  let lastActivity;
317
442
  let lastText;
318
443
  let result;
444
+ let toolEvents = [];
445
+ // `tool_use.id` → tool name, so a result can be reported under the name of
446
+ // the call it answers. Deleted on the result: bounded by tools in flight.
447
+ const toolNames = new Map();
448
+ const enqueue = (event) => {
449
+ toolEvents.push(event);
450
+ if (toolEvents.length > MAX_PENDING_TOOL_EVENTS)
451
+ toolEvents.shift();
452
+ };
453
+ /** Every `tool_use` block in an assistant message → a `calling` event. */
454
+ const collectToolUses = (event) => {
455
+ const message = event.message;
456
+ if (!message || typeof message !== 'object')
457
+ return;
458
+ const content = message.content;
459
+ if (!Array.isArray(content))
460
+ return;
461
+ for (const block of content) {
462
+ if (!block || typeof block !== 'object')
463
+ continue;
464
+ const entry = block;
465
+ if (entry.type !== 'tool_use')
466
+ continue;
467
+ const id = text(entry.id);
468
+ const name = text(entry.name);
469
+ if (!id || !name)
470
+ continue;
471
+ toolNames.set(id, name);
472
+ const args = toolArgsPreview(name, entry.input);
473
+ enqueue({
474
+ tool_id: id,
475
+ tool_name: name,
476
+ status: 'calling',
477
+ started_at: new Date().toISOString(),
478
+ ...(args ? { args_preview: args } : {}),
479
+ });
480
+ }
481
+ };
482
+ /** Every `tool_result` block in a user message → the matching terminal event. */
483
+ const collectToolResults = (event) => {
484
+ const message = event.message;
485
+ if (!message || typeof message !== 'object')
486
+ return;
487
+ const content = message.content;
488
+ if (!Array.isArray(content))
489
+ return;
490
+ for (const block of content) {
491
+ if (!block || typeof block !== 'object')
492
+ continue;
493
+ const entry = block;
494
+ if (entry.type !== 'tool_result')
495
+ continue;
496
+ const id = text(entry.tool_use_id);
497
+ if (!id)
498
+ continue;
499
+ const name = toolNames.get(id) ?? 'unknown';
500
+ toolNames.delete(id);
501
+ const summary = preview(toolResultText(entry.content));
502
+ const failed = entry.is_error === true;
503
+ enqueue({
504
+ tool_id: id,
505
+ tool_name: name,
506
+ status: failed ? 'failed' : 'completed',
507
+ ended_at: new Date().toISOString(),
508
+ ...(summary ? (failed ? { error_summary: summary } : { result_summary: summary }) : {}),
509
+ });
510
+ }
511
+ };
319
512
  const observed = {};
320
513
  const handle = (line) => {
321
514
  const event = asObject(line);
@@ -339,8 +532,15 @@ export function readClaudeStream() {
339
532
  result = event;
340
533
  return;
341
534
  }
535
+ if (event.type === 'user') {
536
+ // The harness's own prompt arrives as a `user` event too; it carries no
537
+ // `tool_result` blocks and falls straight through.
538
+ collectToolResults(event);
539
+ return;
540
+ }
342
541
  if (event.type !== 'assistant')
343
542
  return;
543
+ collectToolUses(event);
344
544
  const { text: written, tool } = assistantContent(event);
345
545
  if (written)
346
546
  lastText = written;
@@ -371,6 +571,11 @@ export function readClaudeStream() {
371
571
  },
372
572
  sessionId: () => session,
373
573
  activity: () => lastActivity,
574
+ drainToolEvents: () => {
575
+ const drained = toolEvents;
576
+ toolEvents = [];
577
+ return drained;
578
+ },
374
579
  observed: () => ({ ...observed }),
375
580
  salvage: () => {
376
581
  const trimmed = lastText?.trim();
@@ -29,6 +29,23 @@ export declare const INVALID_DURATION: unique symbol;
29
29
  * had not.
30
30
  */
31
31
  export declare function parseSeconds(raw: string | boolean | undefined): number | undefined | typeof INVALID_DURATION;
32
+ /**
33
+ * Environment the daemon must be given explicitly.
34
+ *
35
+ * launchd starts a job with almost nothing — notably a minimal `PATH` of
36
+ * `/usr/bin:/bin:/usr/sbin:/sbin`, which contains no `claude`. Installed from
37
+ * npm, Homebrew, or the official installer it lives in `~/.local/bin`,
38
+ * `/opt/homebrew/bin`, or `/usr/local/bin`, so without this the daemon
39
+ * installs cleanly, reports success, and then crash-loops on
40
+ * `claude: command not found` in a log nobody is watching.
41
+ *
42
+ * The `PATH` captured is the one in effect when the operator ran
43
+ * `install-daemon` — i.e. the one in which their `--exec` command actually
44
+ * works. `PROHOST_HOME` is pinned for the same reason it is used for the label:
45
+ * a daemon must keep serving the home it was installed from, whatever the
46
+ * environment of a later login happens to say.
47
+ */
48
+ export declare function daemonEnvironment(): Record<string, string>;
32
49
  /** Dispatch an ``agent`` subcommand. Returns the process exit code. */
33
- export declare function runAgent(subcommand: string | undefined, flags: Flags, printHelp: () => void): Promise<number>;
50
+ export declare function runAgent(subcommand: string | undefined, flags: Flags, printHelp: () => void, args?: string[]): Promise<number>;
34
51
  export {};
@@ -10,8 +10,11 @@ import path from 'node:path';
10
10
  import process from 'node:process';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { agentLogPath, buildSystemdUnit, harnessArguments, installLaunchdDaemon, launchdLabel, uninstallLaunchdDaemon, } from './daemon.js';
13
+ import { accountCommand, listCommand, workspaceCommand } from './agent_commands.js';
14
+ import { AccountError, loadAccounts, setAgentAccount } from './accounts.js';
15
+ import { acquireRunLock } from './lock.js';
13
16
  import { PairError, runPair } from './pair.js';
14
- import { CredentialsMissingError, ensureWorkspace, loadCredentials, prohostHome } from './credentials.js';
17
+ import { CredentialsMissingError, ensureWorkspace, loadCredentials, prohostHome, tryLoadCredentials, } from './credentials.js';
15
18
  import { DEFAULT_EXEC_TIMEOUT_MS, DEFAULT_IDLE_TIMEOUT_MS, runAgentHarness } from './run.js';
16
19
  import { announceUpgradeIfAvailable } from '../upgrade.js';
17
20
  function flagString(flags, name) {
@@ -132,10 +135,14 @@ function parseIdleTimeoutSeconds(flags) {
132
135
  * a daemon must keep serving the home it was installed from, whatever the
133
136
  * environment of a later login happens to say.
134
137
  */
135
- function daemonEnvironment() {
138
+ export function daemonEnvironment() {
136
139
  const env = { PROHOST_HOME: prohostHome() };
137
140
  if (process.env.PATH)
138
141
  env.PATH = process.env.PATH;
142
+ // A registry moved with $PROHOST_ACCOUNTS_ROOT must be the one the daemon
143
+ // reads, or it can't find the account it was installed with.
144
+ if (process.env.PROHOST_ACCOUNTS_ROOT)
145
+ env.PROHOST_ACCOUNTS_ROOT = process.env.PROHOST_ACCOUNTS_ROOT;
139
146
  return env;
140
147
  }
141
148
  /** Absolute path of this CLI's entrypoint, for a daemon to exec. */
@@ -147,7 +154,7 @@ function cliEntrypoint() {
147
154
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'index.js');
148
155
  }
149
156
  /** Dispatch an ``agent`` subcommand. Returns the process exit code. */
150
- export async function runAgent(subcommand, flags, printHelp) {
157
+ export async function runAgent(subcommand, flags, printHelp, args = []) {
151
158
  if (subcommand === 'pair')
152
159
  return pairCommand(flags);
153
160
  if (subcommand === 'run')
@@ -156,9 +163,16 @@ export async function runAgent(subcommand, flags, printHelp) {
156
163
  return installDaemonCommand(flags);
157
164
  if (subcommand === 'uninstall-daemon')
158
165
  return uninstallDaemonCommand();
166
+ if (subcommand === 'account')
167
+ return accountCommand(args, flags);
168
+ if (subcommand === 'list')
169
+ return listCommand();
170
+ if (subcommand === 'workspace')
171
+ return workspaceCommand(args, flags);
159
172
  process.stderr.write(subcommand
160
173
  ? `Unknown agent subcommand: ${subcommand}\n\n`
161
- : 'Missing agent subcommand — expected `pair`, `run`, `install-daemon`, or `uninstall-daemon`.\n\n');
174
+ : 'Missing agent subcommand — expected `pair`, `run`, `install-daemon`, `uninstall-daemon`, ' +
175
+ '`account`, `list`, or `workspace`.\n\n');
162
176
  printHelp();
163
177
  return 1;
164
178
  }
@@ -184,6 +198,8 @@ async function installDaemonCommand(flags) {
184
198
  const credentials = loadOrExplain();
185
199
  if (!credentials)
186
200
  return 1;
201
+ if (!applyAccountFlag(flags))
202
+ return 1;
187
203
  // Validated here and not only in `agent run`, because the consequence differs:
188
204
  // a bad --timeout typed at a terminal prints an error, while one baked into a
189
205
  // plist makes every launch exit on that same validation and KeepAlive restart
@@ -261,6 +277,16 @@ async function pairCommand(flags) {
261
277
  process.stderr.write('Missing pairing code. Pass --code or set $PROHOST_PAIRING_CODE.\n');
262
278
  return 1;
263
279
  }
280
+ // Checked before the code is spent: a typo here must not cost a pairing.
281
+ const account = flagString(flags, 'account');
282
+ if (flags.account === true) {
283
+ process.stderr.write('--account needs a label, e.g. --account work\n');
284
+ return 1;
285
+ }
286
+ // A re-pair keeps the account the home already runs on unless told otherwise.
287
+ const previousAccount = tryLoadCredentials()?.account;
288
+ if (account && !accountExists(account))
289
+ return 1;
264
290
  try {
265
291
  await runPair({
266
292
  code,
@@ -268,6 +294,19 @@ async function pairCommand(flags) {
268
294
  wsUrl: flagString(flags, 'url') ?? process.env.PROHOST_WS_URL,
269
295
  webhookUrl: flagString(flags, 'webhook-url'),
270
296
  });
297
+ const chosen = account ?? previousAccount;
298
+ if (chosen) {
299
+ try {
300
+ setAgentAccount(chosen, undefined);
301
+ process.stdout.write(` Account ${chosen}\n`);
302
+ }
303
+ catch (err) {
304
+ // Paired either way; only the carried-over account is gone.
305
+ if (!(err instanceof AccountError))
306
+ throw err;
307
+ process.stderr.write(`! ${err.message} This agent will use the default login.\n`);
308
+ }
309
+ }
271
310
  return 0;
272
311
  }
273
312
  catch (err) {
@@ -293,6 +332,14 @@ async function runCommand(flags) {
293
332
  const parsedIdleTimeout = parseIdleTimeoutSeconds(flags);
294
333
  if (parsedIdleTimeout === INVALID_DURATION)
295
334
  return 1;
335
+ // One harness per home: two would both execute every run.
336
+ const lock = acquireRunLock();
337
+ if (!lock.ok) {
338
+ process.stderr.write(`✗ Another \`prohost agent run\` (pid ${lock.holder.pid}, started ${lock.holder.started_at || 'at an unknown time'}) ` +
339
+ `is already serving ${prohostHome()}. Stop it first — or, if it is the daemon, use ` +
340
+ '`prohost agent uninstall-daemon` — or give this agent its own $PROHOST_HOME.\n');
341
+ return 1;
342
+ }
296
343
  // First signal stops accepting work and lets the in-flight run finish
297
344
  // reporting itself (so the server doesn't wait on a run we abandoned); a
298
345
  // second one means the operator wants out now.
@@ -335,5 +382,42 @@ async function runCommand(flags) {
335
382
  finally {
336
383
  process.off('SIGINT', onSig);
337
384
  process.off('SIGTERM', onSig);
385
+ lock.release();
386
+ }
387
+ }
388
+ /** Whether an account label exists on this machine (any runtime), explaining if not. */
389
+ function accountExists(label) {
390
+ if (label === 'default')
391
+ return true;
392
+ try {
393
+ // Validates only; the write happens once pairing has succeeded.
394
+ if (loadAccounts().some((a) => a.label === label))
395
+ return true;
396
+ }
397
+ catch {
398
+ /* fall through */
399
+ }
400
+ process.stderr.write(`✗ No account named "${label}" on this machine. Add it first: prohost agent account add ${label} --runtime claude|codex\n`);
401
+ return false;
402
+ }
403
+ /** `--account <label>` on install-daemon: persist it to agent.json. */
404
+ function applyAccountFlag(flags) {
405
+ if (flags.account === undefined)
406
+ return true;
407
+ const label = flagString(flags, 'account');
408
+ if (!label) {
409
+ process.stderr.write('--account needs a label, e.g. --account work\n');
410
+ return false;
411
+ }
412
+ try {
413
+ setAgentAccount(label, undefined);
414
+ return true;
415
+ }
416
+ catch (err) {
417
+ if (err instanceof AccountError) {
418
+ process.stderr.write(`✗ ${err.message}\n`);
419
+ return false;
420
+ }
421
+ throw err;
338
422
  }
339
423
  }
@@ -14,6 +14,7 @@
14
14
  * - payload shape — `shared/events/payloads/agent_runs.py`
15
15
  * - `POST /v1/agent-runs/{id}/complete` — `api_public/routers/agent_runs.py`
16
16
  * - `POST /v1/agent-runs/{id}/heartbeat` — `api_public/routers/agent_runs.py`
17
+ * - `POST /v1/agent-runs/{id}/events` — `api_public/routers/agent_runs.py`
17
18
  * - `POST /v1/agent-pairing/redeem` — `api_public/routers/agent_pairing.py`
18
19
  * - `POST /v1/conversations/{id}/messages` — `api_public/routers/conversations.py`
19
20
  */
@@ -27,6 +28,27 @@ export declare const EVENT_MESSAGE_TEAM_CHAT = "message.team_chat";
27
28
  /** Terminal statuses accepted by the run-completion endpoint. */
28
29
  export declare const RUN_STATUS_SUCCEEDED = "succeeded";
29
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;
30
52
  /** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
31
53
  export declare const MAX_ERROR_CHARS = 1000;
32
54
  /** Server-side cap on the `model` field of a completion (`MAX_MODEL_CHARS`). */
@@ -48,6 +70,37 @@ export declare const RUN_ERROR_CANCELLED_BY_USER = "cancelled_by_user";
48
70
  * enormous block of text can't turn a keep-alive into a payload.
49
71
  */
50
72
  export declare const MAX_PROGRESS_CHARS = 200;
73
+ /**
74
+ * Server-side cap on events per `POST /v1/agent-runs/{id}/events` call
75
+ * (`shared.agents.external_tool_events.MAX_EVENTS_PER_REQUEST`). The run event
76
+ * also carries it as `events.max_batch`; this is the value for a payload that
77
+ * omits it, and the ceiling either way.
78
+ */
79
+ export declare const MAX_TOOL_EVENTS_PER_BATCH = 50;
80
+ /**
81
+ * One tool-call lifecycle event, as `POST /v1/agent-runs/{id}/events` takes it.
82
+ *
83
+ * `tool_id` is the runtime's own id for the invocation (Claude Code's
84
+ * `tool_use.id`); the server keeps one timeline row per id, opened by a
85
+ * `calling` and settled by a `completed` / `failed`, and ignores anything that
86
+ * would move a row backwards — which is what makes re-sending a batch safe.
87
+ * Everything else is optional and clipped server-side.
88
+ */
89
+ export interface ToolEvent {
90
+ tool_id: string;
91
+ tool_name: string;
92
+ status: 'calling' | 'completed' | 'failed';
93
+ /** ISO 8601. */
94
+ started_at?: string;
95
+ /** ISO 8601. */
96
+ ended_at?: string;
97
+ /** The command, the path, the query — one short line. */
98
+ args_preview?: string;
99
+ /** One short line of what came back. */
100
+ result_summary?: string;
101
+ /** Why it failed, on `failed` only. */
102
+ error_summary?: string;
103
+ }
51
104
  /** `POST` — exchange a one-time pairing code for a scoped credential. */
52
105
  export declare const REDEEM_PATH = "/v1/agent-pairing/redeem";
53
106
  /** `POST` — send a message into a conversation as the paired agent. */
@@ -148,6 +201,8 @@ export declare const MAX_ATTACHMENTS = 10;
148
201
  * quietly stop half-way through with nothing to explain it.
149
202
  */
150
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;
151
206
  /** The `agent.run_requested` payload, as far as the harness cares about it. */
152
207
  export interface AgentRunRequest {
153
208
  run_id: string;
@@ -188,6 +243,21 @@ export interface AgentRunRequest {
188
243
  */
189
244
  trigger_attachments?: RunAttachment[];
190
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;
191
261
  /** For a trigger fan-out with no surface: the event type that fired it. */
192
262
  trigger_type?: string;
193
263
  /** For a trigger fan-out with no surface: the entity that event was about. */
@@ -231,6 +301,15 @@ export interface AgentRunRequest {
231
301
  * for what happens then.
232
302
  */
233
303
  heartbeat_path?: string;
304
+ /**
305
+ * Server-supplied path to report tool events at.
306
+ *
307
+ * Absent on every server built before the `events` block was added beside
308
+ * `heartbeat`. Unlike the heartbeat there is no fallback: with no path the
309
+ * events are simply not sent, and the run behaves exactly as it did before —
310
+ * the heartbeat line is the only narration.
311
+ */
312
+ events_path?: string;
234
313
  /**
235
314
  * Earlier messages on this surface, oldest first. Absent on a server that
236
315
  * predates the field — which is the case the harness shipped with, so an