@oh-my-pi/pi-coding-agent 16.2.3 → 16.2.4

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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Hard-cap helper for host→guest collab frames.
3
+ *
4
+ * The host wraps every {@link CollabFrame} in an AES-GCM envelope and ships it
5
+ * through the relay's WebSocket. WebSocket servers enforce a per-frame
6
+ * `maxPayloadLength` (Bun's default is 16 MB; many proxies cap lower). A
7
+ * single oversized payload — typically a `read`/`bash`/`search` tool result
8
+ * captured as one multi-megabyte string, or a tool result whose `content`
9
+ * array holds thousands of small blocks — would otherwise ship as its own
10
+ * oversized frame and trip that limit, killing the host's WebSocket with
11
+ * `1006 Received too big message`. `CollabSocket` treats 1006 as transient
12
+ * and reconnects, the next guest hello triggers the same oversized send, and
13
+ * the loop never breaks (issue #3739).
14
+ *
15
+ * This helper bounds any JSON-serializable payload below
16
+ * {@link MAX_REPLICATED_PAYLOAD_BYTES}. Already-small payloads pass through
17
+ * untouched; oversized ones are returned as a deep-cloned shadow where long
18
+ * strings are head-truncated AND long arrays are head-clipped, with
19
+ * `[…N chars elided for collab session]` / `[…N items elided for collab
20
+ * session]` markers. Both axes are needed: string truncation alone leaves
21
+ * the cap unenforced for a payload built of many short strings, where no
22
+ * field exceeds the per-string floor.
23
+ */
24
+
25
+ /**
26
+ * Per-payload ceiling for host→guest frames. Bun's default WebSocket
27
+ * `maxPayloadLength` is 16 MB; we leave a generous margin so the AES-GCM
28
+ * envelope (+ IV + tag), the 4-byte peer header, and the outer wire wrapper
29
+ * fit comfortably under that on every reasonable relay.
30
+ */
31
+ export const MAX_REPLICATED_PAYLOAD_BYTES = 1 * 1024 * 1024;
32
+
33
+ /**
34
+ * Progressive shrink passes. Each pass tightens both the per-string cap and
35
+ * the per-array head limit; the loop stops at the first pass whose output
36
+ * fits {@link MAX_REPLICATED_PAYLOAD_BYTES}. The schedule is concrete (not
37
+ * recomputed) so the failure modes the helper guards against are visible:
38
+ *
39
+ * - One giant string → the first pass already truncates it under 64 KB.
40
+ * - Array of many small blocks (e.g. a tool result with thousands of
41
+ * `{type:"text", text:"..."}` content items) → later passes head-clip the
42
+ * array to a small sample with a `[…N items elided]` summary element.
43
+ *
44
+ * The final pass clamps every string to 64 B and every array to one element,
45
+ * so even pathological mixes converge.
46
+ */
47
+ interface ShrinkPass {
48
+ stringCap: number;
49
+ arrayLimit: number;
50
+ }
51
+
52
+ const SHRINK_PASSES: readonly ShrinkPass[] = [
53
+ { stringCap: 64 * 1024, arrayLimit: 256 },
54
+ { stringCap: 16 * 1024, arrayLimit: 128 },
55
+ { stringCap: 4 * 1024, arrayLimit: 64 },
56
+ { stringCap: 1 * 1024, arrayLimit: 32 },
57
+ { stringCap: 256, arrayLimit: 16 },
58
+ { stringCap: 256, arrayLimit: 4 },
59
+ { stringCap: 64, arrayLimit: 1 },
60
+ ];
61
+
62
+ const STRING_ELISION_RESERVE = 80;
63
+
64
+ /**
65
+ * Recursively walk `value`, head-truncating any string longer than
66
+ * `stringCap` and head-clipping any array longer than `arrayLimit`. Returns
67
+ * a freshly built deep clone — every object/array is rebuilt so the
68
+ * recursive output can be safely serialized in isolation. Cheap to call on
69
+ * small values: short strings, numbers, and booleans pass through without
70
+ * allocation.
71
+ */
72
+ function shrinkWalk(value: unknown, stringCap: number, arrayLimit: number): unknown {
73
+ if (typeof value === "string") {
74
+ if (value.length <= stringCap) return value;
75
+ const headLen = Math.max(0, stringCap - STRING_ELISION_RESERVE);
76
+ return `${value.slice(0, headLen)}\n…[${value.length - headLen} chars elided for collab session]`;
77
+ }
78
+ if (Array.isArray(value)) {
79
+ const keep = Math.min(value.length, arrayLimit);
80
+ const elided = value.length - keep;
81
+ const out: unknown[] = new Array(elided > 0 ? keep + 1 : keep);
82
+ for (let i = 0; i < keep; i++) out[i] = shrinkWalk(value[i], stringCap, arrayLimit);
83
+ if (elided > 0) out[keep] = `…[${elided} items elided for collab session]`;
84
+ return out;
85
+ }
86
+ if (value && typeof value === "object") {
87
+ const src = value as Record<string, unknown>;
88
+ const out: Record<string, unknown> = {};
89
+ for (const k in src) out[k] = shrinkWalk(src[k], stringCap, arrayLimit);
90
+ return out;
91
+ }
92
+ return value;
93
+ }
94
+
95
+ /**
96
+ * Return `value` unchanged when its JSON serialization already fits
97
+ * {@link MAX_REPLICATED_PAYLOAD_BYTES}; otherwise return a deep-cloned
98
+ * shadow shrunk along both string and array axes until the payload fits.
99
+ * The function is generic over `T` because the wire shape is preserved:
100
+ * only string leaves and array tails change; discriminator fields, ids, and
101
+ * other small metadata pass through untouched.
102
+ */
103
+ export function shrinkForReplication<T>(value: T): T {
104
+ if (JSON.stringify(value).length <= MAX_REPLICATED_PAYLOAD_BYTES) return value;
105
+ let shrunk: unknown = value;
106
+ for (const pass of SHRINK_PASSES) {
107
+ shrunk = shrinkWalk(value, pass.stringCap, pass.arrayLimit);
108
+ if (JSON.stringify(shrunk).length <= MAX_REPLICATED_PAYLOAD_BYTES) return shrunk as T;
109
+ }
110
+ return shrunk as T;
111
+ }
@@ -0,0 +1,25 @@
1
+ import * as imageGen from "../tools/image-gen";
2
+ import * as webSearch from "../web/search";
3
+
4
+ interface ProviderGlobalSettings {
5
+ get(path: "providers.webSearchExclude"): unknown;
6
+ get(path: "providers.webSearch"): unknown;
7
+ get(path: "providers.image"): unknown;
8
+ }
9
+
10
+ export function applyProviderGlobalsFromSettings(settings: ProviderGlobalSettings): void {
11
+ const excludedWebSearchProviders = settings.get("providers.webSearchExclude");
12
+ if (Array.isArray(excludedWebSearchProviders)) {
13
+ webSearch.setExcludedSearchProviders(excludedWebSearchProviders.filter(webSearch.isSearchProviderId));
14
+ }
15
+
16
+ const webSearchProvider = settings.get("providers.webSearch");
17
+ if (typeof webSearchProvider === "string" && webSearch.isSearchProviderPreference(webSearchProvider)) {
18
+ webSearch.setPreferredSearchProvider(webSearchProvider);
19
+ }
20
+
21
+ const imageProvider = settings.get("providers.image");
22
+ if (imageGen.isImageProviderPreference(imageProvider)) {
23
+ imageGen.setPreferredImageProvider(imageProvider);
24
+ }
25
+ }
@@ -9,7 +9,7 @@ const OWNER_ID = "julia-prelude-tests";
9
9
  describe.skipIf(!HAS_JULIA)("eval Julia prelude helpers", () => {
10
10
  afterEach(async () => {
11
11
  await disposeJuliaKernelSessionsByOwner(OWNER_ID);
12
- });
12
+ }, 30_000);
13
13
 
14
14
  it("supports output ranges, JSON queries, metadata, and ANSI stripping", async () => {
15
15
  using tempDir = TempDir.createSync("@omp-eval-julia-output-");
@@ -44,7 +44,7 @@ nothing
44
44
  expect(result.output).toContain("STRIPPED=red");
45
45
  expect(result.output).toContain("META=alpha:true");
46
46
  expect(result.output).toContain("MULTI=2:alpha:json");
47
- }, 30_000);
47
+ }, 60_000);
48
48
 
