@prohost/cli 0.13.1 → 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,36 @@ 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
+
27
+ ## 0.13.2
28
+
29
+ - **A run stopped by the Claude usage limit tells ProhostAI when the limit
30
+ lifts.** The completion now carries `failure_kind: "usage_limit"` and, when
31
+ Claude Code's stream said, `retry_after` (the reset time). When the limit
32
+ lifts within a few hours, ProhostAI runs the same request again shortly after,
33
+ unless someone asks again first; either way it alerts the person who asked
34
+ instead of leaving a note nobody is notified of. The thread keeps its session, as before, so the retry continues it. A
35
+ server that predates the fields ignores them.
36
+
7
37
  ## 0.13.1
8
38
 
9
39
  - **A connected team's thread is never answered after the run's grant ended.**
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@prohost/cli`
2
2
 
3
- The [ProhostAI](https://www.prohost.ai) command-line tool. Three things it does:
3
+ The [ProhostAI](https://prohost.ai) command-line tool. Three things it does:
4
4
 
5
5
  - **`prohost agent`** — run your own local agent as a ProhostAI teammate. Someone
6
6
  @-mentions it in the app, it runs on your machine, and its answer is posted
@@ -233,9 +233,12 @@ and gets a reply that has forgotten asking. The CLI reads the `session_id` out
233
233
  of Claude Code's JSON output, records it per conversation in
234
234
  `~/.prohost/sessions.json`, and `--resume`s it next time. Sessions don't live
235
235
  forever; when one is gone the run is retried once from scratch rather than
236
- failing. A run stopped by your Claude usage limit is not retried: it fails
236
+ failing. A run stopped by your Claude usage limit is not retried here: it fails
237
237
  with Claude Code's own message, which says when the limit resets, and the
238
- conversation keeps its session for the next run.
238
+ conversation keeps its session for the next run. The failure also tells
239
+ ProhostAI when the limit lifts: if that is within a few hours, ProhostAI asks
240
+ the agent again shortly after (unless someone asks again first), and either way
241
+ it alerts whoever asked.
239
242
 
240
243
  **It lets the agent actually do things.** Runs are invoked with
241
244
  `--dangerously-skip-permissions`, so tool calls execute without stopping to ask.
@@ -129,6 +129,20 @@ export type HeartbeatOutcome =
129
129
  * work send none either.
130
130
  */
131
131
  export declare function heartbeatRun(options: ApiOptions, path: string, progress?: string): Promise<HeartbeatOutcome>;
132
+ /**
133
+ * A run's terminal state as the completion reports it. A failure the brain's
134
+ * usage limit caused says so, with when the limit lifts if the brain said.
135
+ */
136
+ export type CompletionOutcome = {
137
+ ok: true;
138
+ silent?: boolean;
139
+ } | {
140
+ ok: false;
141
+ error: string;
142
+ usageLimit?: {
143
+ retryAfter?: string;
144
+ };
145
+ };
132
146
  /**
133
147
  * Report a run's terminal state. Called for every run — including one whose
134
148
  * command crashed — so the server never waits on a run this harness abandoned.
@@ -140,13 +154,7 @@ export declare function heartbeatRun(options: ApiOptions, path: string, progress
140
154
  * ``model`` is the model id the run used, when the harness knows it. Omitted
141
155
  * otherwise, so a plain ``--exec`` command's completion body is unchanged.
142
156
  */
143
- export declare function completeRun(options: ApiOptions, path: string, outcome: {
144
- ok: true;
145
- silent?: boolean;
146
- } | {
147
- ok: false;
148
- error: string;
149
- }, model?: string): Promise<ApiResult>;
157
+ export declare function completeRun(options: ApiOptions, path: string, outcome: CompletionOutcome, model?: string): Promise<ApiResult>;
150
158
  /**
151
159
  * Report a batch of tool events for a run in flight.
152
160
  *
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_OUTCOME_SILENT, 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_FAILURE_USAGE_LIMIT, 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
  /**
@@ -184,6 +184,11 @@ export async function completeRun(options, path, outcome, model) {
184
184
  const body = outcome.ok
185
185
  ? { status: RUN_STATUS_SUCCEEDED, ...(outcome.silent ? { outcome: RUN_OUTCOME_SILENT } : {}) }
186
186
  : { status: RUN_STATUS_FAILED, error: outcome.error.slice(0, MAX_ERROR_CHARS) };
187
+ if (!outcome.ok && outcome.usageLimit) {
188
+ body.failure_kind = RUN_FAILURE_USAGE_LIMIT;
189
+ if (outcome.usageLimit.retryAfter)
190
+ body.retry_after = outcome.usageLimit.retryAfter;
191
+ }
187
192
  if (model)
188
193
  body.model = model.slice(0, MAX_MODEL_CHARS);
189
194
  const result = await postJson(options, path, body, undefined, false, SEND_TIMEOUT_MS);
@@ -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
@@ -199,6 +206,13 @@ export interface ClaudeStream {
199
206
  * help until the limit resets, so a caller must not retry it.
200
207
  */
201
208
  usageLimit(): string | undefined;
209
+ /**
210
+ * When the usage limit that stopped the turn lifts, in epoch seconds: the
211
+ * `resetsAt` of the rejecting `rate_limit_event`. `undefined` when no such
212
+ * event arrived, even if {@link usageLimit} recognised the limit from Claude
213
+ * Code's message alone — that message's "resets 11pm" is for people.
214
+ */
215
+ usageLimitResetsAt(): number | undefined;
202
216
  /**
203
217
  * The turn's outcome. ``stdout`` is the complete captured output, used only
204
218
  * when the stream held no `result` event — a crashed binary, a login prompt,
@@ -230,6 +244,27 @@ export declare function toolArgsPreview(name: string, input: unknown): string |
230
244
  * vocabulary belongs to Claude Code and grows without asking us, and a reader
231
245
  * that failed on an unknown `type` would turn a Claude Code release into a
232
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.
233
268
  */
234
269
  export declare function readClaudeStream(): ClaudeStream;
235
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
  },
@@ -655,12 +734,16 @@ export function readClaudeStream() {
655
734
  ? 'usage limit reached'
656
735
  : `usage limit reached · resets ${new Date(resetsAt * 1000).toISOString()}`;
657
736
  },
737
+ usageLimitResetsAt: () => {
738
+ flushTail();
739
+ return limitRejected?.resetsAt;
740
+ },
658
741
  finish(stdout) {
659
742
  // A binary that exits without flushing a final `\n` must not lose its
660
743
  // result.
661
744
  flushTail();
662
745
  if (result)
663
- return outputFromEnvelope(result);
746
+ return outcome(result);
664
747
  // No `result` event: either the command isn't emitting the stream we
665
748
  // asked for, or it died before finishing. Either way the whole-output
666
749
  // parser gives the better error message, and it still understands a
@@ -50,6 +50,13 @@ export declare const RUN_OUTCOME_SILENT = "silent";
50
50
  export declare const NO_REPLY_TOKEN = "[NO_REPLY]";
51
51
  /** Whether the agent's output is the no-reply token and nothing else. */
52
52
  export declare function isNoReply(output: string): boolean;
53
+ /**
54
+ * Completion `failure_kind` for a run the brain's subscription usage limit
55
+ * stopped. Sent with `status: failed`, plus `retry_after` when the brain said
56
+ * when the limit lifts, so the server can retry the work then instead of
57
+ * leaving the question unanswered. A server that predates the field ignores it.
58
+ */
59
+ export declare const RUN_FAILURE_USAGE_LIMIT = "usage_limit";
53
60
  /** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
54
61
  export declare const MAX_ERROR_CHARS = 1000;
55
62
  /** Server-side cap on the `model` field of a completion (`MAX_MODEL_CHARS`). */
@@ -52,6 +52,13 @@ export const NO_REPLY_TOKEN = '[NO_REPLY]';
52
52
  export function isNoReply(output) {
53
53
  return output.trim().toUpperCase() === NO_REPLY_TOKEN;
54
54
  }
55
+ /**
56
+ * Completion `failure_kind` for a run the brain's subscription usage limit
57
+ * stopped. Sent with `status: failed`, plus `retry_after` when the brain said
58
+ * when the limit lifts, so the server can retry the work then instead of
59
+ * leaving the question unanswered. A server that predates the field ignores it.
60
+ */
61
+ export const RUN_FAILURE_USAGE_LIMIT = 'usage_limit';
55
62
  /** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
56
63
  export const MAX_ERROR_CHARS = 1000;
57
64
  /** Server-side cap on the `model` field of a completion (`MAX_MODEL_CHARS`). */
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.
@@ -759,14 +776,19 @@ venueCwd) {
759
776
  * A turn the Claude subscription's usage limit stopped, worded for the
760
777
  * thread: whose limit (this machine's login) and, in Claude Code's own
761
778
  * words, when it resets. The only fix is waiting, so the reason says that
762
- * rather than "exited 1".
779
+ * rather than "exited 1" — and the completion says when, so the server can
780
+ * run the work again then.
763
781
  */
764
- const usageLimitFailure = (said, stderr = '') => ({
765
- ok: false,
766
- reason: `Claude usage limit reached on ${options.machine ?? os.hostname()}`,
767
- detail: [said, stderr.includes(said) ? '' : stderr].filter((part) => part.length > 0).join('\n'),
768
- durationMs: elapsed(),
769
- });
782
+ const usageLimitFailure = (said, stderr = '') => {
783
+ const resetsAt = stream?.usageLimitResetsAt();
784
+ return {
785
+ ok: false,
786
+ reason: `Claude usage limit reached on ${options.machine ?? os.hostname()}`,
787
+ detail: [said, stderr.includes(said) ? '' : stderr].filter((part) => part.length > 0).join('\n'),
788
+ durationMs: elapsed(),
789
+ usageLimit: resetsAt === undefined ? {} : { retryAfter: new Date(resetsAt * 1000).toISOString() },
790
+ };
791
+ };
770
792
  const failure = (result) => {
771
793
  // A killed run has no reply, but it usually has most of one. Reporting the
772
794
  // tail of what the agent was writing turns "no output for 15 minutes" into
@@ -1021,13 +1043,63 @@ mcp) {
1021
1043
  // workspace, and on this machine's log: never the agent's words about the
1022
1044
  // connected team's thread (see `VENUE_PROGRESS`).
1023
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;
1024
1092
  let result;
1025
1093
  try {
1026
- 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);
1027
1095
  }
1028
1096
  finally {
1029
1097
  venueDir?.remove();
1098
+ await earlyPosts;
1030
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();
1031
1103
  // A venue session is never continued: the next turn there starts fresh from
1032
1104
  // the thread the server sends, so nothing of this one is remembered.
1033
1105
  const sessionId = venue ? undefined : result.ok ? result.sessionId : undefined;
@@ -1089,7 +1161,11 @@ mcp) {
1089
1161
  log(` [dry-run] would report the run as failed`);
1090
1162
  return 'skipped';
1091
1163
  }
1092
- const completed = await completeWithRetry(api, run.completion_path, { ok: false, error: detail ? `${reason}: ${detail}` : reason }, log, sleep, runtime.model());
1164
+ const completed = await completeWithRetry(api, run.completion_path, {
1165
+ ok: false,
1166
+ error: detail ? `${reason}: ${detail}` : reason,
1167
+ ...(result.usageLimit ? { usageLimit: result.usageLimit } : {}),
1168
+ }, log, sleep, runtime.model());
1093
1169
  if (!completed.ok) {
1094
1170
  log(` ! could not report failure (${describeFailure(completed)})` +
1095
1171
  `${completed.status !== 0 && completed.error ? ` ${completed.error}` : ''}`);
@@ -1107,14 +1183,29 @@ mcp) {
1107
1183
  log(` [dry-run] nothing was sent and the run was left open`);
1108
1184
  return 'skipped';
1109
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
+ }
1110
1196
  // The agent decided not to reply — the usual answer when it was woken as a
1111
1197
  // bystander (`reply_expected: false`). A finished run, not a failure: nothing
1112
1198
  // is posted, and the completion says so, so the server records a deliberate
1113
1199
  // silence and clears the thinking indicator as one. Checked on every run, not
1114
1200
  // only bystander ones: the literal token must never reach a thread.
1115
1201
  if (isNoReply(reply)) {
1116
- log(`${timestamp()} ✓ ${label} chose not to reply (${seconds}s)`);
1117
- 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());
1118
1209
  if (!completed.ok) {
1119
1210
  log(` ! could not mark the run complete (${describeFailure(completed)})`);
1120
1211
  return 'unreported';
@@ -1179,7 +1270,8 @@ mcp) {
1179
1270
  log(`${timestamp()} ! ${label} replied, but marking it complete failed (${describeFailure(completed)})`);
1180
1271
  return 'unreported';
1181
1272
  }
1182
- 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})`);
1183
1275
  return 'succeeded';
1184
1276
  }
1185
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.1";
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.1";
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.1';
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,10 +1,10 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.13.1",
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",
7
- "homepage": "https://www.prohost.ai",
7
+ "homepage": "https://prohost.ai",
8
8
  "bugs": {
9
9
  "email": "support@prohost.ai"
10
10
  },