@oh-my-pi/pi-coding-agent 17.0.4 → 17.0.6

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 (143) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/dist/cli.js +3616 -3676
  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/exec/bash-executor.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/load-errors.d.ts +3 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  17. package/dist/types/launch/spawn-options.d.ts +10 -0
  18. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  19. package/dist/types/modes/components/status-line/component.d.ts +10 -3
  20. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  21. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  22. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  23. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  24. package/dist/types/modes/components/usage-row.d.ts +1 -1
  25. package/dist/types/modes/interactive-mode.d.ts +2 -0
  26. package/dist/types/modes/print-mode.d.ts +4 -0
  27. package/dist/types/modes/rpc/rpc-client.d.ts +1 -1
  28. package/dist/types/modes/rpc/rpc-frame.d.ts +9 -0
  29. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  30. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  31. package/dist/types/modes/setup-wizard/index.d.ts +1 -1
  32. package/dist/types/modes/setup-wizard/scenes/model.d.ts +3 -0
  33. package/dist/types/modes/types.d.ts +2 -0
  34. package/dist/types/sdk.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +42 -6
  36. package/dist/types/session/messages.d.ts +6 -0
  37. package/dist/types/session/session-paths.d.ts +21 -4
  38. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  39. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  40. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  41. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  42. package/dist/types/tools/gh.d.ts +5 -3
  43. package/dist/types/tools/hub/index.d.ts +1 -1
  44. package/dist/types/tools/hub/jobs.d.ts +1 -0
  45. package/dist/types/tools/hub/types.d.ts +2 -0
  46. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  47. package/dist/types/tools/xdev.d.ts +5 -3
  48. package/dist/types/utils/git.d.ts +33 -0
  49. package/dist/types/vibe/__tests__/token-rate.test.d.ts +1 -0
  50. package/dist/types/vibe/runtime.d.ts +23 -0
  51. package/dist/types/web/search/providers/codex.d.ts +1 -1
  52. package/package.json +12 -12
  53. package/src/advisor/__tests__/advisor.test.ts +150 -0
  54. package/src/advisor/advise-tool.ts +4 -0
  55. package/src/advisor/runtime.ts +38 -14
  56. package/src/async/job-manager.ts +3 -0
  57. package/src/cli/bench-cli.ts +8 -2
  58. package/src/cli/dry-balance-cli.ts +1 -0
  59. package/src/config/model-registry.ts +89 -8
  60. package/src/config/model-resolver.ts +78 -14
  61. package/src/config/models-config-schema.ts +1 -0
  62. package/src/config/settings.ts +3 -1
  63. package/src/dap/client.ts +168 -1
  64. package/src/dap/config.ts +51 -1
  65. package/src/dap/session.ts +575 -234
  66. package/src/dap/types.ts +6 -5
  67. package/src/discovery/agents.ts +2 -2
  68. package/src/exec/bash-executor.ts +68 -8
  69. package/src/extensibility/extensions/load-errors.ts +13 -0
  70. package/src/extensibility/extensions/runner.ts +6 -4
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  72. package/src/extensibility/plugins/legacy-pi-compat.ts +41 -6
  73. package/src/launch/broker.ts +34 -31
  74. package/src/launch/client.ts +7 -1
  75. package/src/launch/spawn-options.test.ts +31 -0
  76. package/src/launch/spawn-options.ts +17 -0
  77. package/src/lsp/types.ts +5 -1
  78. package/src/main.ts +25 -4
  79. package/src/mcp/transports/stdio.test.ts +9 -1
  80. package/src/modes/components/ask-dialog.ts +137 -73
  81. package/src/modes/components/chat-transcript-builder.ts +10 -1
  82. package/src/modes/components/status-line/component.ts +57 -3
  83. package/src/modes/components/status-line/segments.ts +20 -3
  84. package/src/modes/components/status-line/types.ts +3 -1
  85. package/src/modes/components/tool-execution.ts +5 -0
  86. package/src/modes/components/transcript-container.ts +23 -122
  87. package/src/modes/components/tree-selector.ts +10 -4
  88. package/src/modes/components/usage-row.ts +17 -1
  89. package/src/modes/controllers/command-controller.ts +41 -1
  90. package/src/modes/controllers/event-controller.ts +7 -3
  91. package/src/modes/controllers/input-controller.ts +1 -1
  92. package/src/modes/controllers/selector-controller.ts +29 -8
  93. package/src/modes/interactive-mode.ts +102 -60
  94. package/src/modes/noninteractive-dispose.test.ts +14 -1
  95. package/src/modes/print-mode.ts +81 -9
  96. package/src/modes/rpc/rpc-client.ts +76 -35
  97. package/src/modes/rpc/rpc-frame.ts +156 -0
  98. package/src/modes/rpc/rpc-input.ts +38 -0
  99. package/src/modes/rpc/rpc-mode.ts +11 -4
  100. package/src/modes/setup-wizard/index.ts +2 -0
  101. package/src/modes/setup-wizard/scenes/model.ts +134 -0
  102. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  103. package/src/modes/types.ts +2 -0
  104. package/src/modes/utils/ui-helpers.ts +9 -3
  105. package/src/prompts/system/workflow-notice.md +89 -74
  106. package/src/prompts/tools/browser.md +3 -2
  107. package/src/prompts/tools/debug.md +2 -7
  108. package/src/prompts/tools/github.md +6 -1
  109. package/src/prompts/tools/hub.md +4 -2
  110. package/src/prompts/tools/task-async-contract.md +1 -0
  111. package/src/prompts/tools/task.md +9 -2
  112. package/src/sdk.ts +38 -6
  113. package/src/session/agent-session.ts +484 -188
  114. package/src/session/messages.test.ts +91 -0
  115. package/src/session/messages.ts +248 -110
  116. package/src/session/session-manager.ts +34 -3
  117. package/src/session/session-paths.ts +38 -9
  118. package/src/slash-commands/builtin-registry.ts +1 -0
  119. package/src/task/executor.ts +104 -33
  120. package/src/task/index.ts +46 -9
  121. package/src/task/worktree.ts +10 -0
  122. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  123. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  124. package/src/tools/browser/cmux/socket-client.ts +139 -3
  125. package/src/tools/browser/run-cancellation.ts +4 -0
  126. package/src/tools/browser/tab-protocol.ts +2 -0
  127. package/src/tools/browser/tab-supervisor.ts +21 -11
  128. package/src/tools/browser/tab-worker.ts +199 -33
  129. package/src/tools/debug.ts +3 -0
  130. package/src/tools/gh.ts +40 -2
  131. package/src/tools/hub/index.ts +4 -1
  132. package/src/tools/hub/jobs.ts +42 -1
  133. package/src/tools/hub/types.ts +2 -0
  134. package/src/tools/tool-timeouts.ts +1 -1
  135. package/src/tools/xdev.ts +11 -4
  136. package/src/tools/yield.ts +4 -1
  137. package/src/utils/git.ts +237 -0
  138. package/src/{modes/components/status-line → utils}/token-rate.ts +6 -0
  139. package/src/vibe/__tests__/token-rate.test.ts +96 -0
  140. package/src/vibe/runtime.ts +50 -0
  141. package/src/web/search/index.ts +9 -5
  142. package/src/web/search/providers/codex.ts +195 -99
  143. /package/dist/types/{modes/components/status-line → utils}/token-rate.d.ts +0 -0
