@prohost/cli 0.12.2 → 0.12.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,25 @@ 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.12.3
8
+
9
+ - **A run stopped by the Claude usage limit says so.** It used to fail with
10
+ "agent command exited 1", the reason left in the stream the CLI never read.
11
+ It now reports `Claude usage limit reached on <machine>: You've hit your
12
+ weekly limit · resets Oct 16 at 11am (America/New_York)`, and the thread's
13
+ notice shows it.
14
+ - **That run keeps its conversation's session, and runs once.** A resumed run
15
+ that failed for any reason was taken for a session Claude Code could no
16
+ longer find, because every stream event names the session. The CLI then
17
+ dropped the conversation's session and ran the turn again from scratch, which
18
+ spent quota and, at a usage limit, failed a second time. A resume now counts
19
+ as rejected only when no session started. A run stopped by the usage limit
20
+ is not run again, and the next run after the reset continues the
21
+ conversation.
22
+ - **Any Claude Code or Codex run that exits non-zero reports its own error**
23
+ (an API error, a failed turn), not just the exit code. Only that message is
24
+ read from the stream; tool output stays on this machine.
25
+
7
26
  ## 0.12.2
8
27
 
9
28
  - **Two agents on one computer no longer delete each other's account.** Every
package/README.md CHANGED
@@ -233,7 +233,9 @@ 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.
236
+ failing. A run stopped by your Claude usage limit is not retried: it fails
237
+ with Claude Code's own message, which says when the limit resets, and the
238
+ conversation keeps its session for the next run.
237
239
 
238
240
  **It lets the agent actually do things.** Runs are invoked with
239
241
  `--dangerously-skip-permissions`, so tool calls execute without stopping to ask.
