@adecore/agents 0.31.12 → 0.33.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 +258 -21
  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 +55 -3
  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 +303 -24
  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 +57 -3
  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
  }
@@ -596,21 +633,123 @@ export interface AcpPermission {
596
633
  events: BackendEvent[];
597
634
  }
598
635
 
599
- /* 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. */
600
644
  export interface AcpStopWords {
601
645
  maxTokens: string;
602
646
  maxTurnRequests: string;
603
647
  refusal: string;
648
+ ownAnswer: string;
604
649
  }
605
650
 
606
651
  export function acpStopWords(agentName: string): AcpStopWords {
607
652
  return {
608
- maxTokens: `${agentName} stopped: the response reached its token limit.`,
609
- maxTurnRequests: `${agentName} stopped: the turn reached its limit of model requests.`,
610
- 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.`
611
657
  };
612
658
  }
613
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
+
614
753
  /*
615
754
  * One ACP session's `session/update` stream, mapped onto backend events. A message's chunks become
616
755
  * one text item, cut where the agent switches to a thought or a tool; a tool call becomes a row that
@@ -622,10 +761,14 @@ export class AcpProtocol {
622
761
  private readonly words: AcpStopWords;
623
762
  private readonly tools = new Map<string, ToolState>();
624
763
  private readonly permissions = new Map<string, { rpcId: number | string; toolCallId: string; options: Array<z.infer<typeof PermissionOptionSchema>> }>();
625
- // `held` while the message may still be the refusal line, whose chunks the thread has not heard yet.
626
- 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;
627
767
  // Set by a refusal until the next message shows whether it is the refusal line.
628
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;
629
772
  private thought: { ref: string; text: string } | null = null;
630
773
  private counter = 0;
631
774
  private costUsd = 0;
@@ -638,11 +781,12 @@ export class AcpProtocol {
638
781
  private readonly titles: boolean;
639
782
 
640
783
  // `titles` takes the session titles the agent sends as the chat's suggested title.
641
- constructor(generation: number, modes: AcpModeMap, words: AcpStopWords, titles = false) {
784
+ constructor(generation: number, modes: AcpModeMap, words: AcpStopWords, titles = false, now: () => number = Date.now) {
642
785
  this.generation = generation;
643
786
  this.modes = modes;
644
787
  this.words = words;
645
788
  this.titles = titles;
789
+ this.now = now;
646
790
  }
647
791
 
648
792
  /* One `session/update` notification; empty for another session, an unknown update or one that does not parse. */
@@ -796,14 +940,122 @@ export class AcpProtocol {
796
940
  return { rpcId: pending.rpcId, result, events: this.toolCall({ toolCallId: pending.toolCallId }, false) };
797
941
  }
798
942
 
799
- /* Every permission request still open, answered as cancelled, which the protocol asks of a client that cancels a turn. */
800
- cancelPermissions(): Array<{ requestId: string; rpcId: number | string; result: unknown }> {
801
- const cancelled = [...this.permissions].map(([requestId, pending]) => ({
802
- requestId,
803
- rpcId: pending.rpcId,
804
- 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
805
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
+ ];
806
1057
  this.permissions.clear();
1058
+ this.questions.clear();
807
1059
  return cancelled;
808
1060
  }
809
1061
 
@@ -811,6 +1063,10 @@ export class AcpProtocol {
811
1063
  return this.permissions.has(requestId);
812
1064
  }
813
1065
 
1066
+ hasQuestion(requestId: string): boolean {
1067
+ return this.questions.has(requestId);
1068
+ }
1069
+
814
1070
  /*
815
1071
  * The end of a turn, from the `stopReason` of its `session/prompt` response. Only a turn the client cancelled
816
1072
  * (`cancelled`) ends aborted: Grok 1.0.62 answers `cancelled` by itself when a person turns a call down, and
@@ -823,6 +1079,15 @@ export class AcpProtocol {
823
1079
  this.refusalLine = false;
824
1080
  const events = [...this.closeText(), ...this.closeThought(), ...this.vendorUsage(isRecord(result) ? result._meta : undefined)];
825
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
+ }
826
1091
  switch (reason) {
827
1092
  case 'cancelled':
828
1093
  events.push({ type: 'turn.done', ...base, state: 'aborted' });
@@ -842,15 +1107,24 @@ export class AcpProtocol {
842
1107
  return events;
843
1108
  }
844
1109
 
845
- /* A turn that ended without a `stopReason`: the request failed, or was cancelled before the agent said so. */
846
- 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[] {
847
1112
  this.refusalLine = false;
848
1113
  const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
849
- return [
850
- ...this.closeText(),
851
- ...this.closeThought(),
852
- error === null ? { type: 'turn.done', ...base, state: 'aborted' } : { type: 'turn.done', ...base, state: 'error', error }
853
- ];
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;
854
1128
  }
855
1129
 
856
1130
  private messageChunk(update: Record<string, unknown>): BackendEvent[] {
@@ -864,12 +1138,13 @@ export class AcpProtocol {
864
1138
  events.push(...this.closeText());
865
1139
  }
866
1140
  if (this.text === null) {
867
- 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 };
868
1143
  this.refusalLine = false;
869
1144
  }
870
1145
  const text = this.text;
871
1146
  text.text += chunk.data.content.text;
872
- if (text.held && !REFUSAL_LINE.startsWith(text.text)) {
1147
+ if (text.held && !withheld(text.text, text.afterRefusal)) {
873
1148
  text.held = false;
874
1149
  events.push({ type: 'text.delta', ref: text.ref, text: text.text });
875
1150
  } else if (!text.held) {
@@ -895,7 +1170,11 @@ export class AcpProtocol {
895
1170
  private closeText(): BackendEvent[] {
896
1171
  const text = this.text;
897
1172
  this.text = null;
898
- 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()) };
899
1178
  return [];
900
1179
  }
901
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
  }
@@ -46,6 +46,22 @@ function contextTokens(usage: unknown): number {
46
46
  return isRecord(usage) ? num(usage.input_tokens) + num(usage.cache_creation_input_tokens) + num(usage.cache_read_input_tokens) : 0;
47
47
  }
48
48
 
49
+ // How long a prompt cache entry lives, by the field of `usage.cache_creation` that counts what a request wrote with it.
50
+ const CACHE_TTL_MS: Record<string, number> = { ephemeral_5m_input_tokens: 5 * 60_000, ephemeral_1h_input_tokens: 60 * 60_000 };
51
+
52
+ /*
53
+ * The lifetime of the cache a response wrote, or null when it wrote none or names no lifetime. A response that wrote
54
+ * with both answers the shorter, which is when the part of its context that goes cold first does.
55
+ */
56
+ function cacheTtlWritten(usage: unknown): number | null {
57
+ const creation = isRecord(usage) && isRecord(usage.cache_creation) ? usage.cache_creation : null;
58
+ if (creation === null) {
59
+ return null;
60
+ }
61
+ const written = Object.entries(CACHE_TTL_MS).filter(([field]) => num(creation[field]) > 0);
62
+ return written.length === 0 ? null : Math.min(...written.map(([, ttl]) => ttl));
63
+ }
64
+
49
65
  // The kinds of task Claude Code keeps beside its turns that are a command or a monitor; a subagent has a row of its own.
50
66
  const BACKGROUND_TASK_TYPES = new Set(['local_bash', 'monitor_mcp', 'monitor_ws']);
51
67
  const ENDED_TASK_STATUSES = new Set(['completed', 'failed', 'killed', 'stopped']);
@@ -79,6 +95,10 @@ function parseWorkflowProgress(entries: unknown[]): Pick<ChatWorkflow, 'phases'
79
95
  phases.set(index, { index, title: str(entry.title) ?? '' });
80
96
  } else if (entry.type === 'workflow_agent') {
81
97
  const durationMs = intOrNull(entry.durationMs);
98
+ const lastToolSummary = str(entry.lastToolSummary);
99
+ const promptPreview = str(entry.promptPreview);
100
+ const resultPreview = str(entry.resultPreview);
101
+ const error = str(entry.error);
82
102
  agents.set(index, {
83
103
  index,
84
104
  label: str(entry.label) ?? '',
@@ -87,7 +107,11 @@ function parseWorkflowProgress(entries: unknown[]): Pick<ChatWorkflow, 'phases'
87
107
  status: WORKFLOW_AGENT_STATUS[str(entry.state) ?? ''] ?? 'running',
88
108
  startedAt: typeof entry.startedAt === 'number' ? entry.startedAt : null,
89
109
  durationMs: durationMs !== null && durationMs >= 0 ? durationMs : null,
90
- lastTool: str(entry.lastToolName)
110
+ lastTool: str(entry.lastToolName),
111
+ ...(lastToolSummary ? { lastToolSummary } : {}),
112
+ ...(promptPreview ? { promptPreview } : {}),
113
+ ...(resultPreview ? { resultPreview } : {}),
114
+ ...(error ? { error } : {})
91
115
  });
92
116
  }
93
117
  }
@@ -202,6 +226,9 @@ export class ClaudeProtocol {
202
226
  private readonly backgroundTasks = new Set<string>();
203
227
  // Workflows still running, by task id, to the Workflow call that launched each.
204
228
  private readonly workflows = new Map<string, string>();
229
+ // The main-chain response whose cache lifetime the thread has, and the lifetime of the cache last written.
230
+ private cacheMessageId: string | null = null;
231
+ private cacheTtlMs: number | null;
205
232
  // The uuid of the last main-chain assistant frame of the turn, which is the line of the transcript the turn ends on.
206
233
  private lastUuid: string | null = null;
207
234
  // The model each subagent last answered with, so a frame only reports one it had not named yet.
@@ -224,9 +251,11 @@ export class ClaudeProtocol {
224
251
  // Background subagents still at work; Claude Code holds a goal's check until they report.
225
252
  private readonly backgroundAgents = new Set<string>();
226
253
 
227
- constructor(goal: ChatGoal | null = null, now: () => number = () => Date.now()) {
254
+ // `cacheTtlMs` is the lifetime the chat knew before this process, so a resumed CLI that only reads the cache still moves its expiry.
255
+ constructor(goal: ChatGoal | null = null, now: () => number = () => Date.now(), cacheTtlMs: number | null = null) {
228
256
  this.goal = goal;
229
257
  this.now = now;
258
+ this.cacheTtlMs = cacheTtlMs;
230
259
  }
231
260
 
232
261
  /* A stop is on its way; the turn it ends leaves the goal set but nobody working on it. */
@@ -572,6 +601,7 @@ export class ClaudeProtocol {
572
601
  }
573
602
  if (event.type === 'message_start') {
574
603
  const message = isRecord(event.message) ? event.message : {};
604
+ this.noteCache(str(message.id), message.usage, events);
575
605
  this.streamMessageId = str(message.id);
576
606
  this.streamTextCount = 0;
577
607
  this.streamThinkingCount = 0;
@@ -607,6 +637,27 @@ export class ClaudeProtocol {
607
637
  }
608
638
  }
609
639
 
640
+ /*
641
+ * When the prompt cache goes cold, from the first frame of each main-chain response: its `message_start`, or its
642
+ * first `assistant` frame when the CLI streams no partial messages. The lifetime runs from the start of the request,
643
+ * and a response that only read the cache starts the lifetime it was written with again, as a cache hit does.
644
+ */
645
+ private noteCache(messageId: string | null, usage: unknown, events: BackendEvent[]): void {
646
+ if (messageId === null || messageId === this.cacheMessageId || !isRecord(usage)) {
647
+ return;
648
+ }
649
+ this.cacheMessageId = messageId;
650
+ const written = cacheTtlWritten(usage);
651
+ if (written !== null) {
652
+ this.cacheTtlMs = written;
653
+ } else if (num(usage.cache_read_input_tokens) === 0) {
654
+ return;
655
+ }
656
+ if (this.cacheTtlMs !== null) {
657
+ events.push({ type: 'usage', cacheTtlMs: this.cacheTtlMs, cacheExpiresAt: this.now() + this.cacheTtlMs });
658
+ }
659
+ }
660
+
610
661
  private handleAssistant(frame: Frame, events: BackendEvent[]): void {
611
662
  const message = isRecord(frame.message) ? frame.message : {};
612
663
  const messageId = str(message.id) ?? 'message';
@@ -653,6 +704,9 @@ export class ClaudeProtocol {
653
704
  if (tokens > 0) {
654
705
  events.push({ type: 'usage', contextTokens: tokens });
655
706
  }
707
+ if (!parentRef) {
708
+ this.noteCache(str(message.id), message.usage, events);
709
+ }
656
710
  const error = str(frame.error);
657
711
  if (error) {
658
712
  events.push({ type: 'note', level: 'error', text: `The request failed: ${error.replaceAll('_', ' ')}` });
@@ -756,7 +810,7 @@ export class ClaudeProtocol {
756
810
  events.push({ type: 'usage', costUsd: num(frame.total_cost_usd) });
757
811
  }
758
812
  if (frame.is_error === true) {
759
- events.push({ type: 'note', level: 'warning', text: 'A background Claude turn failed; the submitted prompt is still pending.' });
813
+ events.push({ type: 'note', level: 'warning', text: 'Claude could not finish a turn in the background. Your message is still waiting.' });
760
814
  }
761
815
  return;
762
816
  }