@oh-my-pi/pi-coding-agent 17.0.3 → 17.0.5

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 (120) hide show
  1. package/CHANGELOG.md +88 -35
  2. package/dist/cli.js +3540 -3521
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/advisor/runtime.d.ts +7 -1
  5. package/dist/types/async/job-manager.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +7 -0
  7. package/dist/types/config/model-resolver.d.ts +8 -0
  8. package/dist/types/config/models-config-schema.d.ts +10 -0
  9. package/dist/types/config/models-config.d.ts +6 -0
  10. package/dist/types/dap/client.d.ts +10 -0
  11. package/dist/types/dap/types.d.ts +6 -5
  12. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  13. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  14. package/dist/types/launch/spawn-options.d.ts +10 -0
  15. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  16. package/dist/types/mnemopi/state.d.ts +32 -9
  17. package/dist/types/modes/components/status-line/component.d.ts +2 -3
  18. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  19. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  20. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  21. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  22. package/dist/types/modes/interactive-mode.d.ts +2 -0
  23. package/dist/types/modes/print-mode.d.ts +4 -0
  24. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  25. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +2 -0
  27. package/dist/types/sdk.d.ts +2 -0
  28. package/dist/types/session/agent-session.d.ts +19 -1
  29. package/dist/types/session/messages.d.ts +6 -0
  30. package/dist/types/task/types.d.ts +6 -6
  31. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  32. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  33. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  34. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  35. package/dist/types/tools/gh.d.ts +5 -3
  36. package/dist/types/tools/hub/index.d.ts +1 -1
  37. package/dist/types/tools/hub/jobs.d.ts +1 -0
  38. package/dist/types/tools/hub/types.d.ts +2 -0
  39. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  40. package/dist/types/utils/git.d.ts +33 -0
  41. package/dist/types/web/search/providers/codex.d.ts +1 -1
  42. package/package.json +12 -12
  43. package/src/advisor/__tests__/advisor.test.ts +150 -0
  44. package/src/advisor/advise-tool.ts +4 -0
  45. package/src/advisor/runtime.ts +38 -14
  46. package/src/async/job-manager.ts +3 -0
  47. package/src/cli/bench-cli.ts +8 -2
  48. package/src/cli/dry-balance-cli.ts +1 -0
  49. package/src/config/model-registry.ts +89 -8
  50. package/src/config/model-resolver.ts +78 -14
  51. package/src/config/models-config-schema.ts +1 -0
  52. package/src/config/settings.ts +3 -1
  53. package/src/dap/client.ts +168 -1
  54. package/src/dap/config.ts +51 -1
  55. package/src/dap/session.ts +575 -234
  56. package/src/dap/types.ts +6 -5
  57. package/src/discovery/agents.ts +2 -2
  58. package/src/extensibility/extensions/runner.ts +6 -4
  59. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  60. package/src/launch/broker.ts +34 -31
  61. package/src/launch/client.ts +7 -1
  62. package/src/launch/spawn-options.test.ts +31 -0
  63. package/src/launch/spawn-options.ts +17 -0
  64. package/src/lsp/types.ts +5 -1
  65. package/src/main.ts +17 -4
  66. package/src/mcp/transports/stdio.test.ts +9 -1
  67. package/src/mnemopi/backend.ts +1 -1
  68. package/src/mnemopi/embed-worker.ts +8 -7
  69. package/src/mnemopi/state.ts +45 -18
  70. package/src/modes/components/ask-dialog.ts +137 -73
  71. package/src/modes/components/status-line/component.ts +2 -2
  72. package/src/modes/components/status-line/segments.ts +20 -3
  73. package/src/modes/components/status-line/types.ts +3 -1
  74. package/src/modes/components/tool-execution.ts +5 -0
  75. package/src/modes/components/transcript-container.ts +18 -111
  76. package/src/modes/components/tree-selector.ts +10 -4
  77. package/src/modes/controllers/event-controller.ts +1 -2
  78. package/src/modes/controllers/input-controller.ts +1 -1
  79. package/src/modes/controllers/selector-controller.ts +29 -8
  80. package/src/modes/interactive-mode.ts +40 -8
  81. package/src/modes/noninteractive-dispose.test.ts +12 -1
  82. package/src/modes/print-mode.ts +21 -9
  83. package/src/modes/rpc/rpc-input.ts +38 -0
  84. package/src/modes/rpc/rpc-mode.ts +7 -2
  85. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  86. package/src/modes/types.ts +2 -0
  87. package/src/modes/utils/ui-helpers.ts +3 -2
  88. package/src/prompts/tools/browser.md +3 -2
  89. package/src/prompts/tools/debug.md +2 -7
  90. package/src/prompts/tools/github.md +6 -1
  91. package/src/prompts/tools/hub.md +4 -2
  92. package/src/prompts/tools/task-async-contract.md +1 -0
  93. package/src/prompts/tools/task.md +9 -2
  94. package/src/sdk.ts +38 -6
  95. package/src/session/agent-session.ts +395 -162
  96. package/src/session/messages.test.ts +91 -0
  97. package/src/session/messages.ts +248 -110
  98. package/src/session/session-loader.ts +31 -3
  99. package/src/slash-commands/builtin-registry.ts +1 -0
  100. package/src/stt/recorder.ts +68 -55
  101. package/src/task/executor.ts +59 -33
  102. package/src/task/index.ts +46 -9
  103. package/src/task/types.ts +11 -8
  104. package/src/task/worktree.ts +10 -0
  105. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  106. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  107. package/src/tools/browser/cmux/socket-client.ts +139 -3
  108. package/src/tools/browser/run-cancellation.ts +4 -0
  109. package/src/tools/browser/tab-protocol.ts +2 -0
  110. package/src/tools/browser/tab-supervisor.ts +21 -11
  111. package/src/tools/browser/tab-worker.ts +199 -33
  112. package/src/tools/debug.ts +3 -0
  113. package/src/tools/gh.ts +40 -2
  114. package/src/tools/hub/index.ts +4 -1
  115. package/src/tools/hub/jobs.ts +42 -1
  116. package/src/tools/hub/types.ts +2 -0
  117. package/src/tools/tool-timeouts.ts +1 -1
  118. package/src/utils/git.ts +237 -0
  119. package/src/web/search/index.ts +9 -5
  120. package/src/web/search/providers/codex.ts +195 -99
