@adecore/agents 0.31.11 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/chat/acp-backend.d.ts +4 -2
  2. package/dist/chat/acp-backend.js +50 -11
  3. package/dist/chat/acp-protocol.d.ts +29 -4
  4. package/dist/chat/acp-protocol.js +262 -23
  5. package/dist/chat/backend.d.ts +3 -0
  6. package/dist/chat/chat-core.js +6 -3
  7. package/dist/chat/chat-session.js +5 -2
  8. package/dist/chat/claude-backend.js +4 -4
  9. package/dist/chat/claude-protocol.d.ts +4 -1
  10. package/dist/chat/claude-protocol.js +46 -2
  11. package/dist/chat/codex-backend.js +4 -4
  12. package/dist/chat/fixtures/antigravity-1.4.1-question.json +69 -0
  13. package/dist/chat/fixtures/antigravity-1.4.1-quota.json +35 -0
  14. package/dist/chat/fixtures/grok-1.0.62-limits.json +66 -0
  15. package/dist/chat/fixtures/grok-1.0.62-question.json +99 -0
  16. package/dist/chat/json-rpc-transport.js +2 -2
  17. package/dist/chat/projector.js +22 -10
  18. package/dist/providers/accounts/service.js +4 -0
  19. package/dist/providers/claude-provider.js +2 -1
  20. package/dist/providers/detect.d.ts +1 -1
  21. package/dist/providers/detect.js +6 -3
  22. package/dist/providers/registry.d.ts +7 -0
  23. package/dist/providers/registry.js +64 -5
  24. package/dist/skills.d.ts +9 -1
  25. package/dist/skills.js +178 -20
  26. package/dist/testing/fake-acp-capture.d.ts +1 -0
  27. package/dist/testing/fake-acp-capture.js +4 -3
  28. package/dist/usage/accounts.js +3 -2
  29. package/package.json +3 -3
  30. package/src/chat/acp-backend.ts +56 -11
  31. package/src/chat/acp-protocol.ts +308 -26
  32. package/src/chat/backend.ts +5 -1
  33. package/src/chat/chat-core.ts +6 -3
  34. package/src/chat/chat-session.ts +5 -2
  35. package/src/chat/claude-backend.ts +4 -4
  36. package/src/chat/claude-protocol.ts +48 -2
  37. package/src/chat/codex-backend.ts +4 -4
  38. package/src/chat/fixtures/antigravity-1.4.1-question.json +69 -0
  39. package/src/chat/fixtures/antigravity-1.4.1-quota.json +35 -0
  40. package/src/chat/fixtures/grok-1.0.62-limits.json +66 -0
  41. package/src/chat/fixtures/grok-1.0.62-question.json +99 -0
  42. package/src/chat/json-rpc-transport.ts +2 -2
  43. package/src/chat/projector.ts +24 -12
  44. package/src/providers/accounts/service.ts +4 -0
  45. package/src/providers/claude-provider.ts +2 -1
  46. package/src/providers/detect.ts +7 -3
  47. package/src/providers/registry.ts +71 -5
  48. package/src/skills.ts +198 -19
  49. package/src/testing/fake-acp-capture.ts +8 -4
  50. package/src/usage/accounts.ts +3 -2
@@ -1,4 +1,4 @@
1
- import { isRecord, type ChatFileChange, type RuntimeMode } from '@adecore/agent-contracts';
1
+ import { isRecord, type AgentKind, type ChatFileChange, type ChatQuestion, type ChatTurnLimit, type RuntimeMode } from '@adecore/agent-contracts';
2
2
  import { z } from 'zod';
3
3
  import { MODE_ORDER } from '../modes.ts';
4
4
  import { cleanTitle } from '../title-file.ts';
@@ -55,6 +55,32 @@ const PermissionRequestSchema = z.looseObject({
55
55
  options: z.array(PermissionOptionSchema)
56
56
  });
57
57
 
