@prohost/cli 0.5.0 → 0.7.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,8 +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';
19
+ import type { RuntimeObservation } from './runtime_status.js';
18
20
  export { MCP_SERVER_NAME };
21
+ export type { ToolEvent };
19
22
  /**
20
23
  * What goes to `--allowedTools` so the ProhostAI tools are callable.
21
24
  *
@@ -125,6 +128,13 @@ export declare function parseClaudeOutput(stdout: string): ClaudeOutput;
125
128
  export declare const MAX_ACTIVITY_CHARS = 120;
126
129
  /** Cap on the partial answer salvaged from a run that was killed. */
127
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;
128
138
  /**
129
139
  * A run's stdout, read as it is produced.
130
140
  *
@@ -151,6 +161,22 @@ export interface ClaudeStream {
151
161
  activity(): string | undefined;
152
162
  /** The tail of the last thing the agent wrote, for a run that was killed. */
153
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[];
174
+ /**
175
+ * Model, Claude Code version and capacity windows the stream announced —
176
+ * `system/init.{model, claude_code_version}` and the last
177
+ * `rate_limit_event`. Fields the stream never mentioned are absent.
178
+ */
179
+ observed(): RuntimeObservation;
154
180
  /**
155
181
  * The turn's outcome. ``stdout`` is the complete captured output, used only
156
182
  * when the stream held no `result` event — a crashed binary, a login prompt,
@@ -158,6 +184,22 @@ export interface ClaudeStream {
158
184
  */
159
185
  finish(stdout: string): ClaudeOutput;
160
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;
161
203
  /**
162
204
  * Parse `--output-format stream-json` incrementally.
163
205
  *
@@ -18,6 +18,7 @@ import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import { prohostHome } from './credentials.js';
20
20
  import { MCP_SERVER_NAME } from './mcp.js';
21
+ import { fromClaudeRateLimitInfo } from './runtime_status.js';
21
22
  import { SHELL_CONTROL_OPERATORS, shellQuote } from './shell.js';
22
23
  export { MCP_SERVER_NAME };
23
24
  /**
@@ -268,6 +269,21 @@ export function parseClaudeOutput(stdout) {
268
269
  export const MAX_ACTIVITY_CHARS = 120;
269
270
  /** Cap on the partial answer salvaged from a run that was killed. */
270
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;
271
287
  /**
272
288
  * Cap on a single buffered stdout line.
273
289
  *
@@ -300,6 +316,116 @@ function assistantContent(event) {
300
316
  }
301
317
  return { text: latestText, tool: latestTool };
302
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
+ }
303
429
  /**
304
430
  * Parse `--output-format stream-json` incrementally.
305
431
  *
@@ -315,10 +441,89 @@ export function readClaudeStream() {
315
441
  let lastActivity;
316
442
  let lastText;
317
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
+ };
512
+ const observed = {};
318
513
  const handle = (line) => {
319
514
  const event = asObject(line);
320
515
  if (!event)
321
516
  return;
517
+ if (event.type === 'system' && event.subtype === 'init') {
518
+ observed.model = text(event.model) ?? observed.model;
519
+ observed.runtime_version = text(event.claude_code_version) ?? observed.runtime_version;
520
+ }
521
+ else if (event.type === 'rate_limit_event') {
522
+ // Last one wins; absent for API-key / Bedrock / Vertex sessions.
523
+ const windows = fromClaudeRateLimitInfo(event.rate_limit_info);
524
+ if (windows.length > 0)
525
+ observed.quota_windows = windows;
526
+ }
322
527
  // Present on the `system`/`init` event, and repeated on most others. Taken
323
528
  // from whichever arrives first — they all name the same session.
324
529
  if (!session)
@@ -327,8 +532,15 @@ export function readClaudeStream() {
327
532
  result = event;
328
533
  return;
329
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
+ }
330
541
  if (event.type !== 'assistant')
331
542
  return;
543
+ collectToolUses(event);
332
544
  const { text: written, tool } = assistantContent(event);
333
545
  if (written)
334
546
  lastText = written;
@@ -359,6 +571,12 @@ export function readClaudeStream() {
359
571
  },
360
572
  sessionId: () => session,
361
573
  activity: () => lastActivity,
574
+ drainToolEvents: () => {
575
+ const drained = toolEvents;
576
+ toolEvents = [];
577
+ return drained;
578
+ },
579
+ observed: () => ({ ...observed }),
362
580
  salvage: () => {
363
581
  const trimmed = lastText?.trim();
364
582
  if (!trimmed)
@@ -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
  */
@@ -29,6 +30,8 @@ export declare const RUN_STATUS_SUCCEEDED = "succeeded";
29
30
  export declare const RUN_STATUS_FAILED = "failed";
30
31
  /** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
31
32
  export declare const MAX_ERROR_CHARS = 1000;
33
+ /** Server-side cap on the `model` field of a completion (`MAX_MODEL_CHARS`). */
34
+ export declare const MAX_MODEL_CHARS = 100;
32
35
  /**
33
36
  * The completion `error` that means "a person asked for this run to stop".
34
37
  *
@@ -46,6 +49,37 @@ export declare const RUN_ERROR_CANCELLED_BY_USER = "cancelled_by_user";
46
49
  * enormous block of text can't turn a keep-alive into a payload.
47
50
  */
48
51
  export declare const MAX_PROGRESS_CHARS = 200;