@@ -8,6 +8,7 @@ import {
8
8
  replaceLlmImagesWithText,
9
9
  SKILL_PROMPT_MESSAGE_TYPE,
10
10
  type SkillPromptDetails,
11
+ stripImagesFromMessage,
11
12
  } from "./messages";
12
13
 
13
14
  function customMessage(customType: string, attribution: "agent" | "user"): CustomMessage<SkillPromptDetails> {
@@ -125,6 +126,96 @@ describe("convertToLlm", () => {
125
126
  });
126
127
  });
127
128
 
129
+ function settledAssistant(text: string): AssistantMessage {
130
+ return {
131
+ role: "assistant",
132
+ content: [{ type: "text", text }],
133
+ api: "anthropic-messages",
134
+ provider: "anthropic",
135
+ model: "claude-sonnet-4-5",
136
+ usage: {
137
+ input: 100,
138
+ output: 20,
139
+ cacheRead: 0,
140
+ cacheWrite: 0,
141
+ totalTokens: 120,
142
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
143
+ },
144
+ stopReason: "stop",
145
+ timestamp: 1,
146
+ };
147
+ }
148
+
149
+ function userMessage(text: string, timestamp: number): AgentMessage {
150
+ return { role: "user", content: text, attribution: "user", timestamp } as AgentMessage;
151
+ }
152
+
153
+ describe("convertToLlm caching", () => {
154
+ it("reuses the outer array on an exact repeat of the same history", () => {
155
+ const messages: AgentMessage[] = [userMessage("hello", 1), settledAssistant("hi")];
156
+ const first = convertToLlm(messages);
157
+ const second = convertToLlm(messages);
158
+ expect(second).toBe(first);
159
+ });
160
+
161
+ it("reuses the unchanged prefix output on append-only growth", () => {
162
+ const messages: AgentMessage[] = [userMessage("one", 1), settledAssistant("reply one")];
163
+ const first = convertToLlm(messages);
164
+ messages.push(userMessage("two", 2));
165
+ const grown = convertToLlm(messages);
166
+ // New outer array (no held-result aliasing), but the converted prefix is
167
+ // byte-identical and the appended turn is present.
168
+ expect(grown).not.toBe(first);
169
+ expect(grown.length).toBe(first.length + 1);
170
+ expect(grown.slice(0, first.length)).toEqual(first);
171
+ expect(grown[grown.length - 1]?.role).toBe("user");
172
+ });
173
+
174
+ it("recomputes the boundary assistant when a following interrupted-thinking marker appears on growth", () => {
175
+ const messages: AgentMessage[] = [
176
+ abortedAssistant([
177
+ { type: "text", text: "partial answer" },
178
+ { type: "thinking", thinking: "interrupted reasoning" },
179
+ ]),
180
+ ];
181
+ const before = convertToLlm(messages);
182
+ const beforeAssistant = before.find(entry => entry.role === "assistant");
183
+ expect(Array.isArray(beforeAssistant?.content) && beforeAssistant.content.map(b => b.type)).toEqual([
184
+ "text",
185
+ "thinking",
186
+ ]);
187
+
188
+ // Append the continuity marker on the same array: the assistant is now the
189
+ // boundary message and its LLM view must drop the trailing thinking run.
190
+ messages.push(interruptedThinkingContinuity());
191
+ const after = convertToLlm(messages);
192
+ const afterAssistant = after.find(entry => entry.role === "assistant");
193
+ expect(Array.isArray(afterAssistant?.content) && afterAssistant.content.map(b => b.type)).toEqual(["text"]);
194
+ });
195
+
196
+ it("recomputes a message after strip-images invalidates its cache", () => {
197
+ const withImage: AgentMessage = {
198
+ role: "user",
199
+ content: [
200
+ { type: "text", text: "look" },
201
+ { type: "image", data: "aaaa", mimeType: "image/png" },
202
+ ],
203
+ attribution: "user",
204
+ timestamp: 1,
205
+ };
206
+ const messages: AgentMessage[] = [withImage];
207
+ const before = convertToLlm(messages);
208
+ const beforeUser = before.find(entry => entry.role === "user");
209
+ expect(Array.isArray(beforeUser?.content) && beforeUser.content.some(b => b.type === "image")).toBe(true);
210
+
211
+ // Mutate in place through the owner seam, which must invalidate the cache.
212
+ stripImagesFromMessage(withImage);
213
+ const after = convertToLlm(messages);
214
+ const afterUser = after.find(entry => entry.role === "user");
215
+ expect(Array.isArray(afterUser?.content) && afterUser.content.some(b => b.type === "image")).toBe(false);
216
+ });
217
+ });
218
+
128
219
  describe("replaceLlmImagesWithText", () => {
129
220
  it("replaces image blocks in user, developer, and tool-result messages with the placeholder", () => {
130
221
  const converted = convertToLlm([
@@ -5,6 +5,10 @@
5
5
  * and provides a transformer to convert them to LLM-compatible messages.
6
6
  */
7
7
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
8
+ import {
9
+ invalidateMessageCache,
10
+ registerMessageCacheInvalidator,
11
+ } from "@oh-my-pi/pi-agent-core/compaction/message-cache";
8
12
  import {
9
13
  type BranchSummaryMessage,
10
14
  type CompactionSummaryMessage,
@@ -457,6 +461,14 @@ function stripImagesFromArrayContent(content: (TextContent | ImageContent)[]): S
457
461
  * pure local mutation and intentionally does neither.
458
462
  */
459
463
  export function stripImagesFromMessage(message: AgentMessage): number {
464
+ const removed = stripImagesFromMessageContent(message);
465
+ // The mutated message keeps its identity across context rebuilds, so drop its
466
+ // cached estimate/convert before the next pass counts/converts the new shape.
467
+ if (removed > 0) invalidateMessageCache(message);
468
+ return removed;
469
+ }
470
+
471
+ function stripImagesFromMessageContent(message: AgentMessage): number {
460
472
  switch (message.role) {
461
473
  case "user":
462
474
  case "developer":
@@ -751,127 +763,253 @@ function convertImageBearingCustomMessage(message: CustomMessage | HookMessage):
751
763
  }
752
764
 
753
765
  /**
754
- * Transform AgentMessages (including custom types) to LLM-compatible Messages.
766
+ * Per-message conversion result, keyed by message identity. `interruptedNext`
767
+ * records the neighbor state the fragment was built against so an assistant
768
+ * whose following {@link INTERRUPTED_THINKING_MESSAGE_TYPE} marker appears or
769
+ * disappears is recomputed (its LLM view strips the trailing thinking run only
770
+ * while that marker follows).
755
771
  *
756
- * This is used by:
757
- * - Agent's transormToLlm option (for prompt calls and queued messages)
758
- * - Compaction's generateSummary (for summarization)
759
- * - Custom extensions and tools
772
+ * WeakMap (not a symbol tag) is deliberate: `wrapSteeringForModel` and
773
+ * `deobfuscateAgentMessages` spread messages into fresh variants with different
774
+ * content; a symbol-keyed fragment would ride that spread and mis-convert the
775
+ * copy. Identity keying keeps the cache off spread copies.
760
776
  */
761
- export function convertToLlm(messages: AgentMessage[]): Message[] {
762
- return messages.flatMap((m, index): Message[] => {
763
- switch (m.role) {
764
- case "bashExecution":
765
- if (m.excludeFromContext) {
766
- return [];
767
- }
768
- return [
769
- {
770
- role: "user",
771
- content: [{ type: "text", text: bashExecutionToText(m) }],
772
- attribution: "user",
773
- timestamp: m.timestamp,
774
- },
777
+ interface ConvertMemoEntry {
778
+ interruptedNext: boolean;
779
+ fragment: Message[];
780
+ }
781
+ const convertCache = new WeakMap<AgentMessage, ConvertMemoEntry>();
782
+
783
+ // Array-level shortcuts over the per-message memo. The live agent mutates one
784
+ // `AgentMessage[]` identity across a turn: appending new messages and swapping
785
+ // the streaming tail (`context.messages[len-1] = partial → trailing`). Between
786
+ // owner invalidations (prune/shake/strip bump `convertGeneration`) and for a
787
+ // given array identity, only the last index is ever swapped and the array only
788
+ // grows — interior prefix messages are immutable. That invariant lets two
789
+ // shortcuts skip the O(N) re-walk:
790
+ // - exact-repeat: same array, same length, same generation, same tail identity
791
+ // → hand back the same outer array.
792
+ // - slice-on-growth: same array, same generation, length grew → copy the
793
+ // unchanged prefix output and reconvert only the neighbor-sensitive boundary
794
+ // message plus the appended suffix.
795
+ // The tail-identity guard on exact-repeat catches the streaming snapshot swap
796
+ // (partial → trailing is a fresh identity), so a settled tail is never served
797
+ // from a stale mid-stream fragment.
798
+ let convertGeneration = 0;
799
+ let lastConvertInput: AgentMessage[] | undefined;
800
+ let lastConvertLength = 0;
801
+ let lastConvertOutput: Message[] | undefined;
802
+ let lastConvertGeneration = -1;
803
+ let lastConvertTail: AgentMessage | undefined;
804
+ // Output-message count contributed by messages[0 .. lastConvertLength-1), i.e.
805
+ // every message except the last. The last message is neighbor-sensitive (its LLM
806
+ // view drops the trailing thinking run only while an interrupted-thinking marker
807
+ // follows), so growth reconverts it rather than reusing its old fragment.
808
+ let lastConvertPrefixOutputLen = 0;
809
+
810
+ registerMessageCacheInvalidator(message => {
811
+ convertCache.delete(message);
812
+ convertGeneration++;
813
+ });
814
+
815
+ /** Convert one message to its LLM fragment. `interruptedNext` is true only for an
816
+ * assistant turn immediately followed by its interrupted-thinking marker. */
817
+ function convertOne(m: AgentMessage, interruptedNext: boolean): Message[] {
818
+ switch (m.role) {
819
+ case "bashExecution":
820
+ if (m.excludeFromContext) {
821
+ return [];
822
+ }
823
+ return [
824
+ {
825
+ role: "user",
826
+ content: [{ type: "text", text: bashExecutionToText(m) }],
827
+ attribution: "user",
828
+ timestamp: m.timestamp,
829
+ },
830
+ ];
831
+ case "pythonExecution":
832
+ if (m.excludeFromContext) {
833
+ return [];
834
+ }
835
+ return [
836
+ {
837
+ role: "user",
838
+ content: [{ type: "text", text: pythonExecutionToText(m) }],
839
+ attribution: "user",
840
+ timestamp: m.timestamp,
841
+ },
842
+ ];
843
+ case "fileMention": {
844
+ // One `fileMention` can mix `@notes.md` (text) and `@screenshot.png` (image)
845
+ // in the same turn (`generateFileMentionMessages` packs every `@…` into a
846
+ // single message). Splitting by image presence keeps text-only mentions on
847
+ // the higher-priority `developer` slot while routing image attachments
848
+ // through `user`, the only Responses content slot that legitimately accepts
849
+ // `input_image` (Codex chatgpt.com /codex/responses rejects everything else
850
+ // with `Invalid value: 'input_image'`, #3443).
851
+ const wrap = (file: FileMentionMessage["files"][number]): string => {
852
+ const inner = file.content ? `\n${file.content}\n` : "\n";
853
+ return `<file path="${file.path}">${inner}</file>`;
854
+ };
855
+ const textFiles = m.files.filter(file => !file.image);
856
+ const imageFiles = m.files.filter(file => file.image);
857
+ const out: Message[] = [];
858
+ if (textFiles.length > 0) {
859
+ out.push({
860
+ role: "developer",
861
+ content: [{ type: "text" as const, text: textFiles.map(wrap).join("\n") }],
862
+ attribution: "user",
863
+ timestamp: m.timestamp,
864
+ });
865
+ }
866
+ if (imageFiles.length > 0) {
867
+ const content: (TextContent | ImageContent)[] = [
868
+ { type: "text" as const, text: imageFiles.map(wrap).join("\n") },
775
869
  ];
776
- case "pythonExecution":
777
- if (m.excludeFromContext) {
778
- return [];
870
+ for (const file of imageFiles) {
871
+ if (file.image) content.push(file.image);
779
872
  }
873
+ out.push({
874
+ role: "user",
875
+ content,
876
+ attribution: "user",
877
+ timestamp: m.timestamp,
878
+ });
879
+ }
880
+ return out;
881
+ }
882
+ case "custom": {
883
+ if (!isCustomMessageContent(m.content)) return [];
884
+ if (isUserInvokedSkillPrompt(m)) {
780
885
  return [
781
886
  {
782
887
  role: "user",
783
- content: [{ type: "text", text: pythonExecutionToText(m) }],
888
+ content: customMessageContentToLlmContent(m.content),
784
889
  attribution: "user",
785
890
  timestamp: m.timestamp,
786
891
  },
787
892
  ];
788
- case "fileMention": {
789
- // One `fileMention` can mix `@notes.md` (text) and `@screenshot.png` (image)
790
- // in the same turn (`generateFileMentionMessages` packs every `@…` into a
791
- // single message). Splitting by image presence keeps text-only mentions on
792
- // the higher-priority `developer` slot while routing image attachments
793
- // through `user`, the only Responses content slot that legitimately accepts
794
- // `input_image` (Codex chatgpt.com /codex/responses rejects everything else
795
- // with `Invalid value: 'input_image'`, #3443).
796
- const wrap = (file: FileMentionMessage["files"][number]): string => {
797
- const inner = file.content ? `\n${file.content}\n` : "\n";
798
- return `<file path="${file.path}">${inner}</file>`;
799
- };
800
- const textFiles = m.files.filter(file => !file.image);
801
- const imageFiles = m.files.filter(file => file.image);
802
- const out: Message[] = [];
803
- if (textFiles.length > 0) {
804
- out.push({
805
- role: "developer",
806
- content: [{ type: "text" as const, text: textFiles.map(wrap).join("\n") }],
807
- attribution: "user",
808
- timestamp: m.timestamp,
809
- });
810
- }
811
- if (imageFiles.length > 0) {
812
- const content: (TextContent | ImageContent)[] = [
813
- { type: "text" as const, text: imageFiles.map(wrap).join("\n") },
814
- ];
815
- for (const file of imageFiles) {
816
- if (file.image) content.push(file.image);
817
- }
818
- out.push({
819
- role: "user",
820
- content,
821
- attribution: "user",
822
- timestamp: m.timestamp,
823
- });
824
- }
825
- return out;
826
- }
827
- case "custom": {
828
- if (!isCustomMessageContent(m.content)) return [];
829
- if (isUserInvokedSkillPrompt(m)) {
830
- return [
831
- {
832
- role: "user",
833
- content: customMessageContentToLlmContent(m.content),
834
- attribution: "user",
835
- timestamp: m.timestamp,
836
- },
837
- ];
838
- }
839
- const split = convertImageBearingCustomMessage(m);
840
- if (split) return split;
841
- const converted = convertMessageToLlm(m);
842
- return converted ? [converted] : [];
843
- }
844
- case "hookMessage": {
845
- if (!isCustomMessageContent(m.content)) return [];
846
- const split = convertImageBearingCustomMessage(m);
847
- if (split) return split;
848
- const converted = convertMessageToLlm(m);
849
- return converted ? [converted] : [];
850
893
  }
851
- case "assistant": {
852
- // A user-interrupted turn keeps its trailing thinking run on the
853
- // persisted/displayed message so reload and Ctrl+L rebuilds still
854
- // show it. That run is incomplete/unsigned and gets rejected on
855
- // resend, so strip it here — LLM path only — when the hidden
856
- // interrupted-thinking continuity message follows.
857
- const source = followedByInterruptedThinking(messages, index) ? stripDemotedThinkingForLlm(m) : m;
858
- const converted = convertMessageToLlm(source);
859
- return converted ? [converted] : [];
860
- }
861
- case "branchSummary":
862
- case "compactionSummary":
863
- case "user":
864
- case "developer":
865
- case "toolResult": {
866
- // Core roles share one transformer with agent-core —
867
- // duplicating them here is how snapcompact frames once
868
- // silently fell off the provider request.
869
- const converted = convertMessageToLlm(m);
870
- return converted ? [converted] : [];
871
- }
872
- default:
873
- m satisfies never;
874
- return [];
894
+ const split = convertImageBearingCustomMessage(m);
895
+ if (split) return split;
896
+ const converted = convertMessageToLlm(m);
897
+ return converted ? [converted] : [];
875
898
  }
876
- });
899
+ case "hookMessage": {
900
+ if (!isCustomMessageContent(m.content)) return [];
901
+ const split = convertImageBearingCustomMessage(m);
902
+ if (split) return split;
903
+ const converted = convertMessageToLlm(m);
904
+ return converted ? [converted] : [];
905
+ }
906
+ case "assistant": {
907
+ // A user-interrupted turn keeps its trailing thinking run on the
908
+ // persisted/displayed message so reload and Ctrl+L rebuilds still
909
+ // show it. That run is incomplete/unsigned and gets rejected on
910
+ // resend, so strip it here — LLM path only — when the hidden
911
+ // interrupted-thinking continuity message follows.
912
+ const source = interruptedNext ? stripDemotedThinkingForLlm(m) : m;
913
+ const converted = convertMessageToLlm(source);
914
+ return converted ? [converted] : [];
915
+ }
916
+ case "branchSummary":
917
+ case "compactionSummary":
918
+ case "user":
919
+ case "developer":
920
+ case "toolResult": {
921
+ // Core roles share one transformer with agent-core —
922
+ // duplicating them here is how snapcompact frames once
923
+ // silently fell off the provider request.
924
+ const converted = convertMessageToLlm(m);
925
+ return converted ? [converted] : [];
926
+ }
927
+ default:
928
+ m satisfies never;
929
+ return [];
930
+ }
931
+ }
932
+
933
+ /** Cached per-message conversion. Reuses the stored fragment while identity and
934
+ * `interruptedNext` neighbor state hold; recomputes on a neighbor flip. */
935
+ function convertOneCached(m: AgentMessage, interruptedNext: boolean): Message[] {
936
+ const cached = convertCache.get(m);
937
+ if (cached !== undefined && cached.interruptedNext === interruptedNext) return cached.fragment;
938
+ const fragment = convertOne(m, interruptedNext);
939
+ convertCache.set(m, { interruptedNext, fragment });
940
+ return fragment;
941
+ }
942
+
943
+ /**
944
+ * Transform AgentMessages (including custom types) to LLM-compatible Messages.
945
+ *
946
+ * This is used by:
947
+ * - Agent's transormToLlm option (for prompt calls and queued messages)
948
+ * - Compaction's generateSummary (for summarization)
949
+ * - Custom extensions and tools
950
+ *
951
+ * Settled history converts once and is reused per message identity: an
952
+ * append-only turn on the same array re-pays only the new suffix, and an
953
+ * unchanged re-convert of the same array hands back the same outer `Message[]`.
954
+ * Owner mutations (prune/shake/strip-images) invalidate the affected message
955
+ * through the shared registry before the next pass.
956
+ */
957
+ export function convertToLlm(messages: AgentMessage[]): Message[] {
958
+ const len = messages.length;
959
+ const sameArray = messages === lastConvertInput && lastConvertGeneration === convertGeneration;
960
+ const tail = len > 0 ? messages[len - 1] : undefined;
961
+
962
+ // Exact-repeat: same array, same length, same trailing identity → reuse the
963
+ // outer array. The tail-identity check rejects the streaming snapshot swap
964
+ // (partial → settled trailing keeps array identity/length but mints a fresh
965
+ // tail), so a settled tail never reads a stale mid-stream fragment.
966
+ if (sameArray && lastConvertOutput !== undefined && len === lastConvertLength && tail === lastConvertTail) {
967
+ return lastConvertOutput;
968
+ }
969
+
970
+ // Slice-on-growth: same array grew by append. Every interior message is
971
+ // immutable under one array identity, so copy the unchanged prefix output
972
+ // (messages[0 .. lastLen-1)) and reconvert only the old boundary message
973
+ // (neighbor-sensitive: a following interrupted-thinking marker may now exist)
974
+ // plus the appended suffix. The boundary-identity check (old tail still sits
975
+ // at its old index) rejects an in-place interior splice-replace that grew the
976
+ // array while swapping earlier identities, forcing a full rebuild.
977
+ let out: Message[];
978
+ let start: number;
979
+ if (
980
+ sameArray &&
981
+ lastConvertOutput !== undefined &&
982
+ len > lastConvertLength &&
983
+ lastConvertLength > 0 &&
984
+ messages[lastConvertLength - 1] === lastConvertTail &&
985
+ lastConvertPrefixOutputLen <= lastConvertOutput.length
986
+ ) {
987
+ out = lastConvertOutput.slice(0, lastConvertPrefixOutputLen);
988
+ start = lastConvertLength - 1;
989
+ } else {
990
+ out = [];
991
+ start = 0;
992
+ }
993
+
994
+ // Output length contributed by messages[0 .. len-1), captured when the loop
995
+ // reaches the final index so the next growth can reuse this prefix.
996
+ let prefixOutputLen = 0;
997
+ for (let i = start; i < len; i++) {
998
+ if (i === len - 1) prefixOutputLen = out.length;
999
+ const m = messages[i];
1000
+ const interruptedNext = m.role === "assistant" && followedByInterruptedThinking(messages, i);
1001
+ const fragment = convertOneCached(m, interruptedNext);
1002
+ for (const msg of fragment) out.push(msg);
1003
+ }
1004
+ if (len === 0) prefixOutputLen = 0;
1005
+
1006
+ // Record for the next call's shortcuts. `out` is a fresh array (slice or new),
1007
+ // so a prior caller holding the previous `lastConvertOutput` never sees it grow.
1008
+ lastConvertInput = messages;
1009
+ lastConvertLength = len;
1010
+ lastConvertOutput = out;
1011
+ lastConvertGeneration = convertGeneration;
1012
+ lastConvertTail = tail;
1013
+ lastConvertPrefixOutputLen = prefixOutputLen;
1014
+ return out;
877
1015
  }
@@ -271,10 +271,38 @@ async function resolvePersistedBlobRefs(value: unknown, blobStore: BlobStore, ke
271
271
  );
272
272
  }
273
273
 
274
+ /**
275
+ * Cheap synchronous precheck: does this value's tree contain any `blob:sha256:` string?
276
+ * Early-exits on the first hit and allocates no promises, so blob-free entries skip the
277
+ * async {@link resolvePersistedBlobRefs} descent entirely. Conservative — a blob ref in a
278
+ * non-resolved position still returns true, which only costs an extra (no-op) walk.
279
+ */
280
+ function containsBlobRef(value: unknown): boolean {
281
+ if (typeof value === "string") return isBlobRef(value);
282
+ if (Array.isArray(value)) {
283
+ for (const item of value) {
284
+ if (containsBlobRef(item)) return true;
285
+ }
286
+ return false;
287
+ }
288
+ if (typeof value !== "object" || value === null) return false;
289
+ for (const key in value) {
290
+ if (containsBlobRef((value as Record<string, unknown>)[key])) return true;
291
+ }
292
+ return false;
293
+ }
294
+
274
295
  export async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise<void> {
275
- await Promise.all(
276
- entries.filter(entry => entry.type !== "session").map(entry => resolvePersistedBlobRefs(entry, blobStore)),
277
- );
296
+ const pending: Promise<void>[] = [];
297
+ // Interleave precheck + initiation per entry so a positive entry begins resolution at the same
298
+ // relative point as the old filter+map schedule (no scan-all-first pass that could observe a
299
+ // later entry before an earlier resolution mutates it).
300
+ for (const entry of entries) {
301
+ if (entry.type === "session") continue;
302
+ if (!containsBlobRef(entry)) continue;
303
+ pending.push(resolvePersistedBlobRefs(entry, blobStore));
304
+ }
305
+ await Promise.all(pending);
278
306
  }
279
307
 
280
308
  /**
@@ -316,6 +316,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
316
316
  allowArgs: true,
317
317
  getTuiAutocompleteDescription: runtime => {
318
318
  if (!runtime.ctx.loopModeEnabled) return "Loop: off";
319
+ if (runtime.ctx.loopModePaused) return "Loop: paused";
319
320
  if (runtime.ctx.loopLimit) return `Loop: on (${describeLoopLimitRuntime(runtime.ctx.loopLimit)})`;
320
321
  if (runtime.ctx.loopPrompt) return "Loop: on (repeating prompt)";
321
322
  return "Loop: on (waiting for next prompt)";