58
+ /*
59
+ * Grok's questions to the person (`_x.ai/ask_user_question` in xai-org/grok-build, `ask_user_question/types.rs`). The
60
+ * answer goes back keyed by the question's text, each a list of the labels picked.
61
+ */
62
+ export const GROK_QUESTION_METHODS: readonly string[] = ['_x.ai/ask_user_question', 'x.ai/ask_user_question'];
63
+
64
+ const GrokQuestionSchema = z.looseObject({
65
+ question: z.string(),
66
+ header: z.string().nullish(),
67
+ options: z.array(z.unknown()).nullish(),
68
+ multiSelect: z.boolean().nullish()
69
+ });
70
+
71
+ const GrokChoiceSchema = z.looseObject({ label: z.string(), description: z.string().nullish() });
72
+
73
+ const GrokQuestionRequestSchema = z.looseObject({ sessionId: z.string(), questions: z.array(z.unknown()) });
74
+
75
+ // The label Grok takes for an answer in a person's own words, which ride along as the question's `notes`.
76
+ const GROK_OWN_ANSWER = 'Other';
77
+
78
+ /*
79
+ * The prefix Antigravity's ACP server 1.4.1 gives the tool call of a question it asks over `session/request_permission`,
80
+ * one request per question with an option per choice (`ExternalOnInteractionHook`).
81
+ */
82
+ const INTERACTION_PREFIX = 'interaction_';
83
+
58
84
  const SessionNotificationSchema = z.looseObject({ sessionId: z.string(), update: z.looseObject({ sessionUpdate: z.string() }) });
59
85
 
60
86
  const PromptResponseSchema = z.looseObject({ stopReason: z.string() });
@@ -484,6 +510,17 @@ const KEPT_PROMPTS = 3;
484
510
  // What Copilot CLI 1.0.101 writes as a message of its own after a call a person turned down; the thread shows the refusal already.
485
511
  const REFUSAL_LINE = 'Info: Operation cancelled by user';
486
512
 
513
+ /*
514
+ * What Antigravity's ACP server 1.4.1 writes in place of an answer once the account's quota ran out (`quota_errors.py`).
515
+ * Its turn still ends with `end_turn`, so this text is all that tells the limit apart.
516
+ */
517
+ const QUOTA_HEAD = 'Usage Limit Reached\n\nYou have reached your current quota for this period.';
518
+
519
+ /* Whether a message may still turn out to be one the thread does not show: the refusal line after a refusal, or a limit message. */
520
+ function withheld(text: string, afterRefusal: boolean): boolean {
521
+ return (afterRefusal && REFUSAL_LINE.startsWith(text)) || QUOTA_HEAD.startsWith(text) || text.startsWith(QUOTA_HEAD);
522
+ }
523
+
487
524
  function foldSpace(text: string): string {
488
525
  return text.replace(/\s+/g, ' ').trim();
489
526
  }
@@ -493,6 +530,9 @@ function str(value: unknown): string | null {
493
530
  }
494
531
 
495
532
  /* A field of the agent's own input, which no schema promises; the first one that is a non-empty string. */
