@prohost/cli 0.13.2 → 0.13.3

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,26 @@ 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.13.3
8
+
9
+ - **Every turn the agent answers in reaches the thread.** When a command the
10
+ agent ran in the background (or one Claude Code moved to the background at
11
+ its two-minute timeout) is still running as the agent ends its turn, Claude
12
+ Code keeps the session alive and wakes it with another turn when the command
13
+ finishes. The CLI posted only the last turn, so a follow-up that said
14
+ `[NO_REPLY]`, or just "nothing in my earlier reply changes", replaced the real
15
+ answer: 3 of the Customer Support Bot's 155 runs lost theirs that way. A turn
16
+ that ends while a background task is still running is now posted as soon as
17
+ it ends ("the review is still running; I'll post the result"), each under an
18
+ idempotency key of its own (`prohost-cli-run-<run>-turn-<n>`). The run's last
19
+ turn is posted as before, under the run's key. A later `[NO_REPLY]` or a
20
+ repeat of the previous answer takes back nothing; a last turn that fails
21
+ (a usage limit) is still reported as the run's failure. Posts go strictly in
22
+ order: one the server refuses is retried under its own key and holds back
23
+ the rest (if it never lands, the run fails as a rejected reply does), and
24
+ nothing more is posted once the run is cancelled. A run that ends in one turn
25
+ is unchanged.
26
+
7
27
  ## 0.13.2
8
28
 
9
29
  - **A run stopped by the Claude usage limit tells ProhostAI when the limit
