@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.
- package/CHANGELOG.md +209 -0
- package/dist/cli.js +3616 -3676
- package/dist/types/advisor/advise-tool.d.ts +3 -0
- package/dist/types/advisor/runtime.d.ts +7 -1
- package/dist/types/async/job-manager.d.ts +2 -0
- package/dist/types/config/model-registry.d.ts +7 -0
- package/dist/types/config/model-resolver.d.ts +8 -0
- package/dist/types/config/models-config-schema.d.ts +10 -0
- package/dist/types/config/models-config.d.ts +6 -0
- package/dist/types/dap/client.d.ts +10 -0
- package/dist/types/dap/types.d.ts +6 -5
- package/dist/types/exec/bash-executor.d.ts +1 -0
- package/dist/types/extensibility/extensions/load-errors.d.ts +3 -0
- package/dist/types/extensibility/extensions/runner.d.ts +4 -2
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
- package/dist/types/launch/spawn-options.d.ts +10 -0
- package/dist/types/launch/spawn-options.test.d.ts +1 -0
- package/dist/types/modes/components/status-line/component.d.ts +10 -3
- package/dist/types/modes/components/status-line/types.d.ts +3 -1
- package/dist/types/modes/components/tool-execution.d.ts +2 -0
- package/dist/types/modes/components/transcript-container.d.ts +4 -3
- package/dist/types/modes/components/tree-selector.d.ts +6 -2
- package/dist/types/modes/components/usage-row.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/print-mode.d.ts +4 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-frame.d.ts +9 -0
- package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
- package/dist/types/modes/setup-wizard/index.d.ts +1 -1
- package/dist/types/modes/setup-wizard/scenes/model.d.ts +3 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +42 -6
- package/dist/types/session/messages.d.ts +6 -0
- package/dist/types/session/session-paths.d.ts +21 -4
- package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
- package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
- package/dist/types/tools/browser/tab-worker.d.ts +2 -0
- package/dist/types/tools/gh.d.ts +5 -3
- package/dist/types/tools/hub/index.d.ts +1 -1
- package/dist/types/tools/hub/jobs.d.ts +1 -0
- package/dist/types/tools/hub/types.d.ts +2 -0
- package/dist/types/tools/tool-timeouts.d.ts +1 -1
- package/dist/types/tools/xdev.d.ts +5 -3
- package/dist/types/utils/git.d.ts +33 -0
- package/dist/types/vibe/__tests__/token-rate.test.d.ts +1 -0
- package/dist/types/vibe/runtime.d.ts +23 -0
- package/dist/types/web/search/providers/codex.d.ts +1 -1
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +150 -0
- package/src/advisor/advise-tool.ts +4 -0
- package/src/advisor/runtime.ts +38 -14
- package/src/async/job-manager.ts +3 -0
- package/src/cli/bench-cli.ts +8 -2
- package/src/cli/dry-balance-cli.ts +1 -0
- package/src/config/model-registry.ts +89 -8
- package/src/config/model-resolver.ts +78 -14
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings.ts +3 -1
- package/src/dap/client.ts +168 -1
- package/src/dap/config.ts +51 -1
- package/src/dap/session.ts +575 -234
- package/src/dap/types.ts +6 -5
- package/src/discovery/agents.ts +2 -2
- package/src/exec/bash-executor.ts +68 -8
- package/src/extensibility/extensions/load-errors.ts +13 -0
- package/src/extensibility/extensions/runner.ts +6 -4
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +41 -6
- package/src/launch/broker.ts +34 -31
- package/src/launch/client.ts +7 -1
- package/src/launch/spawn-options.test.ts +31 -0
- package/src/launch/spawn-options.ts +17 -0
- package/src/lsp/types.ts +5 -1
- package/src/main.ts +25 -4
- package/src/mcp/transports/stdio.test.ts +9 -1
- package/src/modes/components/ask-dialog.ts +137 -73
- package/src/modes/components/chat-transcript-builder.ts +10 -1
- package/src/modes/components/status-line/component.ts +57 -3
- package/src/modes/components/status-line/segments.ts +20 -3
- package/src/modes/components/status-line/types.ts +3 -1
- package/src/modes/components/tool-execution.ts +5 -0
- package/src/modes/components/transcript-container.ts +23 -122
- package/src/modes/components/tree-selector.ts +10 -4
- package/src/modes/components/usage-row.ts +17 -1
- package/src/modes/controllers/command-controller.ts +41 -1
- package/src/modes/controllers/event-controller.ts +7 -3
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/controllers/selector-controller.ts +29 -8
- package/src/modes/interactive-mode.ts +102 -60
- package/src/modes/noninteractive-dispose.test.ts +14 -1
- package/src/modes/print-mode.ts +81 -9
- package/src/modes/rpc/rpc-client.ts +76 -35
- package/src/modes/rpc/rpc-frame.ts +156 -0
- package/src/modes/rpc/rpc-input.ts +38 -0
- package/src/modes/rpc/rpc-mode.ts +11 -4
- package/src/modes/setup-wizard/index.ts +2 -0
- package/src/modes/setup-wizard/scenes/model.ts +134 -0
- package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +9 -3
- package/src/prompts/system/workflow-notice.md +89 -74
- package/src/prompts/tools/browser.md +3 -2
- package/src/prompts/tools/debug.md +2 -7
- package/src/prompts/tools/github.md +6 -1
- package/src/prompts/tools/hub.md +4 -2
- package/src/prompts/tools/task-async-contract.md +1 -0
- package/src/prompts/tools/task.md +9 -2
- package/src/sdk.ts +38 -6
- package/src/session/agent-session.ts +484 -188
- package/src/session/messages.test.ts +91 -0
- package/src/session/messages.ts +248 -110
- package/src/session/session-manager.ts +34 -3
- package/src/session/session-paths.ts +38 -9
- package/src/slash-commands/builtin-registry.ts +1 -0
- package/src/task/executor.ts +104 -33
- package/src/task/index.ts +46 -9
- package/src/task/worktree.ts +10 -0
- package/src/tools/browser/aria/aria-snapshot.ts +36 -8
- package/src/tools/browser/cmux/cmux-tab.ts +2 -1
- package/src/tools/browser/cmux/socket-client.ts +139 -3
- package/src/tools/browser/run-cancellation.ts +4 -0
- package/src/tools/browser/tab-protocol.ts +2 -0
- package/src/tools/browser/tab-supervisor.ts +21 -11
- package/src/tools/browser/tab-worker.ts +199 -33
- package/src/tools/debug.ts +3 -0
- package/src/tools/gh.ts +40 -2
- package/src/tools/hub/index.ts +4 -1
- package/src/tools/hub/jobs.ts +42 -1
- package/src/tools/hub/types.ts +2 -0
- package/src/tools/tool-timeouts.ts +1 -1
- package/src/tools/xdev.ts +11 -4
- package/src/tools/yield.ts +4 -1
- package/src/utils/git.ts +237 -0
- package/src/{modes/components/status-line → utils}/token-rate.ts +6 -0
- package/src/vibe/__tests__/token-rate.test.ts +96 -0
- package/src/vibe/runtime.ts +50 -0
- package/src/web/search/index.ts +9 -5
- package/src/web/search/providers/codex.ts +195 -99
- /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([
|
package/src/session/messages.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
757
|
-
*
|
|
758
|
-
* -
|
|
759
|
-
*
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
777
|
-
|
|
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:
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
158
|
-
|
|
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
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
-
|
|
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)";
|