533
+ // Where agents put the command a call runs; Antigravity names it `CommandLine`.
534
+ const COMMAND_KEYS = ['command', 'cmd', 'CommandLine'];
535
+
496
536
  function rawField(raw: unknown, ...keys: string[]): string | null {
497
537
  if (!isRecord(raw)) {
498
538
  return null;
@@ -541,7 +581,7 @@ export function toolRow(tool: Pick<ToolState, 'title' | 'name' | 'kind' | 'rawIn
541
581
  const raw = isRecord(tool.rawInput) ? tool.rawInput : {};
542
582
  switch (tool.kind) {
543
583
  case 'execute':
544
- return { name: 'Bash', input: { ...raw, description, command: rawField(tool.rawInput, 'command', 'cmd') ?? description } };
584
+ return { name: 'Bash', input: { ...raw, description, command: rawField(tool.rawInput, ...COMMAND_KEYS) ?? description } };
545
585
  case 'read':
546
586
  return { name: 'Read', input: { ...raw, description, ...(path === null ? {} : { file_path: path }) } };
547
587
  case 'search':
@@ -593,21 +633,123 @@ export interface AcpPermission {
593
633
  events: BackendEvent[];
594
634
  }
595
635
 
596
- /* The words a turn that ended for another reason than its end leaves in the thread. */
636
+ /* A question an agent asks the person, as the backend acts on it. */
637
+ export interface AcpQuestion {
638
+ requestId: string;
639
+ rpcId: number | string;
640
+ events: BackendEvent[];
641
+ }
642
+
643
+ /* The words a turn that ended for another reason than its end leaves in the thread, and a question it could not answer. */
597
644
  export interface AcpStopWords {
598
645
  maxTokens: string;
599
646
  maxTurnRequests: string;
600
647
  refusal: string;
648
+ ownAnswer: string;
601
649
  }
602
650
 
603
651
  export function acpStopWords(agentName: string): AcpStopWords {
604
652
  return {
605
- maxTokens: `${agentName} stopped: the response reached its token limit.`,
606
- maxTurnRequests: `${agentName} stopped: the turn reached its limit of model requests.`,
607
- refusal: `${agentName} refused to continue this turn.`
653
+ maxTokens: `${agentName} stopped because the reply hit its length limit.`,
654
+ maxTurnRequests: `${agentName} stopped because this turn hit its step limit.`,
655
+ refusal: `${agentName} refused to continue this turn.`,
656
+ ownAnswer: `${agentName} accepts only the answers it offered, so it did not get yours.`
608
657
  };
609
658
  }
610
659
 
660
+ // Grok's code for a request its API turned down with a 429, after its own retry (`RATE_LIMITED_ERROR_CODE` in xai-org/grok-build).
661
+ const GROK_RATE_LIMITED = -32003;
662
+
663
+ // What that 429 says when an account on the free tier spent its usage; any other 429 is a rate limit.
664
+ const GROK_FREE_USAGE = 'subscription:free-usage-exhausted';
665
+
666
+ // The stream error types of an overloaded model, as Grok passes them on in its error text.
667
+ const OVERLOADED_TYPES = /\b(overloaded_error|service_unavailable_error)\b/;
668
+
669
+ /*
670
+ * The limit a failed `session/prompt` names in its JSON-RPC error; null for any other failure. ACP 1 has no code
671
+ * for a limit, so this reads Grok 1.0.62's: -32003 after a 429, which only Grok means that way, and -32603 with the
672
+ * HTTP status or the stream error of an overloaded model once its own retries ran out. Neither says when a limit lifts.
673
+ */
674
+ export function acpErrorLimit(code: number | null, data: unknown, kind: AgentKind | null): ChatTurnLimit | null {
675
+ const text = typeof data === 'string' ? data : isRecord(data) && typeof data.message === 'string' ? data.message : '';
676
+ if (code === GROK_RATE_LIMITED && kind === 'grok') {
677
+ return text.includes(GROK_FREE_USAGE) ? { kind: 'usage' } : { kind: 'overload' };
678
+ }
679
+ if (code !== -32603) {
680
+ return null;
681
+ }
682
+ const status = isRecord(data) && typeof data.http_status === 'number' ? data.http_status : null;
683
+ const overloaded = status === 529 || OVERLOADED_TYPES.test(text) || (status !== null && status >= 500 && /overloaded/i.test(text));
684
+ return overloaded ? { kind: 'overload' } : null;
685
+ }
686
+
687
+ function quotaLimit(quota: { resetsAt: number | null }): ChatTurnLimit {
688
+ return { kind: 'usage', ...(quota.resetsAt === null ? {} : { resetsAt: quota.resetsAt }) };
689
+ }
690
+
691
+ const UNIT_MS: Record<string, number> = { second: 1000, minute: 60_000, hour: 3_600_000, day: 86_400_000 };
692
+
693
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
694
+
695
+ /*
696
+ * When the quota in an Antigravity limit message lifts, rounded up: the server writes the two largest units of the
697
+ * delay and drops the rest (`will reset in 4 days, 23 hours.`), or the time to the minute (`will reset on Oct 21,
698
+ * 2026 14:05 UTC.`). Null when it names neither.
699
+ */
700
+ export function quotaResetOf(text: string, now: number): number | null {
701
+ const relative = /will reset in ([^.]+)\./.exec(text);
702
+ if (relative) {
703
+ let total = 0;
704
+ let smallest = Infinity;
705
+ for (const part of relative[1]!.split(',')) {
706
+ const amount = /^(\d+) (second|minute|hour|day)s?$/.exec(part.trim());
707
+ if (!amount) {
708
+ return null;
709
+ }
710
+ const unit = UNIT_MS[amount[2]!]!;
711
+ total += Number(amount[1]) * unit;
712
+ smallest = Math.min(smallest, unit);
713
+ }
714
+ return now + total + smallest;
715
+ }
716
+ const absolute = /will reset on ([A-Z][a-z]{2}) (\d{1,2}), (\d{4}) (\d{2}):(\d{2}) UTC\./.exec(text);
717
+ const month = absolute ? MONTHS.indexOf(absolute[1]!) : -1;
718
+ if (!absolute || month === -1) {
719
+ return null;
720
+ }
721
+ return Date.UTC(Number(absolute[3]), month, Number(absolute[2]), Number(absolute[4]), Number(absolute[5])) + UNIT_MS.minute!;
722
+ }
723
+
724
+ /* The labels a person picked, out of the one answer a question card gives (choices joined with `, `); null for words of their own. */
725
+ function pickedLabels(answer: string, labels: readonly string[], multiSelect: boolean): string[] | null {
726
+ if (labels.includes(answer)) {
727
+ return [answer];
728
+ }
729
+ if (!multiSelect) {
730
+ return null;
731
+ }
732
+ // A label may hold `, ` itself, so the pieces join up again until they make one.
733
+ const picked: string[] = [];
734
+ let part = '';
735
+ for (const piece of answer.split(', ')) {
736
+ part = part === '' ? piece : `${part}, ${piece}`;
737
+ if (labels.includes(part)) {
738
+ picked.push(part);
739
+ part = '';
740
+ }
741
+ }
742
+ return part === '' && picked.length > 0 ? picked : null;
743
+ }
744
+
745
+ type PendingQuestion =
746
+ | { shape: 'grok'; rpcId: number | string; questions: ChatQuestion[] }
747
+ | { shape: 'interaction'; rpcId: number | string; question: ChatQuestion; options: Array<z.infer<typeof PermissionOptionSchema>> };
748
+
749
+ function cancelledAnswer(pending: PendingQuestion): unknown {
750
+ return pending.shape === 'grok' ? { outcome: 'cancelled' } : { outcome: { outcome: 'cancelled' } };
751
+ }
752
+
611
753
  /*
612
754
  * One ACP session's `session/update` stream, mapped onto backend events. A message's chunks become
613
755
  * one text item, cut where the agent switches to a thought or a tool; a tool call becomes a row that
@@ -619,10 +761,14 @@ export class AcpProtocol {
619
761
  private readonly words: AcpStopWords;
620
762
  private readonly tools = new Map<string, ToolState>();
621
763
  private readonly permissions = new Map<string, { rpcId: number | string; toolCallId: string; options: Array<z.infer<typeof PermissionOptionSchema>> }>();
622
- // `held` while the message may still be the refusal line, whose chunks the thread has not heard yet.
623
- private text: { ref: string; messageId: string | null; text: string; held: boolean } | null = null;
764
+ private readonly questions = new Map<string, PendingQuestion>();
765
+ // `held` while the message may still be the refusal line or a limit message, whose chunks the thread has not heard yet.
766
+ private text: { ref: string; messageId: string | null; text: string; held: boolean; afterRefusal: boolean } | null = null;
624
767
  // Set by a refusal until the next message shows whether it is the refusal line.
625
768
  private refusalLine = false;
769
+ // The limit message of this turn, held back from the thread: it ends the turn as a limit, or shows as text when the turn ends otherwise.
770
+ private quota: { ref: string; text: string; resetsAt: number | null } | null = null;
771
+ private readonly now: () => number;
626
772
  private thought: { ref: string; text: string } | null = null;
627
773
  private counter = 0;
628
774
  private costUsd = 0;
@@ -635,11 +781,12 @@ export class AcpProtocol {
635
781
  private readonly titles: boolean;
636
782
 
637
783
  // `titles` takes the session titles the agent sends as the chat's suggested title.
638
- constructor(generation: number, modes: AcpModeMap, words: AcpStopWords, titles = false) {
784
+ constructor(generation: number, modes: AcpModeMap, words: AcpStopWords, titles = false, now: () => number = Date.now) {
639
785
  this.generation = generation;
640
786
  this.modes = modes;
641
787
  this.words = words;
642
788
  this.titles = titles;
789
+ this.now = now;
643
790
  }
644
791
 
645
792
  /* One `session/update` notification; empty for another session, an unknown update or one that does not parse. */
@@ -752,7 +899,7 @@ export class AcpProtocol {
752
899
  description: tool.title === '' ? null : tool.title,
753
900
  canAllowAlways: options.some((option) => option.kind === 'allow_always')
754
901
  });
755
- const command = rawField(tool.rawInput, 'command', 'cmd');
902
+ const command = rawField(tool.rawInput, ...COMMAND_KEYS);
756
903
  const asked = { kind: tool.kind, title: tool.title === '' ? null : tool.title, commands: [...(command === null ? [] : [command]), ...tool.text] };
757
904
  return { requestId, rpcId, kind: tool.kind, request: asked, events };
758
905
  }
@@ -793,14 +940,122 @@ export class AcpProtocol {
793
940
  return { rpcId: pending.rpcId, result, events: this.toolCall({ toolCallId: pending.toolCallId }, false) };
794
941
  }
795
942
 
796
- /* Every permission request still open, answered as cancelled, which the protocol asks of a client that cancels a turn. */
797
- cancelPermissions(): Array<{ requestId: string; rpcId: number | string; result: unknown }> {
798
- const cancelled = [...this.permissions].map(([requestId, pending]) => ({
799
- requestId,
800
- rpcId: pending.rpcId,
801
- result: { outcome: { outcome: 'cancelled' } }
943
+ /* A Grok `ask_user_question` request; null when it does not parse or asks nothing, which the caller answers with an error. */
944
+ question(rpcId: number | string, sessionId: string, params: unknown): AcpQuestion | null {
945
+ const request = GrokQuestionRequestSchema.safeParse(params);
946
+ if (!request.success || request.data.sessionId !== sessionId) {
947
+ return null;
948
+ }
949
+ const questions = validItems(GrokQuestionSchema, request.data.questions).map((question, index): ChatQuestion => ({
950
+ id: String(index),
951
+ header: question.header ?? '',
952
+ question: question.question,
953
+ choices: validItems(GrokChoiceSchema, question.options).map((choice) => ({ label: choice.label, description: choice.description ?? '' })),
954
+ multiSelect: question.multiSelect === true
802
955
  }));
956
+ if (questions.length === 0) {
957
+ return null;
958
+ }
959
+ return this.ask(rpcId, { shape: 'grok', rpcId, questions });
960
+ }
961
+
962
+ /*
963
+ * A `session/request_permission` that is a question rather than a permission, as Antigravity asks one: one choice of
964
+ * several, never a call to run. Null for every other permission request.
965
+ */
966
+ interaction(rpcId: number | string, sessionId: string, params: unknown): AcpQuestion | null {
967
+ const request = PermissionRequestSchema.safeParse(params);
968
+ if (!request.success || request.data.sessionId !== sessionId) {
969
+ return null;
970
+ }
971
+ const call = request.data.toolCall;
972
+ const raw = call.rawInput;
973
+ const bare = raw === undefined || raw === null || (isRecord(raw) && Object.keys(raw).length === 0);
974
+ if (!call.toolCallId.startsWith(INTERACTION_PREFIX) || !bare || call.kind || request.data.options.length === 0) {
975
+ return null;
976
+ }
977
+ const question: ChatQuestion = {
978
+ id: '0',
979
+ header: '',
980
+ question: call.title ?? '',
981
+ choices: request.data.options.map((option) => ({ label: option.name, description: '' })),
982
+ multiSelect: false
983
+ };
984
+ return this.ask(rpcId, { shape: 'interaction', rpcId, question, options: request.data.options });
985
+ }
986
+
987
+ private ask(rpcId: number | string, pending: PendingQuestion): AcpQuestion {
988
+ const requestId = `acp-${this.generation}-${String(rpcId)}`;
989
+ this.questions.set(requestId, pending);
990
+ const questions = pending.shape === 'grok' ? pending.questions : [pending.question];
991
+ return { requestId, rpcId, events: [...this.closeText(), ...this.closeThought(), { type: 'question.requested', requestId, questions }] };
992
+ }
993
+
994
+ /* The answer to a question in the agent's own shape, from the card's answers keyed by question id. */
995
+ answerQuestion(requestId: string, answers: Record<string, string>): { rpcId: number | string; result: unknown; events: BackendEvent[] } | null {
996
+ const pending = this.questions.get(requestId);
997
+ if (!pending) {
998
+ return null;
999
+ }
1000
+ this.questions.delete(requestId);
1001
+ if (pending.shape === 'interaction') {
1002
+ const given = (answers[pending.question.id] ?? '').trim();
1003
+ const option = pending.options.find((candidate) => candidate.name === given) ?? pending.options.find((candidate) => candidate.optionId === given);
1004
+ if (option === undefined) {
1005
+ return {
1006
+ rpcId: pending.rpcId,
1007
+ result: { outcome: { outcome: 'cancelled' } },
1008
+ events: [{ type: 'note', level: 'warning', text: this.words.ownAnswer }]
1009
+ };
1010
+ }
1011
+ return { rpcId: pending.rpcId, result: { outcome: { outcome: 'selected', optionId: option.optionId } }, events: [] };
1012
+ }
1013
+ const picked: Record<string, string[]> = {};
1014
+ const notes: Record<string, { notes: string }> = {};
1015
+ for (const question of pending.questions) {
1016
+ const given = answers[question.id];
1017
+ if (given === undefined) {
1018
+ continue;
1019
+ }
1020
+ const labels = pickedLabels(
1021
+ given,
1022
+ question.choices.map((choice) => choice.label),
1023
+ question.multiSelect
1024
+ );
1025
+ picked[question.question] = labels ?? [GROK_OWN_ANSWER];
1026
+ if (labels === null) {
1027
+ notes[question.question] = { notes: given };
1028
+ }
1029
+ }
1030
+ return {
1031
+ rpcId: pending.rpcId,
1032
+ result: { outcome: 'accepted', answers: picked, ...(Object.keys(notes).length === 0 ? {} : { annotations: notes }) },
1033
+ events: []
1034
+ };
1035
+ }
1036
+
1037
+ /* Turns one question down, in the shape its agent takes a refusal in. */
1038
+ cancelQuestion(requestId: string): { rpcId: number | string; result: unknown } | null {
1039
+ const pending = this.questions.get(requestId);
1040
+ if (!pending) {
1041
+ return null;
1042
+ }
1043
+ this.questions.delete(requestId);
1044
+ return { rpcId: pending.rpcId, result: cancelledAnswer(pending) };
1045
+ }
1046
+
1047
+ /* Every permission request and question still open, answered as cancelled, which the protocol asks of a client that cancels a turn. */
1048
+ cancelRequests(): Array<{ requestId: string; rpcId: number | string; result: unknown }> {
1049
+ const cancelled = [
1050
+ ...[...this.permissions].map(([requestId, pending]) => ({
1051
+ requestId,
1052
+ rpcId: pending.rpcId,
1053
+ result: { outcome: { outcome: 'cancelled' } } as unknown
1054
+ })),
1055
+ ...[...this.questions].map(([requestId, pending]) => ({ requestId, rpcId: pending.rpcId, result: cancelledAnswer(pending) }))
1056
+ ];
803
1057
  this.permissions.clear();
1058
+ this.questions.clear();
804
1059
  return cancelled;
805
1060
  }
806
1061
 
@@ -808,6 +1063,10 @@ export class AcpProtocol {
808
1063
  return this.permissions.has(requestId);
809
1064
  }
810
1065
 
1066
+ hasQuestion(requestId: string): boolean {
1067
+ return this.questions.has(requestId);
1068
+ }
1069
+
811
1070
  /*
812
1071
  * The end of a turn, from the `stopReason` of its `session/prompt` response. Only a turn the client cancelled
813
1072
  * (`cancelled`) ends aborted: Grok 1.0.62 answers `cancelled` by itself when a person turns a call down, and
@@ -820,6 +1079,15 @@ export class AcpProtocol {
820
1079
  this.refusalLine = false;
821
1080
  const events = [...this.closeText(), ...this.closeThought(), ...this.vendorUsage(isRecord(result) ? result._meta : undefined)];
822
1081
  const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
1082
+ const quota = this.quota;
1083
+ this.quota = null;
1084
+ if (quota !== null && reason !== 'cancelled') {
1085
+ events.push({ type: 'turn.done', ...base, state: 'error', error: quota.text, limit: quotaLimit(quota) });
1086
+ return events;
1087
+ }
1088
+ if (quota !== null) {
1089
+ events.push({ type: 'text.done', ref: quota.ref, text: quota.text });
1090
+ }
823
1091
  switch (reason) {
824
1092
  case 'cancelled':
825
1093
  events.push({ type: 'turn.done', ...base, state: 'aborted' });
@@ -839,15 +1107,24 @@ export class AcpProtocol {
839
1107
  return events;
840
1108
  }
841
1109
 
842
- /* A turn that ended without a `stopReason`: the request failed, or was cancelled before the agent said so. */
843
- abort(promptId: string | undefined, error: string | null): BackendEvent[] {
1110
+ /* A turn that ended without a `stopReason`: the request failed, on a limit its error names, or was cancelled before the agent said so. */
1111
+ abort(promptId: string | undefined, error: string | null, limit: ChatTurnLimit | null = null): BackendEvent[] {
844
1112
  this.refusalLine = false;
845
1113
  const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
846
- return [
847
- ...this.closeText(),
848
- ...this.closeThought(),
849
- error === null ? { type: 'turn.done', ...base, state: 'aborted' } : { type: 'turn.done', ...base, state: 'error', error }
850
- ];
1114
+ const events = [...this.closeText(), ...this.closeThought()];
1115
+ const quota = this.quota;
1116
+ this.quota = null;
1117
+ if (quota !== null) {
1118
+ events.push({ type: 'text.done', ref: quota.ref, text: quota.text });
1119
+ }
1120
+ // A failed request after the limit message still ran into that limit, so it pauses as one when the error names none.
1121
+ const named = limit ?? (quota === null ? null : quotaLimit(quota));
1122
+ events.push(
1123
+ error === null
1124
+ ? { type: 'turn.done', ...base, state: 'aborted' }
1125
+ : { type: 'turn.done', ...base, state: 'error', error, ...(named === null ? {} : { limit: named }) }
1126
+ );
1127
+ return events;
851
1128
  }
852
1129
 
853
1130
  private messageChunk(update: Record<string, unknown>): BackendEvent[] {
@@ -861,12 +1138,13 @@ export class AcpProtocol {
861
1138
  events.push(...this.closeText());
862
1139
  }
863
1140
  if (this.text === null) {
864
- this.text = { ref: messageId === null ? `message-${++this.counter}` : `message-${messageId}`, messageId, text: '', held: this.refusalLine };
1141
+ const ref = messageId === null ? `message-${++this.counter}` : `message-${messageId}`;
1142
+ this.text = { ref, messageId, text: '', held: true, afterRefusal: this.refusalLine };
865
1143
  this.refusalLine = false;
866
1144
  }
867
1145
  const text = this.text;
868
1146
  text.text += chunk.data.content.text;
869
- if (text.held && !REFUSAL_LINE.startsWith(text.text)) {
1147
+ if (text.held && !withheld(text.text, text.afterRefusal)) {
870
1148
  text.held = false;
871
1149
  events.push({ type: 'text.delta', ref: text.ref, text: text.text });
872
1150
  } else if (!text.held) {
@@ -892,7 +1170,11 @@ export class AcpProtocol {
892
1170
  private closeText(): BackendEvent[] {
893
1171
  const text = this.text;
894
1172
  this.text = null;
895
- if (text === null || (text.held && text.text === REFUSAL_LINE)) {
1173
+ if (text === null || (text.held && text.afterRefusal && text.text === REFUSAL_LINE)) {
1174
+ return [];
1175
+ }
1176
+ if (text.held && text.text.startsWith(QUOTA_HEAD)) {
1177
+ this.quota = { ref: text.ref, text: text.text.trim(), resetsAt: quotaResetOf(text.text, this.now()) };
896
1178
  return [];
897
1179
  }
898
1180
  return [{ type: 'text.done', ref: text.ref, text: text.text }];
@@ -47,6 +47,9 @@ export interface BackendLaunch {
47
47
  resumeInstructions?: string | null;
48
48
  // The goal the chat last knew of, which a CLI that takes it up again from its own transcript on a resume still works toward.
49
49
  goal?: ChatGoal;
50
+ // How long the prompt cache the chat last wrote lives, in milliseconds, for a CLI that reads that cache again
51
+ // without saying its lifetime.
52
+ cacheTtlMs?: number;
50
53
  // Folders outside `cwd` the agent may reach without asking. Claude Code takes them as `--add-dir`; Codex's
51
54
  // sandbox reads the whole disk already and keeps writes to the workspace, so it needs nothing.
52
55
  folders?: readonly string[];
@@ -165,7 +168,8 @@ export type BackendEvent =
165
168
  // call did; `monitor` is set when the CLI's own frame already says it is one.
166
169
  | { type: 'background.started'; taskId: string; ref: string | null; monitor: boolean; description: string | null }
167
170
  | { type: 'background.ended'; taskId: string }
168
- | { type: 'usage'; contextTokens?: number; contextWindow?: number; costUsd?: number }
171
+ // `cacheTtlMs` and `cacheExpiresAt` come together, for a CLI that says how long its prompt cache lives.
172
+ | { type: 'usage'; contextTokens?: number; contextWindow?: number; costUsd?: number; cacheTtlMs?: number; cacheExpiresAt?: number }
169
173
  // What the CLI said in passing about the plan it runs on; it belongs to the machine, not the chat.
170
174
  | { type: 'limits'; update: LimitsUpdate }
171
175
  | { type: 'compaction'; preTokens: number | null }
@@ -446,10 +446,12 @@ export class ChatCore {
446
446
  await this.hooks.admit?.(payload.chatId, cwd);
447
447
  // A thread on disk keeps its account too. One it lost still opens, and says why on its next turn.
448
448
  // A new one without an account takes the person's pick for this CLI, which is refused rather than replaced when it went.
449
- const account = stored ? stored.info.account : storedAccount(kind, payload.account ?? this.composerPreferences.for(kind).account);
449
+ // The pick is checked as named, before the default account loses its id, so one that is turned off is refused too.
450
+ const requested = payload.account ?? this.composerPreferences.for(kind).account;
450
451
  if (!stored) {
451
- this.requireAccount(kind, account);
452
+ this.requireAccount(kind, requested);
452
453
  }
454
+ const account = stored ? stored.info.account : storedAccount(kind, requested);
453
455
  const narrowed = (mode: RuntimeMode): RuntimeMode => (this.hooks.runtimeModeFor ? this.hooks.runtimeModeFor(payload.chatId, mode) : mode);
454
456
  // Asked again for a chat on disk, since the host may have listed it since.
455
457
  const hidden = this.hooks.hiddenFor?.(payload.chatId) ? { hidden: true } : {};
@@ -568,7 +570,8 @@ export class ChatCore {
568
570
  if (to === from) {
569
571
  return;
570
572
  }
571
- this.requireAccount(kind, to);
573
+ // As named, so a switch to the default account is refused while it is turned off.
574
+ this.requireAccount(kind, requested);
572
575
  const spoke = session.info.agentSessionId !== null || session.info.usage.turns > 0;
573
576
  if (spoke && !this.canContinue(kind, from, to)) {
574
577
  const label = this.accountLabel(kind, to);
@@ -737,7 +737,7 @@ export class ChatSession {
737
737
  compact(): void {
738
738
  const compaction = this.options.provider.capabilities.compaction;
739
739
  if (compaction === 'none') {
740
- throw new ChatError('chat-unsupported', `${this.options.provider.name} cannot fold its context`);
740
+ throw new ChatError('chat-unsupported', `${this.options.provider.name} cannot compact its context`);
741
741
  }
742
742
  if (compaction === 'prompt') {
743
743
  this.send('/compact');
@@ -899,7 +899,9 @@ export class ChatSession {
899
899
  ...usage,
900
900
  contextTokens: 0,
901
901
  contextWindow: this.options.provider.catalog.contextWindowFor(this.thread.info.selection),
902
- breakdown: undefined
902
+ breakdown: undefined,
903
+ cacheTtlMs: undefined,
904
+ cacheExpiresAt: undefined
903
905
  }
904
906
  })
905
907
  ]);
@@ -1892,6 +1894,7 @@ export class ChatSession {
1892
1894
  resumeNote: this.options.resumeNote?.() ?? null,
1893
1895
  resumeInstructions: this.options.resumeInstructions?.() ?? null,
1894
1896
  ...(info.goal === undefined ? {} : { goal: info.goal }),
1897
+ ...(info.usage.cacheTtlMs === undefined ? {} : { cacheTtlMs: info.usage.cacheTtlMs }),
1895
1898
  ...(folders.length > 0 ? { folders } : {}),
1896
1899
  ...(projectConfig ? {} : { projectConfig }),
1897
1900
  ...(this.options.spawn ? { spawn: this.options.spawn } : {})
@@ -41,7 +41,7 @@ export class ClaudeBackend implements ChatBackend {
41
41
  this.launch = launch;
42
42
  this.host = host;
43
43
  this.allowedTools = options.allowedTools ?? [];
44
- this.protocol = new ClaudeProtocol(launch.goal ?? null, launch.now);
44
+ this.protocol = new ClaudeProtocol(launch.goal ?? null, launch.now, launch.cacheTtlMs ?? null);
45
45
  }
46
46
 
47
47
  get running(): boolean {
@@ -292,10 +292,10 @@ export class ClaudeBackend implements ChatBackend {
292
292
  }
293
293
 
294
294
  private failed(error: unknown): void {
295
- this.rejectSteers(`Claude transport failed: ${errorText(error)}`);
295
+ this.rejectSteers(`Lost the connection to Claude: ${errorText(error)}`);
296
296
  this.protocol.forgetPending();
297
297
  try {
298
- this.host.onEvent({ type: 'failed', message: `Claude transport failed: ${errorText(error)}`, processAlive: this.running });
298
+ this.host.onEvent({ type: 'failed', message: `Lost the connection to Claude: ${errorText(error)}`, processAlive: this.running });
299
299
  } catch (reportError) {
300
300
  console.error('Reporting a Claude transport failure failed:', errorText(reportError));
301
301
  }
@@ -343,7 +343,7 @@ export class ClaudeBackend implements ChatBackend {
343
343
  return;
344
344
  }
345
345
  this.child = null;
346
- this.rejectSteers('Claude exited before acknowledging your message');
346
+ this.rejectSteers('Claude stopped before it got your message');
347
347
  this.protocol.forgetPending();
348
348
  this.host.onEvent({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
349
349
  }