52
+ /**
53
+ * Server-side cap on events per `POST /v1/agent-runs/{id}/events` call
54
+ * (`shared.agents.external_tool_events.MAX_EVENTS_PER_REQUEST`). The run event
55
+ * also carries it as `events.max_batch`; this is the value for a payload that
56
+ * omits it, and the ceiling either way.
57
+ */
58
+ export declare const MAX_TOOL_EVENTS_PER_BATCH = 50;
59
+ /**
60
+ * One tool-call lifecycle event, as `POST /v1/agent-runs/{id}/events` takes it.
61
+ *
62
+ * `tool_id` is the runtime's own id for the invocation (Claude Code's
63
+ * `tool_use.id`); the server keeps one timeline row per id, opened by a
64
+ * `calling` and settled by a `completed` / `failed`, and ignores anything that
65
+ * would move a row backwards — which is what makes re-sending a batch safe.
66
+ * Everything else is optional and clipped server-side.
67
+ */
68
+ export interface ToolEvent {
69
+ tool_id: string;
70
+ tool_name: string;
71
+ status: 'calling' | 'completed' | 'failed';
72
+ /** ISO 8601. */
73
+ started_at?: string;
74
+ /** ISO 8601. */
75
+ ended_at?: string;
76
+ /** The command, the path, the query — one short line. */
77
+ args_preview?: string;
78
+ /** One short line of what came back. */
79
+ result_summary?: string;
80
+ /** Why it failed, on `failed` only. */
81
+ error_summary?: string;
82
+ }
49
83
  /** `POST` — exchange a one-time pairing code for a scoped credential. */
50
84
  export declare const REDEEM_PATH = "/v1/agent-pairing/redeem";
51
85
  /** `POST` — send a message into a conversation as the paired agent. */
@@ -229,6 +263,15 @@ export interface AgentRunRequest {
229
263
  * for what happens then.
230
264
  */
231
265
  heartbeat_path?: string;
266
+ /**
267
+ * Server-supplied path to report tool events at.
268
+ *
269
+ * Absent on every server built before the `events` block was added beside
270
+ * `heartbeat`. Unlike the heartbeat there is no fallback: with no path the
271
+ * events are simply not sent, and the run behaves exactly as it did before —
272
+ * the heartbeat line is the only narration.
273
+ */
274
+ events_path?: string;
232
275
  /**
233
276
  * Earlier messages on this surface, oldest first. Absent on a server that
234
277
  * predates the field — which is the case the harness shipped with, so an
@@ -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
  */
@@ -29,6 +30,8 @@ export const RUN_STATUS_SUCCEEDED = 'succeeded';
29
30
  export const RUN_STATUS_FAILED = 'failed';
30
31
  /** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
31
32
  export const MAX_ERROR_CHARS = 1000;
33
+ /** Server-side cap on the `model` field of a completion (`MAX_MODEL_CHARS`). */
34
+ export const MAX_MODEL_CHARS = 100;
32
35
  /**
33
36
  * The completion `error` that means "a person asked for this run to stop".
34
37
  *
@@ -46,6 +49,13 @@ export const RUN_ERROR_CANCELLED_BY_USER = 'cancelled_by_user';
46
49
  * enormous block of text can't turn a keep-alive into a payload.
47
50
  */
48
51
  export const MAX_PROGRESS_CHARS = 200;
52
+ /**
53
+ * Server-side cap on events per `POST /v1/agent-runs/{id}/events` call
54
+ * (`shared.agents.external_tool_events.MAX_EVENTS_PER_REQUEST`). The run event
55
+ * also carries it as `events.max_batch`; this is the value for a payload that
56
+ * omits it, and the ceiling either way.
57
+ */
58
+ export const MAX_TOOL_EVENTS_PER_BATCH = 50;
49
59
  /** `POST` — exchange a one-time pairing code for a scoped credential. */
50
60
  export const REDEEM_PATH = '/v1/agent-pairing/redeem';
51
61
  /** `POST` — send a message into a conversation as the paired agent. */
@@ -263,6 +273,7 @@ export function parseRunRequest(data) {
263
273
  const replyInstructions = obj(data.reply_instructions);
264
274
  const completion = obj(data.completion);
265
275
  const heartbeat = obj(data.heartbeat);
276
+ const events = obj(data.events);
266
277
  const context = obj(data.context);
267
278
  const declaredSurface = str(replyInstructions.surface);
268
279
  const conversationId = str(data.conversation_id) ?? str(replyInstructions.conversation_id);
@@ -308,6 +319,8 @@ export function parseRunRequest(data) {
308
319
  // No fallback of its own: an absent block is not an error, it is an older
309
320
  // server, and `heartbeatPathFor` already knows what to do about that.
310
321
  heartbeat_path: str(heartbeat.path),
322
+ // Same shape, same reasoning, and no fallback at all — see the field.
323
+ events_path: str(events.path),
311
324
  // Accepted both at the top level and nested under `context`, because the
312
325
  // server can move them without a CLI release either way — and a published
313
326
  // CLI cannot be redeployed to follow.
@@ -38,6 +38,11 @@ export interface AgentCredentials {
38
38
  events: string[];
39
39
  agent: StoredAgent;
40
40
  paired_at: string;
41
+ /**
42
+ * Label of the brain account (Claude Code / Codex login) this agent runs on
43
+ * — see `accounts.ts`. Absent means the CLI's own default login.
44
+ */
45
+ account?: string;
41
46
  }
42
47
  /**
43
48
  * On-disk credential schema version.