@@ -177,6 +177,28 @@ export interface ClaudeStream {
177
177
  * `rate_limit_event`. Fields the stream never mentioned are absent.
178
178
  */
179
179
  observed(): RuntimeObservation;
180
+ /**
181
+ * Why the turn failed, in Claude Code's own words: the error a `result`
182
+ * event reported, or else the API error an `assistant` event carried in
183
+ * place of an answer. `undefined` when the stream said neither.
184
+ *
185
+ * Read for a run that exited non-zero, whose stdout is otherwise never
186
+ * looked at — without it such a run reports only "exited 1". Never the raw
187
+ * stream: that carries the output of every tool call, and this text is
188
+ * echoed into a workspace thread.
189
+ */
190
+ error(): string | undefined;
191
+ /**
192
+ * Claude Code's message when the subscription's usage limit stopped the
193
+ * turn — "You've hit your weekly limit · resets Oct 16 at 11am
194
+ * (America/New_York)" — or `undefined` when something else did.
195
+ *
196
+ * Recognised by the `rate_limit` error on the `assistant` event Claude Code
197
+ * writes in place of an answer, or by a `rate_limit_event` whose status is
198
+ * `rejected` with no overage to fall back on. Running the turn again cannot
199
+ * help until the limit resets, so a caller must not retry it.
200
+ */
201
+ usageLimit(): string | undefined;
180
202
  /**
181
203
  * The turn's outcome. ``stdout`` is the complete captured output, used only
182
204
  * when the stream held no `result` event — a crashed binary, a login prompt,
@@ -218,11 +240,20 @@ export declare function readClaudeStream(): ClaudeStream;
218
240
  * and the ledger has no way to know. A stale entry must not turn into a
219
241
  * permanently broken surface, so the caller retries once without `--resume`.
220
242
  *
221
- * The observed failure is exit 1, empty stdout, and
222
- * `No conversation found with session ID: <uuid>` on stderr. Matching is
223
- * broader than that string because the wording is not ours to depend on, and it
224
- * is gated by the caller on the run having actually failed — so a *successful*
225
- * run whose reply text happens to quote a session id is never mistaken for one.
243
+ * The observed failure (Claude Code 2.1.296) is exit 1, `No conversation found
244
+ * with session ID: <uuid>` on stderr, and on stdout a lone `result` event with
245
+ * that message in `errors` — no `system`/`init` event, because no session ever
246
+ * started. Matching is broader than that string because the wording is not
247
+ * ours to depend on, and it is gated by the caller on the run having actually
248
+ * failed — so a *successful* run whose reply text happens to quote a session id
249
+ * is never mistaken for one.
250
+ *
251
+ * A stream that announced its session or produced an assistant message is a
252
+ * resume that **worked**, whatever went wrong after it. That guard is what
253
+ * keeps an ordinary failed turn from matching: every stream-json event carries
254
+ * `session_id`, so "the output mentions the session id" — which this used to
255
+ * accept — is true of every resumed run, and a resumed run that then hit a
256
+ * usage limit was retried from scratch and lost its thread's session.
226
257
  */
227
258
  export declare function looksLikeResumeFailure(output: string, sessionId: string): boolean;
228
259
  /** Absolute path of the generated MCP config. */
@@ -209,6 +209,19 @@ function resultEnvelope(stdout) {
209
209
  function text(value) {
210
210
  return typeof value === 'string' && value.length > 0 ? value : undefined;
211
211
  }
212
+ function asRecord(value) {
213
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
214
+ }
215
+ /**
216
+ * The `errors` an `error_during_execution` envelope carries instead of a
217
+ * `result` — e.g. `No conversation found with session ID: <uuid>` for a
218
+ * rejected `--resume` (Claude Code 2.1.296).
219
+ */
220
+ function envelopeErrors(parsed) {
221
+ if (!Array.isArray(parsed.errors))
222
+ return undefined;
223
+ return text(parsed.errors.filter((entry) => typeof entry === 'string' && entry.length > 0).join('; '));
224
+ }
212
225
  /**
213
226
  * Turn a `result` envelope into the harness's view of the turn.
214
227
  *
@@ -229,7 +242,7 @@ function outputFromEnvelope(parsed) {
229
242
  ok: false,
230
243
  sessionId,
231
244
  denials,
232
- error: result ?? `claude reported an error (${String(parsed.subtype ?? 'unknown')})`,
245
+ error: result ?? envelopeErrors(parsed) ?? `claude reported an error (${String(parsed.subtype ?? 'unknown')})`,
233
246
  };
234
247
  }
235
248
  if (!result) {
@@ -452,6 +465,10 @@ export function readClaudeStream() {
452
465
  let lastActivity;
453
466
  let lastText;
454
467
  let result;
468
+ /** The last `assistant` event that was an API error rather than an answer. */
469
+ let apiError;
470
+ /** When a `rate_limit_event` said the limit is hit: its reset, epoch seconds. */
471
+ let limitRejected;
455
472
  let toolEvents = [];
456
473
  // `tool_use.id` → tool name, so a result can be reported under the name of
457
474
  // the call it answers. Deleted on the result: bounded by tools in flight.
@@ -534,6 +551,13 @@ export function readClaudeStream() {
534
551
  const windows = fromClaudeRateLimitInfo(event.rate_limit_info);
535
552
  if (windows.length > 0)
536
553
  observed.quota_windows = windows;
554
+ const info = asRecord(event.rate_limit_info);
555
+ // `rejected` with overage still `allowed` is a turn that carries on, on
556
+ // overage; only a rejection with nothing to fall back on stops it.
557
+ const overage = info?.overageStatus;
558
+ if (info?.status === 'rejected' && overage !== 'allowed' && overage !== 'allowed_warning') {
559
+ limitRejected = { resetsAt: typeof info.resetsAt === 'number' ? info.resetsAt : undefined };
560
+ }
537
561
  }
538
562
  // Present on the `system`/`init` event, and repeated on most others. Taken
539
563
  // from whichever arrives first — they all name the same session.
@@ -551,6 +575,15 @@ export function readClaudeStream() {
551
575
  }
552
576
  if (event.type !== 'assistant')
553
577
  return;
578
+ // Claude Code writes an API failure — a usage limit, a billing or auth
579
+ // error — as an assistant message whose text is the explanation, marked by
580
+ // `error` (stream-json) or `isApiErrorMessage` (the transcript shape).
581
+ if (typeof event.error === 'string' || event.isApiErrorMessage === true) {
582
+ apiError = {
583
+ kind: event.apiError === 'usage_limit_reached' ? 'rate_limit' : text(event.error),
584
+ text: assistantContent(event).text?.trim() || undefined,
585
+ };
586
+ }
554
587
  collectToolUses(event);
555
588
  const { text: written, tool } = assistantContent(event);
556
589
  if (written)
@@ -562,6 +595,14 @@ export function readClaudeStream() {
562
595
  else if (written)
563
596
  lastActivity = written.replace(/\s+/g, ' ').trim().slice(0, MAX_ACTIVITY_CHARS);
564
597
  };
598
+ /** A trailing event with no newline after it is still an event. */
599
+ const flushTail = () => {
600
+ const tail = buffer.trim();
601
+ if (tail) {
602
+ handle(tail);
603
+ buffer = '';
604
+ }
605
+ };
565
606
  return {
566
607
  push(chunk) {
567
608
  buffer += chunk;
@@ -594,14 +635,30 @@ export function readClaudeStream() {
594
635
  return undefined;
595
636
  return trimmed.length > MAX_SALVAGE_CHARS ? trimmed.slice(-MAX_SALVAGE_CHARS) : trimmed;
596
637
  },
597
- finish(stdout) {
598
- // A trailing event with no newline after it is still an event — a binary
599
- // that exits without flushing a final `\n` must not lose its result.
600
- const tail = buffer.trim();
601
- if (tail) {
602
- handle(tail);
603
- buffer = '';
638
+ error: () => {
639
+ flushTail();
640
+ if (result) {
641
+ const outcome = outputFromEnvelope(result);
642
+ if (!outcome.ok)
643
+ return outcome.error;
604
644
  }
645
+ return apiError?.text;
646
+ },
647
+ usageLimit: () => {
648
+ flushTail();
649
+ if (apiError?.kind === 'rate_limit')
650
+ return apiError.text ?? 'usage limit reached';
651
+ if (!limitRejected)
652
+ return undefined;
653
+ const { resetsAt } = limitRejected;
654
+ return resetsAt === undefined
655
+ ? 'usage limit reached'
656
+ : `usage limit reached · resets ${new Date(resetsAt * 1000).toISOString()}`;
657
+ },
658
+ finish(stdout) {
659
+ // A binary that exits without flushing a final `\n` must not lose its
660
+ // result.
661
+ flushTail();
605
662
  if (result)
606
663
  return outputFromEnvelope(result);
607
664
  // No `result` event: either the command isn't emitting the stream we
@@ -621,19 +678,42 @@ export function readClaudeStream() {
621
678
  * and the ledger has no way to know. A stale entry must not turn into a
622
679
  * permanently broken surface, so the caller retries once without `--resume`.
623
680
  *
624
- * The observed failure is exit 1, empty stdout, and
625
- * `No conversation found with session ID: <uuid>` on stderr. Matching is
626
- * broader than that string because the wording is not ours to depend on, and it
627
- * is gated by the caller on the run having actually failed — so a *successful*
628
- * run whose reply text happens to quote a session id is never mistaken for one.
681
+ * The observed failure (Claude Code 2.1.296) is exit 1, `No conversation found
682
+ * with session ID: <uuid>` on stderr, and on stdout a lone `result` event with
683
+ * that message in `errors` — no `system`/`init` event, because no session ever
684
+ * started. Matching is broader than that string because the wording is not
685
+ * ours to depend on, and it is gated by the caller on the run having actually
686
+ * failed — so a *successful* run whose reply text happens to quote a session id
687
+ * is never mistaken for one.
688
+ *
689
+ * A stream that announced its session or produced an assistant message is a
690
+ * resume that **worked**, whatever went wrong after it. That guard is what
691
+ * keeps an ordinary failed turn from matching: every stream-json event carries
692
+ * `session_id`, so "the output mentions the session id" — which this used to
693
+ * accept — is true of every resumed run, and a resumed run that then hit a
694
+ * usage limit was retried from scratch and lost its thread's session.
629
695
  */
630
696
  export function looksLikeResumeFailure(output, sessionId) {
631
697
  if (!sessionId)
632
698
  return false;
633
- if (output.includes(sessionId))
634
- return true;
699
+ if (sessionStarted(output))
700
+ return false;
635
701
  return /no (conversation|session)[^\n]{0,40}found|session[^\n]{0,40}not found|--resume|resume[^\n]{0,20}(failed|invalid|expired)/i.test(output);
636
702
  }
703
+ /** Did this output include the `system`/`init` or an `assistant` event of a running session? */
704
+ function sessionStarted(output) {
705
+ for (const line of output.split('\n')) {
706
+ const trimmed = line.trim();
707
+ if (!trimmed.startsWith('{'))
708
+ continue;
709
+ const event = asObject(trimmed);
710
+ if (!event)
711
+ continue;
712
+ if ((event.type === 'system' && event.subtype === 'init') || event.type === 'assistant')
713
+ return true;
714
+ }
715
+ return false;
716
+ }
637
717
  /** Absolute path of the generated MCP config. */
638
718
  export function mcpConfigPath(env = process.env) {
639
719
  return path.join(prohostHome(env), MCP_CONFIG_FILENAME);
@@ -518,6 +518,15 @@ export type CodexOutput = {
518
518
  * the model (verified against 0.144.1, same convention as Claude Code).
519
519
  */
520
520
  export declare function parseCodexOutput(stdout: string): CodexOutput;
521
+ /**
522
+ * The failure a Codex run reported about itself — the last `turn.failed` or
523
+ * `error` event's message — or `undefined` if it reported none.
524
+ *
525
+ * For a run that exited non-zero, whose stdout is otherwise never read: without
526
+ * this such a run reports only "exited 1". Only that message, never the stream
527
+ * around it, which carries command output and would be echoed into a thread.
528
+ */
529
+ export declare function codexFailureMessage(stdout: string): string | undefined;
521
530
  /**
522
531
  * Does this look like the resume having been rejected, rather than the agent
523
532
  * having failed at its actual work?
@@ -1053,6 +1053,27 @@ export function parseCodexOutput(stdout) {
1053
1053
  : 'codex returned no output',
1054
1054
  };
1055
1055
  }
1056
+ /**
1057
+ * The failure a Codex run reported about itself — the last `turn.failed` or
1058
+ * `error` event's message — or `undefined` if it reported none.
1059
+ *
1060
+ * For a run that exited non-zero, whose stdout is otherwise never read: without
1061
+ * this such a run reports only "exited 1". Only that message, never the stream
1062
+ * around it, which carries command output and would be echoed into a thread.
1063
+ */
1064
+ export function codexFailureMessage(stdout) {
1065
+ let failure;
1066
+ for (const line of stdout.split('\n')) {
1067
+ const trimmed = line.trim();
1068
+ if (!trimmed.startsWith('{'))
1069
+ continue;
1070
+ const event = asObject(trimmed);
1071
+ if (event && (event.type === 'turn.failed' || event.type === 'error')) {
1072
+ failure = failureMessage(event) ?? failure;
1073
+ }
1074
+ }
1075
+ return failure;
1076
+ }
1056
1077
  /**
1057
1078
  * Name the tool this item was refused, or `undefined` if it wasn't refused.
1058
1079
  *
package/dist/agent/run.js CHANGED
@@ -33,7 +33,7 @@ import { HarnessAccount } from './account_runtime.js';
33
33
  import { EVENT_AGENT_CONFIG_UPDATED, machineId } from './accounts.js';
34
34
  import { completeRun, describeFailure, isIdempotencyInFlight, postReply } from './api.js';
35
35
  import { MAX_SALVAGE_CHARS, claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
36
- import { APPS_FEATURE, MCP_API_KEY_ENV_VAR, PLUGINS_DISABLED_OVERRIDE, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, extraMcpEntries, isCodexExec, isolateConfiguredServers, listMcpServers, mcpServersOverride, prohostMcpEntry, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
36
+ import { APPS_FEATURE, MCP_API_KEY_ENV_VAR, PLUGINS_DISABLED_OVERRIDE, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, extraMcpEntries, isCodexExec, isolateConfiguredServers, listMcpServers, mcpServersOverride, prohostMcpEntry, codexFailureMessage, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
37
37
  import { ToolEventQueue } from './events.js';
38
38
  import { EXTRA_MCP_FILENAME, loadExtraMcpServers } from './extras.js';
39
39
  import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
@@ -723,6 +723,18 @@ runEnv) {
723
723
  if (mcp.error) {
724
724
  return { ok: false, reason: 'MCP isolation could not be established', detail: mcp.error, durationMs: 0 };
725
725
  }
726
+ /**
727
+ * A turn the Claude subscription's usage limit stopped, worded for the
728
+ * thread: whose limit (this machine's login) and, in Claude Code's own
729
+ * words, when it resets. The only fix is waiting, so the reason says that
730
+ * rather than "exited 1".
731
+ */
732
+ const usageLimitFailure = (said, stderr = '') => ({
733
+ ok: false,
734
+ reason: `Claude usage limit reached on ${options.machine ?? os.hostname()}`,
735
+ detail: [said, stderr.includes(said) ? '' : stderr].filter((part) => part.length > 0).join('\n'),
736
+ durationMs: elapsed(),
737
+ });
726
738
  const failure = (result) => {
727
739
  // A killed run has no reply, but it usually has most of one. Reporting the
728
740
  // tail of what the agent was writing turns "no output for 15 minutes" into
@@ -751,7 +763,20 @@ runEnv) {
751
763
  // completion `error` is capped server-side, and of the two, what the agent
752
764
  // was saying beats the last 500 characters of its logging.
753
765
  const stderrTail = result.stderr.trim().slice(salvaged ? -200 : -500);
754
- const detail = [stderrTail, salvaged ? `unfinished answer: ${salvaged}` : '']
766
+ const limit = killed ? undefined : stream?.usageLimit();
767
+ if (limit)
768
+ return usageLimitFailure(limit, stderrTail);
769
+ // What the brain itself said went wrong — a shaped brain reports it on
770
+ // stdout, which a non-zero exit otherwise leaves unread. First, because the
771
+ // thread shows only the opening of this text.
772
+ const said = killed
773
+ ? undefined
774
+ : (stream?.error() ?? (runtime.brain === 'codex' ? codexFailureMessage(result.stdout) : undefined))?.slice(0, MAX_SALVAGE_CHARS);
775
+ const detail = [
776
+ said ?? '',
777
+ said && stderrTail.includes(said) ? '' : stderrTail,
778
+ salvaged ? `unfinished answer: ${salvaged}` : '',
779
+ ]
755
780
  .filter((part) => part.length > 0)
756
781
  .join('\n');
757
782
  return {
@@ -794,6 +819,9 @@ runEnv) {
794
819
  const resumeFailed = codex ? looksLikeCodexResumeFailure : looksLikeResumeFailure;
795
820
  if (!result.ok &&
796
821
  sessionId &&
822
+ // A usage limit fails a fresh session exactly as it failed this one, and
823
+ // the retry would cost the thread its session for nothing.
824
+ !stream?.usageLimit() &&
797
825
  resumeFailed(`${result.stderr}\n${result.stdout}`, sessionId) &&
798
826
  // Skipped rather than squeezed into a token budget: a retry with no time
799
827
  // left would be killed on the spot and reported as a timeout, which is a
@@ -811,8 +839,11 @@ runEnv) {
811
839
  // transcript is real, it holds however long the agent worked, and the next
812
840
  // delivery on that surface can resume it. Only a turn that failed on its
813
841
  // own — a crash, an unparseable answer — has no continuity worth keeping.
814
- if (!result.timedOut && !result.idleTimedOut && !result.cancelled)
842
+ // A usage limit is not the turn failing on its own either: the session is
843
+ // the thread's, and the next turn after the reset should continue it.
844
+ if (!result.timedOut && !result.idleTimedOut && !result.cancelled && !stream?.usageLimit()) {
815
845
  forgetRecordedSession();
846
+ }
816
847
  return failure(result);
817
848
  }
818
849
  // A denied tool is not an error either brain reports — the turn succeeds and
@@ -852,6 +883,9 @@ runEnv) {
852
883
  toolEvents?.push(stream.drainToolEvents());
853
884
  reportDenials(parsed.denials);
854
885
  if (!parsed.ok) {
886
+ const limit = stream?.usageLimit();
887
+ if (limit)
888
+ return usageLimitFailure(limit);
855
889
  forgetRecordedSession();
856
890
  return {
857
891
  ok: false,
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.12.2";
10
+ export declare const CLI_VERSION = "0.12.3";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.12.2";
12
+ export declare const USER_AGENT = "prohost-cli/0.12.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.12.2';
10
+ export const CLI_VERSION = '0.12.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.12.2",
3
+ "version": "0.12.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",