@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 +30 -0
- package/README.md +6 -3
- package/dist/agent/api.d.ts +15 -7
- package/dist/agent/api.js +6 -1
- package/dist/agent/claude.d.ts +35 -0
- package/dist/agent/claude.js +87 -4
- package/dist/agent/contract.d.ts +7 -0
- package/dist/agent/contract.js +7 -0
- package/dist/agent/run.js +107 -15
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +2 -2
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://
|
|
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.
|
package/dist/agent/api.d.ts
CHANGED
|
@@ -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);
|
package/dist/agent/claude.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/agent/claude.js
CHANGED
|
@@ -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
|
|
642
|
-
if (!
|
|
643
|
-
return
|
|
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
|
|
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
|
package/dist/agent/contract.d.ts
CHANGED
|
@@ -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`). */
|
package/dist/agent/contract.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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, {
|
|
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
|
-
|
|
1117
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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://
|
|
7
|
+
"homepage": "https://prohost.ai",
|
|
8
8
|
"bugs": {
|
|
9
9
|
"email": "support@prohost.ai"
|
|
10
10
|
},
|