@@ -171,6 +171,13 @@ export interface ClaudeStream {
171
171
  * that isn't Claude Code's at all.
172
172
  */
173
173
  drainToolEvents(): ToolEvent[];
174
+ /**
175
+ * Answers from turns that ended while a background task was still running —
176
+ * turns another can follow — in order, since the last call. Taken, not
177
+ * copied: {@link finish} no longer includes them. Empty for a run that ends
178
+ * in one turn, which is every run that starts no background task.
179
+ */
180
+ takeEarlyAnswers(): string[];
174
181
  /**
175
182
  * Model, Claude Code version and capacity windows the stream announced —
176
183
  * `system/init.{model, claude_code_version}` and the last
@@ -237,6 +244,27 @@ export declare function toolArgsPreview(name: string, input: unknown): string |
237
244
  * vocabulary belongs to Claude Code and grows without asking us, and a reader
238
245
  * that failed on an unknown `type` would turn a Claude Code release into a
239
246
  * fleet of broken laptops.
247
+ *
248
+ * **One invocation, several turns.** A run usually ends in one `result`
249
+ * event. It ends in more when a background task — a Bash
250
+ * `run_in_background`, or a command Claude Code moved to the
251
+ * background at its two-minute timeout — is still running when the agent ends
252
+ * its turn: Claude Code keeps the process alive, wakes the session with the
253
+ * task's notification when it finishes, and runs another turn that ends in a
254
+ * `result` of its own. The agent writes each of those turns as a message to the
255
+ * thread ("the review is still running, I'll post the result when it lands";
256
+ * "nothing in my earlier reply changes"), so each one that answers is a message
257
+ * of its own. Reading only the last event lost the answer three times in 155
258
+ * Customer Support Bot runs: twice to a trailing "nothing else changed", once
259
+ * (2026-10-22, Claude Code 2.1.308) to a trailing `[NO_REPLY]`.
260
+ *
261
+ * A turn that ends while a background task is still running can be followed by
262
+ * another, so it is handed out at once ({@link ClaudeStream.takeEarlyAnswers})
263
+ * for the harness to post while the run goes on. Whatever answered and was not
264
+ * taken is the run's reply from {@link ClaudeStream.finish}. Any answer
265
+ * followed by another turn is early too: it is not the run's last word.
266
+ * `[NO_REPLY]` and a repeat of the answer before take back nothing that was
267
+ * already said; a last turn that failed is still the run's outcome.
240
268
  */
241
269
  export declare function readClaudeStream(): ClaudeStream;
242
270
  /**
@@ -17,6 +17,7 @@
17
17
  import { randomBytes } from 'node:crypto';
18
18
  import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
19
19
  import path from 'node:path';
20
+ import { NO_REPLY_TOKEN, isNoReply } from './contract.js';
20
21
  import { prohostHome } from './credentials.js';
21
22
  import { MCP_SERVER_NAME } from './mcp.js';
22
23
  import { fromClaudeRateLimitInfo } from './runtime_status.js';
@@ -458,13 +459,45 @@ function toolResultText(content) {
458
459
  * vocabulary belongs to Claude Code and grows without asking us, and a reader
459
460
  * that failed on an unknown `type` would turn a Claude Code release into a
460
461
  * fleet of broken laptops.
462
+ *
463
+ * **One invocation, several turns.** A run usually ends in one `result`
464
+ * event. It ends in more when a background task — a Bash
465
+ * `run_in_background`, or a command Claude Code moved to the
466
+ * background at its two-minute timeout — is still running when the agent ends
467
+ * its turn: Claude Code keeps the process alive, wakes the session with the
468
+ * task's notification when it finishes, and runs another turn that ends in a
469
+ * `result` of its own. The agent writes each of those turns as a message to the
470
+ * thread ("the review is still running, I'll post the result when it lands";
471
+ * "nothing in my earlier reply changes"), so each one that answers is a message
472
+ * of its own. Reading only the last event lost the answer three times in 155
473
+ * Customer Support Bot runs: twice to a trailing "nothing else changed", once
474
+ * (2026-10-22, Claude Code 2.1.308) to a trailing `[NO_REPLY]`.
475
+ *
476
+ * A turn that ends while a background task is still running can be followed by
477
+ * another, so it is handed out at once ({@link ClaudeStream.takeEarlyAnswers})
478
+ * for the harness to post while the run goes on. Whatever answered and was not
479
+ * taken is the run's reply from {@link ClaudeStream.finish}. Any answer
480
+ * followed by another turn is early too: it is not the run's last word.
481
+ * `[NO_REPLY]` and a repeat of the answer before take back nothing that was
482
+ * already said; a last turn that failed is still the run's outcome.
461
483
  */
462
484
  export function readClaudeStream() {
463
485
  let buffer = '';
464
486
  let session;
465
487
  let lastActivity;
466
488
  let lastText;
489
+ /** The last `result` event: what decides a run in which no turn answered. */
467
490
  let result;
491
+ /** Answered turns not yet handed out, in order, and whether another turn can follow each. */
492
+ const answers = [];
493
+ /** Whether any turn answered, handed out or not. */
494
+ let answered = false;
495
+ /** The last answer, so a turn that only repeats it is not said twice. */
496
+ let lastAnswer;
497
+ /** Every turn's refused tools, for the run's report. */
498
+ const denials = new Set();
499
+ /** Background tasks running as of the last `background_tasks_changed`. */
500
+ let backgroundTasks = 0;
468
501
  /** The last `assistant` event that was an API error rather than an answer. */
469
502
  let apiError;
470
503
  /** When a `rate_limit_event` said the limit is hit: its reset, epoch seconds. */
@@ -563,8 +596,26 @@ export function readClaudeStream() {
563
596
  // from whichever arrives first — they all name the same session.
564
597
  if (!session)
565
598
  session = text(event.session_id);
599
+ if (event.type === 'system' && event.subtype === 'background_tasks_changed' && Array.isArray(event.tasks)) {
600
+ backgroundTasks = event.tasks.length;
601
+ }
566
602
  if (event.type === 'result') {
603
+ // One per turn: see "One invocation, several turns" above.
567
604
  result = event;
605
+ const turn = outputFromEnvelope(event);
606
+ for (const name of turn.denials)
607
+ denials.add(name);
608
+ // Another turn ended, so no answer before it is the run's last word.
609
+ for (const answer of answers)
610
+ answer.early = true;
611
+ if (turn.ok && !isNoReply(turn.text)) {
612
+ // Kept as written, so a one-turn run's reply is byte for byte what it was.
613
+ const said = turn.text.trim();
614
+ if (said !== lastAnswer)
615
+ answers.push({ text: turn.text, early: backgroundTasks > 0 });
616
+ lastAnswer = said;
617
+ answered = true;
618
+ }
568
619
  return;
569
620
  }
570
621
  if (event.type === 'user') {
@@ -595,6 +646,24 @@ export function readClaudeStream() {
595
646
  else if (written)
596
647
  lastActivity = written.replace(/\s+/g, ' ').trim().slice(0, MAX_ACTIVITY_CHARS);
597
648
  };
649
+ /** The run's outcome from its turns; only meaningful once a `result` arrived. */
650
+ const outcome = (last) => {
651
+ const final = outputFromEnvelope(last);
652
+ // A last turn that failed is the run's outcome even after an answer — a
653
+ // usage limit must still say when to try again. Every answer before it was
654
+ // handed out early the moment this turn ended.
655
+ if (!answered || !final.ok)
656
+ return final;
657
+ const sessionId = final.sessionId ?? session;
658
+ // Everything still unsaid, or — when all of it was handed out early — the
659
+ // token that says this turn adds nothing.
660
+ const text = answers.length === 1
661
+ ? answers[0].text
662
+ : answers.length > 0
663
+ ? answers.map((answer) => answer.text.trim()).join('\n\n')
664
+ : NO_REPLY_TOKEN;
665
+ return { ok: true, text, sessionId, denials: [...denials] };
666
+ };
598
667
  /** A trailing event with no newline after it is still an event. */
599
668
  const flushTail = () => {
600
669
  const tail = buffer.trim();
@@ -628,6 +697,16 @@ export function readClaudeStream() {
628
697
  toolEvents = [];
629
698
  return drained;
630
699
  },
700
+ takeEarlyAnswers: () => {
701
+ // Up to the last turn another can follow: an answer before it is just as
702
+ // surely not the run's last word, and order is the thread's order.
703
+ let through = -1;
704
+ answers.forEach((answer, index) => {
705
+ if (answer.early)
706
+ through = index;
707
+ });
708
+ return answers.splice(0, through + 1).map((answer) => answer.text);
709
+ },
631
710
  observed: () => ({ ...observed }),
632
711
  salvage: () => {
633
712
  const trimmed = lastText?.trim();
@@ -638,9 +717,9 @@ export function readClaudeStream() {
638
717
  error: () => {
639
718
  flushTail();
640
719
  if (result) {
641
- const outcome = outputFromEnvelope(result);
642
- if (!outcome.ok)
643
- return outcome.error;
720
+ const turned = outcome(result);
721
+ if (!turned.ok)
722
+ return turned.error;
644
723
  }
645
724
  return apiError?.text;
646
725
  },
@@ -664,7 +743,7 @@ export function readClaudeStream() {
664
743
  // result.
665
744
  flushTail();
666
745
  if (result)
667
- return outputFromEnvelope(result);
746
+ return outcome(result);
668
747
  // No `result` event: either the command isn't emitting the stream we
669
748
  // asked for, or it died before finishing. Either way the whole-output
670
749
  // parser gives the better error message, and it still understands a
package/dist/agent/run.js CHANGED
@@ -638,7 +638,13 @@ toolEvents,
638
638
  /** Harness-set variables for this run's command, over the brain's own. */
639
639
  runEnv,
640
640
  /** A venue run's own empty working directory; marks the turn as a venue session. */
641
- venueCwd) {
641
+ venueCwd,
642
+ /**
643
+ * Where an answer goes the moment its turn ends while a background task is
644
+ * still running (see `readClaudeStream`). Absent, such answers stay in the
645
+ * turn's reply.
646
+ */
647
+ onEarlyAnswer) {
642
648
  // `--timeout` is a ceiling on the run, not on each attempt: the retry below
643
649
  // must not let one run take twice what the operator allowed. Both attempts
644
650
  // share one deadline, which costs the retry nothing in practice — a rejected
@@ -699,6 +705,9 @@ venueCwd) {
699
705
  // The heartbeat line above is unchanged for older servers; the events are
700
706
  // the richer channel a newer server renders beneath it.
701
707
  toolEvents?.push(reader.drainToolEvents());
708
+ if (onEarlyAnswer)
709
+ for (const answer of reader.takeEarlyAnswers())
710
+ onEarlyAnswer(answer);
702
711
  };
703
712
  /**
704
713
  * Undo an eagerly recorded Claude session after an ordinary failed turn.
@@ -746,8 +755,16 @@ venueCwd) {
746
755
  : undefined,
747
756
  });
748
757
  // Whatever the stream announced — model, version, capacity — is true of
749
- // this machine whether or not the turn succeeded.
750
- return reader ? running.finally(() => runtime.observe(reader.observed())) : running;
758
+ // this machine whether or not the turn succeeded. Its last event may have
759
+ // come without a newline: ended here, so a turn it closed still hands out
760
+ // the answers before it.
761
+ return reader
762
+ ? running.finally(() => {
763
+ reader.push('\n');
764
+ observe(reader);
765
+ runtime.observe(reader.observed());
766
+ })
767
+ : running;
751
768
  };
752
769
  // Wall clock for the whole turn, retry included — what the operator waited,
753
770
  // rather than what the last attempt took.
@@ -1026,13 +1043,63 @@ mcp) {
1026
1043
  // workspace, and on this machine's log: never the agent's words about the
1027
1044
  // connected team's thread (see `VENUE_PROGRESS`).
1028
1045
  const turnControl = venue ? { ...control, report: () => control.report(VENUE_PROGRESS) } : control;
1046
+ // A turn the agent ended while a background task was still running is a
1047
+ // message of its own, posted as it lands: the agent wrote it for the thread
1048
+ // ("the review is still running, I'll post the result"), and another turn
1049
+ // may follow it by minutes. Strictly in order, each under a key of its own
1050
+ // that every retry reuses, so an answer the server took but whose response
1051
+ // was lost can't land twice. One that can't be posted holds back everything
1052
+ // after it — the run's last reply included — and nothing is sent once the
1053
+ // run has been stopped.
1054
+ const early = [];
1055
+ let earlyCount = 0;
1056
+ let earlyPosted = 0;
1057
+ let earlyPosts = Promise.resolve();
1058
+ const earlyPath = run.reply_path;
1059
+ const earlyBodyKey = run.reply_body_key;
1060
+ const stoppedNow = () => control.signal.aborted || control.disposition() !== undefined;
1061
+ const postEarly = async () => {
1062
+ if (!earlyPath || !earlyBodyKey)
1063
+ return;
1064
+ while (early.length > 0) {
1065
+ // The tool calls that led here are on the timeline before the answer.
1066
+ await toolEvents.flush();
1067
+ if (stoppedNow()) {
1068
+ early.length = 0;
1069
+ return;
1070
+ }
1071
+ const { answer, key } = early[0];
1072
+ const posted = await postReplyWithReplay(api, () => stoppedNow()
1073
+ ? Promise.resolve({ ok: false, status: -1, error: 'the run was stopped' })
1074
+ : postReply(api, earlyPath, earlyBodyKey, answer, key, run.reply_body_extra), label, log, sleep);
1075
+ if (!posted.ok) {
1076
+ if (!stoppedNow())
1077
+ log(`${timestamp()} ! ${label} could not post an answer yet (${describeFailure(posted)})`);
1078
+ return;
1079
+ }
1080
+ early.shift();
1081
+ earlyPosted += 1;
1082
+ log(`${timestamp()} ✓ ${label} posted an answer; a background task is still running`);
1083
+ }
1084
+ };
1085
+ const onEarlyAnswer = !options.dryRun && earlyPath && earlyBodyKey
1086
+ ? (answer) => {
1087
+ earlyCount += 1;
1088
+ early.push({ answer, key: `${run.run_id}-turn-${earlyCount}` });
1089
+ earlyPosts = earlyPosts.then(postEarly);
1090
+ }
1091
+ : undefined;
1029
1092
  let result;
1030
1093
  try {
1031
- result = await runTurn(run, prompt, options, runtime, mcp, turnControl, log, toolEvents, runEnv, venueDir?.dir);
1094
+ result = await runTurn(run, prompt, options, runtime, mcp, turnControl, log, toolEvents, runEnv, venueDir?.dir, onEarlyAnswer);
1032
1095
  }
1033
1096
  finally {
1034
1097
  venueDir?.remove();
1098
+ await earlyPosts;
1035
1099
  }
1100
+ // One more try for an answer that couldn't be posted while the run worked.
1101
+ if (early.length > 0)
1102
+ await postEarly();
1036
1103
  // A venue session is never continued: the next turn there starts fresh from
1037
1104
  // the thread the server sends, so nothing of this one is remembered.
1038
1105
  const sessionId = venue ? undefined : result.ok ? result.sessionId : undefined;
@@ -1116,14 +1183,29 @@ mcp) {
1116
1183
  log(` [dry-run] nothing was sent and the run was left open`);
1117
1184
  return 'skipped';
1118
1185
  }
1186
+ // An earlier answer still not in the thread: posting the last one would put
1187
+ // it out of order, and posting the earlier one under any other key could
1188
+ // post it twice. The run fails the way a rejected reply does.
1189
+ if (early.length > 0) {
1190
+ log(`${timestamp()} ✗ ${label} ${early.length} answer(s) could not be posted (${seconds}s)`);
1191
+ if (result.sessionRecorded)
1192
+ runtime.sessions.forget(sessionKeyFor(run));
1193
+ const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: 'posting the reply failed: an earlier answer could not be posted' }, log, sleep, runtime.model());
1194
+ return reported.ok ? 'failed' : 'unreported';
1195
+ }
1119
1196
  // The agent decided not to reply — the usual answer when it was woken as a
1120
1197
  // bystander (`reply_expected: false`). A finished run, not a failure: nothing
1121
1198
  // is posted, and the completion says so, so the server records a deliberate
1122
1199
  // silence and clears the thinking indicator as one. Checked on every run, not
1123
1200
  // only bystander ones: the literal token must never reach a thread.
1124
1201
  if (isNoReply(reply)) {
1125
- log(`${timestamp()} ✓ ${label} chose not to reply (${seconds}s)`);
1126
- const completed = await completeWithRetry(api, run.completion_path, { ok: true, silent: true }, log, sleep, runtime.model());
1202
+ // Silence only when nothing was said: a last turn that adds nothing to
1203
+ // answers already in the thread is a run that replied.
1204
+ const silent = earlyPosted === 0;
1205
+ log(silent
1206
+ ? `${timestamp()} ✓ ${label} chose not to reply (${seconds}s)`
1207
+ : `${timestamp()} ✓ ${label} replied in ${seconds}s (${earlyPosted} posted while it worked)`);
1208
+ const completed = await completeWithRetry(api, run.completion_path, silent ? { ok: true, silent: true } : { ok: true }, log, sleep, runtime.model());
1127
1209
  if (!completed.ok) {
1128
1210
  log(` ! could not mark the run complete (${describeFailure(completed)})`);
1129
1211
  return 'unreported';
@@ -1188,7 +1270,8 @@ mcp) {
1188
1270
  log(`${timestamp()} ! ${label} replied, but marking it complete failed (${describeFailure(completed)})`);
1189
1271
  return 'unreported';
1190
1272
  }
1191
- log(`${timestamp()} ✓ ${label} replied in ${seconds}s (${reply.length} chars)`);
1273
+ const alsoPosted = earlyPosted > 0 ? `, ${earlyPosted} more posted while it worked` : '';
1274
+ log(`${timestamp()} ✓ ${label} replied in ${seconds}s (${reply.length} chars${alsoPosted})`);
1192
1275
  return 'succeeded';
1193
1276
  }
1194
1277
  /**
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.13.2";
10
+ export declare const CLI_VERSION = "0.13.3";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.13.2";
12
+ export declare const USER_AGENT = "prohost-cli/0.13.3";
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.13.2';
10
+ export const CLI_VERSION = '0.13.3';
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.13.2",
3
+ "version": "0.13.3",
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",