@@ -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
  }
@@ -446,6 +446,13 @@ export class SessionManager {
446
446
  #inMemoryArtifactCounter = 0;
447
447
 
448
448
  #suppressBreadcrumb = false;
449
+ /**
450
+ * The last breadcrumb this manager wrote marked a lazy `/new` boundary whose
451
+ * JSONL is not yet on disk. Cleared (and the crumb re-stamped non-fresh) once
452
+ * the session materializes, so a materialized-then-deleted session still falls
453
+ * back to the most-recent session instead of being treated as a fresh crumb.
454
+ */
455
+ #breadcrumbFresh = false;
449
456
  #sessionNameChangedCallbacks = new Set<() => void>();
450
457
 
451
458
  private constructor(cwd: string, sessionDir: string, persist: boolean, storage: SessionStorage) {
@@ -458,8 +465,18 @@ export class SessionManager {
458
465
  if (persist && sessionDir) this.#storage.ensureDirSync(sessionDir);
459
466
  }
460
467
 
461
- #rememberBreadcrumb(cwd: string, sessionFile: string): void {
462
- if (!this.#suppressBreadcrumb) writeTerminalBreadcrumb(cwd, sessionFile);
468
+ #rememberBreadcrumb(cwd: string, sessionFile: string, fresh = false): void {
469
+ this.#breadcrumbFresh = fresh;
470
+ if (!this.#suppressBreadcrumb) writeTerminalBreadcrumb(cwd, sessionFile, fresh);
471
+ }
472
+
473
+ /**
474
+ * Re-stamp a fresh `/new` breadcrumb as non-fresh once the session has
475
+ * materialized on disk. A no-op unless the current breadcrumb is still fresh.
476
+ */
477
+ #materializeBreadcrumb(): void {
478
+ if (!this.#breadcrumbFresh || !this.#sessionFile) return;
479
+ this.#rememberBreadcrumb(this.#cwd, this.#sessionFile, false);
463
480
  }
464
481
 
465
482
  #clearDiskError(): void {
@@ -601,6 +618,7 @@ export class SessionManager {
601
618
  this.#closeWriterEventually();
602
619
  this.#storage.writeTextSync(this.#sessionFile, body);
603
620
  this.#fileIsCurrent = true;
621
+ this.#materializeBreadcrumb();
604
622
  this.#rewriteRequired = false;
605
623
  this.#hasTitleSlot = true;
606
624
  } catch (err) {
@@ -626,6 +644,7 @@ export class SessionManager {
626
644
  async () => {
627
645
  if (await this.#runFencedAtomicRewrite(startEpoch)) {
628
646
  this.#fileIsCurrent = true;
647
+ this.#materializeBreadcrumb();
629
648
  this.#rewriteRequired = false;
630
649
  this.#hasTitleSlot = true;
631
650
  }
@@ -808,7 +827,7 @@ export class SessionManager {
808
827
  this.#sessionFile =
809
828
  forcedSessionFile ??
810
829
  path.join(this.#sessionDir, `${fileSafeTimestamp(timestamp)}_${this.#sessionId}.jsonl`);
811
- this.#rememberBreadcrumb(this.#cwd, this.#sessionFile);
830
+ this.#rememberBreadcrumb(this.#cwd, this.#sessionFile, true);
812
831
  } else {
813
832
  this.#sessionFile = undefined;
814
833
  }
@@ -2055,6 +2074,18 @@ export class SessionManager {
2055
2074
  let chosenSession: string | null | undefined;
2056
2075
 
2057
2076
  if (breadcrumb) {
2077
+ // A fresh `/new` boundary whose JSONL was never materialized (lazy
2078
+ // new-session persistence, then a process exit before any assistant
2079
+ // output). Honor the boundary: start fresh rather than falling back to
2080
+ // findMostRecentSession(), which would resurrect the pre-`/new`
2081
+ // transcript. A materialized (or genuinely stale/deleted) crumb reports
2082
+ // exists=false only when fresh, so this never masks a real stale crumb.
2083
+ if (breadcrumb.fresh && !breadcrumb.exists) {
2084
+ const manager = new SessionManager(cwd, dir, true, storage);
2085
+ manager.#resetToNewSession();
2086
+ return manager;
2087
+ }
2088
+
2058
2089
  // Recover stale crumbs: a subagent open (pre-fix) may have pointed this
2059
2090
  // terminal's breadcrumb at an artifact child; resume the parent instead.
2060
2091
  breadcrumb.sessionFile = resolveBreadcrumbToInteractiveRoot(breadcrumb.sessionFile);
@@ -146,28 +146,54 @@ export function computeDefaultSessionDir(
146
146
  * Write a breadcrumb linking the current terminal to a session file.
147
147
  * The breadcrumb contains the cwd and session path so --continue can
148
148
  * find "this terminal's last session" even when running concurrent instances.
149
+ *
150
+ * `fresh` marks a `/new` (or freshly-minted) session boundary whose JSONL is
151
+ * not yet materialized (new-session persistence is lazy until assistant output
152
+ * exists). A fresh breadcrumb is honored by {@link readTerminalBreadcrumbEntry}
153
+ * even when its target file is still absent, so relaunch/auto-resume reopens the
154
+ * post-`/new` session instead of falling back to the pre-`/new` transcript. Once
155
+ * the session materializes the caller rewrites the breadcrumb with `fresh:false`
156
+ * so a later external delete is still treated as a genuinely stale crumb.
149
157
  */
150
- export function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
158
+ export function writeTerminalBreadcrumb(cwd: string, sessionFile: string, fresh = false): void {
151
159
  const terminalId = getTerminalId();
152
160
  if (!terminalId) return;
153
161
 
154
162
  const breadcrumbDir = getTerminalSessionsDir();
155
163
  const breadcrumbFile = path.join(breadcrumbDir, terminalId);
156
- const content = `${cwd}\n${sessionFile}\n`;
157
- // Best-effort don't break session creation if breadcrumb fails
158
- Bun.write(breadcrumbFile, content).catch(() => {});
164
+ const content = fresh ? `${cwd}\n${sessionFile}\nfresh\n` : `${cwd}\n${sessionFile}\n`;
165
+ // Synchronous + best-effort. Infrequent (session create/switch/reset, never
166
+ // per-append), and writing in order matters: a lazy `/new` fresh crumb is
167
+ // re-stamped non-fresh the instant the session materializes, so an async
168
+ // fire-and-forget could land the two writes out of order and leave a
169
+ // materialized session marked fresh.
170
+ try {
171
+ fs.mkdirSync(breadcrumbDir, { recursive: true });
172
+ fs.writeFileSync(breadcrumbFile, content);
173
+ } catch (err) {
174
+ if (!isEnoent(err)) logger.debug("Terminal breadcrumb write failed", { err });
175
+ }
159
176
  }
160
177
 
161
178
  export interface TerminalBreadcrumb {
162
179
  cwd: string;
163
180
  sessionFile: string;
181
+ /** The recorded session file exists on disk right now. */
182
+ exists: boolean;
183
+ /** Recorded as a `/new` fresh-session boundary whose JSONL may not exist yet. */
184
+ fresh: boolean;
164
185
  }
165
186
 
166
187
  /**
167
188
  * Read the raw terminal breadcrumb for the current terminal.
168
- * Returns the recorded cwd + session file (verified to exist) regardless of
169
- * whether the recorded cwd still matches the current one. Callers decide how
170
- * to interpret a cwd mismatch (e.g. a moved/renamed worktree).
189
+ * Returns the recorded cwd + session file regardless of whether the recorded
190
+ * cwd still matches the current one. Callers decide how to interpret a cwd
191
+ * mismatch (e.g. a moved/renamed worktree).
192
+ *
193
+ * A missing target file yields `null` UNLESS the breadcrumb is a `fresh`
194
+ * boundary — a lazy `/new` session whose JSONL was never written — in which case
195
+ * the entry is returned with `exists:false` so the caller can distinguish it
196
+ * from a genuinely stale/deleted breadcrumb.
171
197
  */
172
198
  export async function readTerminalBreadcrumbEntry(): Promise<TerminalBreadcrumb | null> {
173
199
  const terminalId = getTerminalId();
@@ -181,10 +207,13 @@ export async function readTerminalBreadcrumbEntry(): Promise<TerminalBreadcrumb
181
207
 
182
208
  const breadcrumbCwd = lines[0];
183
209
  const sessionFile = lines[1];
210
+ const fresh = lines[2] === "fresh";
184
211
 
185
- // Verify the session file still exists
186
212
  const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
187
- if (stat?.isFile()) return { cwd: breadcrumbCwd, sessionFile };
213
+ const exists = stat?.isFile() === true;
214
+ // A materialized target resumes normally; a missing target is honored only
215
+ // for a fresh `/new` boundary (never-written lazy session).
216
+ if (exists || fresh) return { cwd: breadcrumbCwd, sessionFile, exists, fresh };
188
217
  } catch (err) {
189
218
  if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
190
219
  // Breadcrumb doesn't exist or is corrupt — fall through
@@ -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)";