49
49
  it("surfaces the exception type and message in the error output, not just stack frames", async () => {
50
50
  using tempDir = TempDir.createSync("@omp-eval-julia-error-");
@@ -147,16 +147,42 @@ export function startMemoryStartupTask(options: {
147
147
  });
148
148
  }
149
149
 
150
- /**
151
- * Build memory usage instructions for prompt injection.
152
- */
153
- export async function buildMemoryToolDeveloperInstructions(
150
+ interface MemoryInstructionSession {
151
+ sessionManager: Pick<AgentSession["sessionManager"], "getSessionFile">;
152
+ }
153
+
154
+ interface MemoryToolDeveloperInstructionsSnapshot {
155
+ summary: string;
156
+ learned: string;
157
+ }
158
+
159
+ interface CachedMemoryToolDeveloperInstructions {
160
+ sessionFile: string | undefined;
161
+ snapshot: MemoryToolDeveloperInstructionsSnapshot | undefined;
162
+ value: string | undefined;
163
+ }
164
+
165
+ const memoryToolDeveloperInstructionsBySession = new WeakMap<
166
+ MemoryInstructionSession,
167
+ CachedMemoryToolDeveloperInstructions
168
+ >();
169
+ const memoryToolDeveloperInstructionsByRoot = new Map<string, MemoryToolDeveloperInstructionsSnapshot | undefined>();
170
+
171
+ function getMemoryInstructionRoot(agentDir: string, settings: Settings): string {
172
+ return getMemoryRoot(agentDir, settings.getCwd());
173
+ }
174
+
175
+ function getMemoryInstructionSessionFile(session: MemoryInstructionSession): string | undefined {
176
+ return session.sessionManager.getSessionFile() ?? undefined;
177
+ }
178
+
179
+ async function readMemoryToolDeveloperInstructionsSnapshot(
154
180
  agentDir: string,
155
181
  settings: Settings,
156
- ): Promise<string | undefined> {
182
+ ): Promise<MemoryToolDeveloperInstructionsSnapshot | undefined> {
157
183
  const cfg = loadMemoryConfig(settings);
158
184
  if (!cfg.enabled) return undefined;
159
- const memoryRoot = getMemoryRoot(agentDir, settings.getCwd());
185
+ const memoryRoot = getMemoryInstructionRoot(agentDir, settings);
160
186
 
161
187
  let summary = "";
162
188
  try {
@@ -166,9 +192,21 @@ export async function buildMemoryToolDeveloperInstructions(
166
192
  // so any captured lessons still surface on their own.
167
193
  }
168
194
  const learned = await readLearnedLessons(memoryRoot);
169
- if (!summary && !learned) return undefined;
195
+ return { summary, learned };
196
+ }
197
+
198
+ function renderMemoryToolDeveloperInstructionsSnapshot(
199
+ snapshot: MemoryToolDeveloperInstructionsSnapshot | undefined,
200
+ settings: Settings,
201
+ ): string | undefined {
202
+ if (!snapshot) return undefined;
203
+ const cfg = loadMemoryConfig(settings);
204
+ if (!cfg.enabled) return undefined;
205
+ if (!snapshot.summary && !snapshot.learned) return undefined;
170
206
 
171
- const summaryOut = summary ? truncateByApproxTokens(summary, cfg.summaryInjectionTokenLimit).trim() : "";
207
+ const summaryOut = snapshot.summary
208
+ ? truncateByApproxTokens(snapshot.summary, cfg.summaryInjectionTokenLimit).trim()
209
+ : "";
172
210
  // Lessons share ONE injection budget with the summary so the combined block
173
211
  // stays within `summaryInjectionTokenLimit` (~4 chars/token, matching
174
212
  // truncateByApproxTokens). With no summary, lessons get the whole budget.
@@ -176,7 +214,8 @@ export async function buildMemoryToolDeveloperInstructions(
176
214
  // can exceed `limit * 4` chars and drive the remainder negative — when the
177
215
  // summary already fills the budget, lessons are simply dropped.
178
216
  const learnedBudget = Math.max(0, cfg.summaryInjectionTokenLimit - Math.ceil(summaryOut.length / 4));
179
- const learnedOut = learned && learnedBudget > 0 ? truncateByApproxTokens(learned, learnedBudget).trim() : "";
217
+ const learnedOut =
218
+ snapshot.learned && learnedBudget > 0 ? truncateByApproxTokens(snapshot.learned, learnedBudget).trim() : "";
180
219
  if (!summaryOut && !learnedOut) return undefined;
181
220
 
182
221
  return prompt.render(readPathTemplate, {
@@ -185,6 +224,72 @@ export async function buildMemoryToolDeveloperInstructions(
185
224
  });
186
225
  }
187
226
 
227
+ function cacheMemoryToolDeveloperInstructions(
228
+ session: MemoryInstructionSession,
229
+ sessionFile: string | undefined,
230
+ snapshot: MemoryToolDeveloperInstructionsSnapshot | undefined,
231
+ settings: Settings,
232
+ ): string | undefined {
233
+ const value = renderMemoryToolDeveloperInstructionsSnapshot(snapshot, settings);
234
+ memoryToolDeveloperInstructionsBySession.set(session, { sessionFile, snapshot, value });
235
+ return value;
236
+ }
237
+
238
+ /**
239
+ * Drop the per-session memory instruction snapshot after explicit memory state
240
+ * changes that must affect the active conversation immediately, such as
241
+ * `/memory clear`.
242
+ */
243
+ export function clearMemoryToolDeveloperInstructionsCache(session: MemoryInstructionSession | undefined): void {
244
+ if (session) memoryToolDeveloperInstructionsBySession.delete(session);
245
+ }
246
+
247
+ /**
248
+ * Refresh the active session's consolidated-memory snapshot after startup maintenance.
249
+ *
250
+ * Startup may finish after the first prompt build and write `memory_summary.md`;
251
+ * the active session should see that summary. It must not reread `learned.md`,
252
+ * because a `learn` call racing with startup belongs to the next session's
253
+ * memory prompt, not the active prompt-cache prefix.
254
+ */
255
+ export async function refreshMemoryToolDeveloperInstructionsCacheAfterStartup(
256
+ session: MemoryInstructionSession,
257
+ agentDir: string,
258
+ settings: Settings,
259
+ ): Promise<void> {
260
+ const sessionFile = getMemoryInstructionSessionFile(session);
261
+ const cached = memoryToolDeveloperInstructionsBySession.get(session);
262
+ const current = await readMemoryToolDeveloperInstructionsSnapshot(agentDir, settings);
263
+ const root = getMemoryInstructionRoot(agentDir, settings);
264
+ const baseline = memoryToolDeveloperInstructionsByRoot.get(root);
265
+ const cachedLearned = cached && cached.sessionFile === sessionFile ? cached.snapshot?.learned : undefined;
266
+ const learned = cachedLearned ?? baseline?.learned ?? "";
267
+ const snapshot = current ? { summary: current.summary, learned } : undefined;
268
+ cacheMemoryToolDeveloperInstructions(session, sessionFile, snapshot, settings);
269
+ }
270
+
271
+ /**
272
+ * Build memory usage instructions for prompt injection.
273
+ */
274
+ export async function buildMemoryToolDeveloperInstructions(
275
+ agentDir: string,
276
+ settings: Settings,
277
+ session?: MemoryInstructionSession,
278
+ ): Promise<string | undefined> {
279
+ if (!session) {
280
+ const snapshot = await readMemoryToolDeveloperInstructionsSnapshot(agentDir, settings);
281
+ memoryToolDeveloperInstructionsByRoot.set(getMemoryInstructionRoot(agentDir, settings), snapshot);
282
+ return renderMemoryToolDeveloperInstructionsSnapshot(snapshot, settings);
283
+ }
284
+
285
+ const sessionFile = getMemoryInstructionSessionFile(session);
286
+ const cached = memoryToolDeveloperInstructionsBySession.get(session);
287
+ if (cached && cached.sessionFile === sessionFile) return cached.value;
288
+
289
+ const snapshot = await readMemoryToolDeveloperInstructionsSnapshot(agentDir, settings);
290
+ return cacheMemoryToolDeveloperInstructions(session, sessionFile, snapshot, settings);
291
+ }
292
+
188
293
  /**
189
294
  * Clear all persisted memory state and generated artifacts.
190
295
  */
@@ -219,6 +324,7 @@ async function runMemoryStartup(options: {
219
324
  }): Promise<void> {
220
325
  await runPhase1(options);
221
326
  await runPhase2(options);
327
+ await refreshMemoryToolDeveloperInstructionsCacheAfterStartup(options.session, options.agentDir, options.settings);
222
328
  await options.session.refreshBaseSystemPrompt?.();
223
329
  }
224
330
 
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  buildMemoryToolDeveloperInstructions,
3
3
  clearMemoryData,
4
+ clearMemoryToolDeveloperInstructionsCache,
4
5
  enqueueMemoryConsolidation,
5
6
  saveLearnedLesson,
6
7
  startMemoryStartupTask,
@@ -20,10 +21,11 @@ export const localBackend: MemoryBackend = {
20
21
  start(options) {
21
22
  startMemoryStartupTask(options);
22
23
  },
23
- async buildDeveloperInstructions(agentDir, settings) {
24
- return buildMemoryToolDeveloperInstructions(agentDir, settings);
24
+ async buildDeveloperInstructions(agentDir, settings, session) {
25
+ return buildMemoryToolDeveloperInstructions(agentDir, settings, session);
25
26
  },
26
- async clear(agentDir, cwd) {
27
+ async clear(agentDir, cwd, session) {
28
+ clearMemoryToolDeveloperInstructionsCache(session);
27
29
  await clearMemoryData(agentDir, cwd);
28
30
  },
29
31
  async enqueue(agentDir, cwd) {
@@ -367,6 +367,12 @@ export class StatusLineComponent implements Component {
367
367
  this.#subagentCount = count;
368
368
  }
369
369
 
370
+ /**
371
+ * Compatibility shim for callers predating the simplified subagent badge.
372
+ * The status line now intentionally shows only the active count.
373
+ */
374
+ setSubagentHubHint(_hint: string | undefined): void {}
375
+
370
376
  /** Active subagent count as currently displayed (collab state mirroring). */
371
377
  get subagentCount(): number {
372
378
  return this.#subagentCount;
@@ -42,7 +42,6 @@ import type { AsyncJobSnapshotItem } from "../../session/agent-session";
42
42
  import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage";
43
43
  import type { CompactMode } from "../../session/compact-modes";
44
44
  import type { NewSessionOptions } from "../../session/session-entries";
45
- import { SessionManager } from "../../session/session-manager";
46
45
  import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types";
47
46
  import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
48
47
  import { outputMeta } from "../../tools/output-meta";
@@ -935,13 +934,13 @@ export class CommandController {
935
934
  }
936
935
 
937
936
  /**
938
- * `/move` — switch to a fresh empty session in a different directory.
937
+ * `/move` — relocate the current session to a different directory.
939
938
  *
940
939
  * With no `targetPath` (TUI only), opens an autocomplete overlay so the user
941
940
  * can pick or type a directory. With a `targetPath`, resolves it directly.
942
941
  * If the target directory does not exist, the user is asked whether to create
943
- * it. A brand-new empty session is then started in the target directory and
944
- * the current session is left behind (resumable via `/resume`).
942
+ * it. The active session file and artifacts are moved into the target
943
+ * directory's session bucket so `/resume` from that directory can find it.
945
944
  */
946
945
  async handleMoveCommand(targetPath?: string): Promise<void> {
947
946
  if (this.ctx.session.isStreaming) {
@@ -1003,47 +1002,18 @@ export class CommandController {
1003
1002
  }
1004
1003
  }
1005
1004
 
1006
- let newSessionFile: string | undefined;
1007
1005
  try {
1008
- // Create a fresh empty session file in the target directory's session
1009
- // folder, then switch to it. The current session is left behind and
1010
- // remains resumable via /resume.
1011
- newSessionFile = SessionManager.createEmptySessionFile(resolvedPath);
1012
- const switched = await this.ctx.session.switchSession(newSessionFile);
1013
- if (!switched) {
1014
- await this.ctx.sessionManager.dropSession(newSessionFile);
1015
- return;
1016
- }
1006
+ await this.ctx.sessionManager.moveTo(resolvedPath);
1017
1007
  } catch (err) {
1018
- if (newSessionFile) {
1019
- try {
1020
- await this.ctx.sessionManager.dropSession(newSessionFile);
1021
- } catch (dropErr) {
1022
- this.ctx.showError(
1023
- `Move failed: ${err instanceof Error ? err.message : String(err)}; failed to remove empty session: ${dropErr instanceof Error ? dropErr.message : String(dropErr)}`,
1024
- );
1025
- return;
1026
- }
1027
- }
1028
1008
  this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`);
1029
1009
  return;
1030
1010
  }
1031
1011
 
1032
- this.ctx.session.markMovedFromEmptySessionFile(newSessionFile!);
1033
1012
  await this.ctx.applyCwdChange(resolvedPath);
1034
1013
 
1035
- this.ctx.chatContainer.clear();
1036
- this.ctx.pendingMessagesContainer.clear();
1037
- this.ctx.compactionQueuedMessages = [];
1038
- this.ctx.streamingComponent = undefined;
1039
- this.ctx.streamingMessage = undefined;
1040
- this.ctx.pendingTools.clear();
1041
- this.ctx.statusLine.invalidate();
1042
- this.ctx.statusLine.resetActiveTime();
1043
- this.ctx.updateEditorTopBorder();
1044
1014
  this.ctx.updateEditorBorderColor();
1045
1015
  await this.ctx.reloadTodos();
1046
- this.ctx.ui.requestRender(true, { clearScrollback: true });
1016
+ this.ctx.ui.requestRender();
1047
1017
 
1048
1018
  this.ctx.present([
1049
1019
  new Spacer(1),
@@ -953,9 +953,13 @@ export class EventController {
953
953
  }
954
954
  // Update todo display when todo tool completes
955
955
  if (event.toolName === "todo" && !event.isError) {
956
+ const hadTodoReminder = (this.ctx.todoReminderContainer?.children.length ?? 0) > 0;
957
+ this.ctx.todoReminderContainer?.clear();
956
958
  const details = event.result.details as { phases?: TodoPhase[] } | undefined;
957
959
  if (details?.phases) {
958
960
  this.ctx.setTodos(details.phases);
961
+ } else if (hadTodoReminder) {
962
+ this.ctx.ui.requestRender();
959
963
  }
960
964
  } else if (event.toolName === "todo" && event.isError) {
961
965
  const textContent = event.result.content.find(
@@ -1252,7 +1256,9 @@ export class EventController {
1252
1256
 
1253
1257
  async #handleTodoReminder(event: Extract<AgentSessionEvent, { type: "todo_reminder" }>): Promise<void> {
1254
1258
  const component = new TodoReminderComponent(event.todos, event.attempt, event.maxAttempts);
1255
- this.ctx.present(component);
1259
+ this.ctx.todoReminderContainer.clear();
1260
+ this.ctx.todoReminderContainer.addChild(component);
1261
+ this.ctx.ui.requestRender();
1256
1262
  }
1257
1263
 
1258
1264
  async #handleTodoAutoClear(_event: Extract<AgentSessionEvent, { type: "todo_auto_clear" }>): Promise<void> {
@@ -53,6 +53,7 @@ import { reset as resetCapabilities } from "../capability";
53
53
  import type { CollabGuestLink } from "../collab/guest";
54
54
  import type { CollabHost } from "../collab/host";
55
55
  import { KeybindingsManager } from "../config/keybindings";
56
+ import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
56
57
  import { isSettingsInitialized, onStatusLineSessionAccentChanged, Settings, settings } from "../config/settings";
57
58
  import { clearClaudePluginRootsCache } from "../discovery/helpers";
58
59
  import type {
@@ -100,7 +101,6 @@ import { STTController, type SttState } from "../stt";
100
101
  import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt";
101
102
  import { formatTaskId } from "../task/render";
102
103
  import type { LspStartupServerInfo } from "../tools";
103
- import { isImageProviderPreference, setPreferredImageProvider } from "../tools/image-gen";
104
104
  import { normalizeLocalScheme } from "../tools/path-utils";
105
105
  import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils";
106
106
  import { setAutoQaConsentHandler } from "../tools/report-tool-issue";
@@ -114,12 +114,6 @@ import { getEditorCommand, openInEditor } from "../utils/external-editor";
114
114
  import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
115
115
  import { messageHasDisplayableThinking } from "../utils/thinking-display";
116
116
  import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
117
- import {
118
- isSearchProviderId,
119
- isSearchProviderPreference,
120
- setExcludedSearchProviders,
121
- setPreferredSearchProvider,
122
- } from "../web/search";
123
117
  import type { AssistantMessageComponent } from "./components/assistant-message";
124
118
  import type { BashExecutionComponent } from "./components/bash-execution";
125
119
  import { ChatBlock, type ChatBlockHost } from "./components/chat-block";
@@ -384,6 +378,7 @@ export class InteractiveMode implements InteractiveModeContext {
384
378
  chatContainer: TranscriptContainer;
385
379
  pendingMessagesContainer: Container;
386
380
  statusContainer: Container;
381
+ todoReminderContainer: Container;
387
382
  todoContainer: Container;
388
383
  subagentContainer: Container;
389
384
  btwContainer: Container;
@@ -552,6 +547,7 @@ export class InteractiveMode implements InteractiveModeContext {
552
547
  this.retryLoader = undefined;
553
548
  }
554
549
  this.statusContainer.clear();
550
+ this.todoReminderContainer.clear();
555
551
  this.pendingMessagesContainer.clear();
556
552
  this.#cancelModelCycleClearTimer();
557
553
  this.modelCycleContainer.clear();
@@ -631,6 +627,7 @@ export class InteractiveMode implements InteractiveModeContext {
631
627
  this.chatContainer = new TranscriptContainer();
632
628
  this.pendingMessagesContainer = new Container();
633
629
  this.statusContainer = new AnchoredLiveContainer();
630
+ this.todoReminderContainer = new AnchoredLiveContainer();
634
631
  this.todoContainer = new AnchoredLiveContainer();
635
632
  this.subagentContainer = new AnchoredLiveContainer();
636
633
  this.btwContainer = new AnchoredLiveContainer();
@@ -850,6 +847,7 @@ export class InteractiveMode implements InteractiveModeContext {
850
847
 
851
848
  this.ui.addChild(this.chatContainer);
852
849
  this.ui.addChild(this.pendingMessagesContainer);
850
+ this.ui.addChild(this.todoReminderContainer);
853
851
  this.ui.addChild(this.todoContainer);
854
852
  this.ui.addChild(this.subagentContainer);
855
853
  this.ui.addChild(this.btwContainer);
@@ -1053,18 +1051,7 @@ export class InteractiveMode implements InteractiveModeContext {
1053
1051
  // module-level search/image provider state reflects the destination
1054
1052
  // project's configuration. Without this, the previous project's
1055
1053
  // exclusions leak and newly-excluded providers are still used.
1056
- const excludedWebSearchProviders = settings.get("providers.webSearchExclude");
1057
- if (Array.isArray(excludedWebSearchProviders)) {
1058
- setExcludedSearchProviders(excludedWebSearchProviders.filter(isSearchProviderId));
1059
- }
1060
- const webSearchProvider = settings.get("providers.webSearch");
1061
- if (typeof webSearchProvider === "string" && isSearchProviderPreference(webSearchProvider)) {
1062
- setPreferredSearchProvider(webSearchProvider);
1063
- }
1064
- const imageProvider = settings.get("providers.image");
1065
- if (isImageProviderPreference(imageProvider)) {
1066
- setPreferredImageProvider(imageProvider);
1067
- }
1054
+ applyProviderGlobalsFromSettings(settings);
1068
1055
  }
1069
1056
  // Re-warm plugin roots, capabilities, slash commands, and the ssh tool so
1070
1057
  // the next prompt sees everything scoped to the new project directory.
@@ -1624,8 +1611,8 @@ export class InteractiveMode implements InteractiveModeContext {
1624
1611
  }),
1625
1612
  }));
1626
1613
  if (!mutated) return;
1627
- this.todoPhases = next;
1628
1614
  this.session.setTodoPhases(next);
1615
+ this.setTodos(next);
1629
1616
  }
1630
1617
 
1631
1618
  #cancelTodoAutoClearTimer(): void {
@@ -4089,12 +4076,14 @@ export class InteractiveMode implements InteractiveModeContext {
4089
4076
  },
4090
4077
  ];
4091
4078
  }
4079
+ this.todoReminderContainer.clear();
4092
4080
  this.#syncTodoAutoClearTimer();
4093
4081
  this.#renderTodoList();
4094
4082
  this.ui.requestRender();
4095
4083
  }
4096
4084
 
4097
4085
  async reloadTodos(): Promise<void> {
4086
+ this.todoReminderContainer.clear();
4098
4087
  await this.#loadTodoList();
4099
4088
  this.ui.requestRender();
4100
4089
  }
@@ -96,6 +96,7 @@ export interface InteractiveModeContext {
96
96
  chatContainer: TranscriptContainer;
97
97
  pendingMessagesContainer: Container;
98
98
  statusContainer: Container;
99
+ todoReminderContainer: Container;
99
100
  todoContainer: Container;
100
101
  subagentContainer: Container;
101
102
  btwContainer: Container;
@@ -121,7 +122,7 @@ export interface InteractiveModeContext {
121
122
  focusParentSession(): Promise<void>;
122
123
  /** Return the view to the main session (delegates to SessionFocusController.unfocus). */
123
124
  unfocusSession(): Promise<void>;
124
- /** Clear loader, status/pending containers, streaming state, and pending tools. */
125
+ /** Clear loader, transient HUD/pending containers, streaming state, and pending tools. */
125
126
  clearTransientSessionUi(): void;
126
127
  settings: Settings;
127
128
  keybindings: KeybindingsManager;
@@ -0,0 +1,12 @@
1
+ <todo_context>
2
+ Current persisted todo state for this goal follows. Goal continuations do not get a visible user nudge, so treat this as live progress state, not old transcript decoration.
3
+ Before continuing substantial work, compare your next action with these todos. If an item is stale, already finished, or no longer the active pointer, call the `todo` tool first to mark it done or rewrite the list. Do not leave a stale in_progress item while working on later phases.
4
+
5
+ Overall: {{closed}}/{{total}} done, {{open}} open.
6
+ {{#each phases}}
7
+ - {{escapeXml name}}
8
+ {{#each tasks}}
9
+ - [{{status}}] {{escapeXml content}}
10
+ {{/each}}
11
+ {{/each}}
12
+ </todo_context>
@@ -231,6 +231,7 @@ import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
231
231
  import { createPlanReadMatcher } from "../plan-mode/plan-protection";
232
232
  import type { PlanModeState } from "../plan-mode/state";
233
233
  import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
234
+ import goalTodoContextPrompt from "../prompts/goals/goal-todo-context.md" with { type: "text" };
234
235
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
235
236
  import eagerTaskPrompt from "../prompts/system/eager-task.md" with { type: "text" };
236
237
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
@@ -6468,16 +6469,46 @@ export class AgentSession {
6468
6469
  #buildGoalModeMessage(): CustomMessage | null {
6469
6470
  const content = this.#goalRuntime.buildActivePrompt();
6470
6471
  if (!content) return null;
6472
+ const todoContext = this.#buildGoalTodoContext();
6471
6473
  return {
6472
6474
  role: "custom",
6473
6475
  customType: "goal-mode-context",
6474
- content,
6476
+ content: todoContext ? `${content}\n\n${todoContext}` : content,
6475
6477
  display: false,
6476
6478
  attribution: "agent",
6477
6479
  timestamp: Date.now(),
6478
6480
  };
6479
6481
  }
6480
6482
 
6483
+ #buildGoalTodoContext(): string | undefined {
6484
+ if (!this.settings.get("todo.enabled")) return undefined;
6485
+ const phases = this.getTodoPhases().filter(phase => phase.tasks.length > 0);
6486
+ if (phases.length === 0) return undefined;
6487
+
6488
+ let total = 0;
6489
+ let closed = 0;
6490
+ let open = 0;
6491
+ const promptPhases = phases.map(phase => ({
6492
+ name: phase.name,
6493
+ tasks: phase.tasks.map(task => {
6494
+ total++;
6495
+ if (task.status === "completed" || task.status === "abandoned") {
6496
+ closed++;
6497
+ } else {
6498
+ open++;
6499
+ }
6500
+ return { content: task.content, status: task.status };
6501
+ }),
6502
+ }));
6503
+
6504
+ return prompt.render(goalTodoContextPrompt, {
6505
+ closed: String(closed),
6506
+ open: String(open),
6507
+ phases: promptPhases,
6508
+ total: String(total),
6509
+ });
6510
+ }
6511
+
6481
6512
  #normalizeImagesForModel(images: ImageContent[] | undefined): Promise<ImageContent[] | undefined> {
6482
6513
  return normalizeModelContextImages(images, { model: this.model });
6483
6514
  }
@@ -1618,9 +1618,8 @@ export class SessionManager {
1618
1618
  /**
1619
1619
  * Create a fresh empty session file in the default session directory for
1620
1620
  * `cwd`, writing only the session header. The returned path can be passed to
1621
- * `setSessionFile` / `AgentSession.switchSession` to start a new empty
1622
- * session in that directory. Used by `/move` to switch projects without
1623
- * dragging the current conversation along.
1621
+ * `setSessionFile` / `AgentSession.switchSession` when a caller explicitly
1622
+ * needs a brand-new persisted session at a cwd-derived path.
1624
1623
  */
1625
1624
  static createEmptySessionFile(cwd: string, storage: SessionStorage = new FileSessionStorage()): string {
1626
1625
  const sessionDir = SessionManager.getDefaultSessionDir(cwd, undefined, storage);