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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/cli.js +3449 -3438
  3. package/dist/types/cli/update-cli.d.ts +15 -0
  4. package/dist/types/collab/replication-shrink.d.ts +39 -0
  5. package/dist/types/config/provider-globals.d.ts +7 -0
  6. package/dist/types/memories/index.d.ts +20 -1
  7. package/dist/types/modes/components/status-line/component.d.ts +5 -0
  8. package/dist/types/modes/components/status-line/types.d.ts +10 -0
  9. package/dist/types/modes/controllers/command-controller.d.ts +3 -3
  10. package/dist/types/modes/interactive-mode.d.ts +1 -0
  11. package/dist/types/modes/theme/theme.d.ts +2 -1
  12. package/dist/types/modes/types.d.ts +2 -1
  13. package/dist/types/session/agent-session.d.ts +7 -0
  14. package/dist/types/session/session-manager.d.ts +2 -3
  15. package/dist/types/task/provider-concurrency.d.ts +40 -0
  16. package/dist/types/utils/git.d.ts +11 -0
  17. package/package.json +12 -12
  18. package/src/autolearn/controller.ts +13 -22
  19. package/src/cli/update-cli.ts +254 -0
  20. package/src/cli/worktree-cli.ts +8 -5
  21. package/src/collab/host.ts +13 -8
  22. package/src/collab/replication-shrink.ts +111 -0
  23. package/src/config/model-discovery.ts +33 -13
  24. package/src/config/provider-globals.ts +25 -0
  25. package/src/config/settings-schema.ts +5 -2
  26. package/src/eval/__tests__/julia-prelude.test.ts +2 -2
  27. package/src/memories/index.ts +115 -9
  28. package/src/memory-backend/local-backend.ts +5 -3
  29. package/src/modes/components/status-line/component.ts +35 -2
  30. package/src/modes/components/status-line/segments.ts +22 -8
  31. package/src/modes/components/status-line/types.ts +7 -0
  32. package/src/modes/controllers/command-controller.ts +5 -35
  33. package/src/modes/controllers/event-controller.ts +7 -1
  34. package/src/modes/controllers/input-controller.ts +1 -1
  35. package/src/modes/interactive-mode.ts +9 -20
  36. package/src/modes/theme/theme.ts +6 -0
  37. package/src/modes/types.ts +2 -1
  38. package/src/prompts/goals/goal-todo-context.md +12 -0
  39. package/src/sdk.ts +13 -5
  40. package/src/session/agent-session.ts +129 -14
  41. package/src/session/session-manager.ts +2 -3
  42. package/src/slash-commands/builtin-registry.ts +7 -21
  43. package/src/task/executor.ts +4 -62
  44. package/src/task/provider-concurrency.ts +100 -0
  45. package/src/task/worktree.ts +13 -4
  46. package/src/task/yield-assembly.ts +27 -39
  47. package/src/tools/read.ts +11 -1
  48. package/src/utils/git.ts +13 -0
  49. package/src/prompts/system/autolearn-nudge.md +0 -5
@@ -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) {
@@ -160,6 +160,29 @@ interface ActiveRepoCache {
160
160
  projectDir: string;
161
161
  activeRepo: ActiveRepoContext | null;
162
162
  effectiveGitCwd: string;
163
+ /** Project + worktree dir name when `projectDir` is a linked worktree, else null. */
164
+ worktree: WorktreeContext | null;
165
+ }
166
+
167
+ interface WorktreeContext {
168
+ /** Primary-checkout (project) name shown by the path segment. */
169
+ projectName: string;
170
+ /** Worktree directory name — suppressed from the path when it equals the branch. */
171
+ worktreeName: string;
172
+ }
173
+
174
+ /**
175
+ * Project + worktree-dir names when `cwd` is a linked git worktree, else null.
176
+ * The project name comes from the shared primary checkout; bare-repo worktrees
177
+ * resolve to the shared `foo.git` dir, so a trailing `.git` is stripped.
178
+ */
179
+ function resolveWorktreeContext(cwd: string): WorktreeContext | null {
180
+ const worktree = git.repo.linkedWorktreeSync(cwd);
181
+ if (!worktree) return null;
182
+ const base = path.basename(worktree.primaryRoot);
183
+ const projectName = base.endsWith(".git") ? base.slice(0, -4) : base;
184
+ if (!projectName) return null;
185
+ return { projectName, worktreeName: path.basename(worktree.root) };
163
186
  }
164
187
 
165
188
  /**
@@ -312,7 +335,10 @@ export class StatusLineComponent implements Component {
312
335
 
313
336
  const activeRepo = resolveActiveRepoContextSync(projectDir);
314
337
  const effectiveGitCwd = activeRepo?.repoRoot ?? projectDir;
315
- this.#activeRepoCache = { projectDir, activeRepo, effectiveGitCwd };
338
+ // Only collapse the bare-cwd case: a single-direct-child-repo context
339
+ // (activeRepo set) renders `<parent> ↳ <child>`, which we leave intact.
340
+ const worktree = activeRepo ? null : resolveWorktreeContext(effectiveGitCwd);
341
+ this.#activeRepoCache = { projectDir, activeRepo, effectiveGitCwd, worktree };
316
342
  return this.#activeRepoCache;
317
343
  }
318
344
 
@@ -367,6 +393,12 @@ export class StatusLineComponent implements Component {
367
393
  this.#subagentCount = count;
368
394
  }
369
395
 
396
+ /**
397
+ * Compatibility shim for callers predating the simplified subagent badge.
398
+ * The status line now intentionally shows only the active count.
399
+ */
400
+ setSubagentHubHint(_hint: string | undefined): void {}
401
+
370
402
  /** Active subagent count as currently displayed (collab state mirroring). */
371
403
  get subagentCount(): number {
372
404
  return this.#subagentCount;
@@ -982,7 +1014,7 @@ export class StatusLineComponent implements Component {
982
1014
  const projectDir = getProjectDir();
983
1015
  const activeRepoCache = shouldResolveActiveRepo
984
1016
  ? this.#resolveActiveRepoCache()
985
- : { projectDir, activeRepo: null, effectiveGitCwd: projectDir };
1017
+ : { projectDir, activeRepo: null, effectiveGitCwd: projectDir, worktree: null };
986
1018
  const gitBranch = includeGit || includePr ? this.#getCurrentBranch(activeRepoCache.effectiveGitCwd) : null;
987
1019
  const gitStatus = includeGit ? this.#getGitStatus(activeRepoCache.effectiveGitCwd) : null;
988
1020
  const gitPr = includePr ? this.#lookupPr(activeRepoCache.effectiveGitCwd) : null;
@@ -1009,6 +1041,7 @@ export class StatusLineComponent implements Component {
1009
1041
  status: gitStatus,
1010
1042
  pr: gitPr,
1011
1043
  },
1044
+ worktree: activeRepoCache.worktree,
1012
1045
  usage: this.#cachedUsage,
1013
1046
  };
1014
1047
  }
@@ -20,6 +20,13 @@ function withIcon(icon: string, text: string): string {
20
20
  return icon ? `${icon} ${text}` : text;
21
21
  }
22
22
 
23
+ /** Left-truncate a path/label to `maxLen`, prefixing an ellipsis when clipped. */
24
+ function clampPathLength(pwd: string, maxLen: number): string {
25
+ if (pwd.length <= maxLen) return pwd;
26
+ const ellipsis = "…";
27
+ return `${ellipsis}${pwd.slice(-Math.max(0, maxLen - ellipsis.length))}`;
28
+ }
29
+
23
30
  /**
24
31
  * Leading glyph of a thinking-level display string (e.g. "◉ xhigh" → "◉").
25
32
  * Compact mode promotes this glyph to the model-segment icon so the level
@@ -220,12 +227,24 @@ const pathSegment: StatusLineSegment = {
220
227
  id: "path",
221
228
  render(ctx) {
222
229
  const opts = ctx.options.path ?? {};
230
+ const stripPrefix = opts.stripWorkPrefix !== false;
231
+
232
+ // Linked git worktree: the on-disk path nests the worktree base, the
233
+ // project, and a worktree dir that usually duplicates the branch (already
234
+ // shown by the git segment). Collapse to the project name, appending the
235
+ // worktree dir only when it diverges from the branch.
236
+ if (stripPrefix && ctx.worktree) {
237
+ const { projectName, worktreeName } = ctx.worktree;
238
+ const label = ctx.git.branch === worktreeName ? projectName : `${projectName}/${worktreeName}`;
239
+ const content = withIcon(theme.icon.worktree, clampPathLength(label, opts.maxLength ?? 40));
240
+ return { content: theme.fg("statusLinePath", content), visible: true };
241
+ }
223
242
 
224
243
  const projectDir = ctx.activeRepo?.cwd ?? getProjectDir();
225
244
  const { scratch, relative } = classifyProjectDir(projectDir);
226
245
  let pwd = projectDir;
227
246
 
228
- if (opts.stripWorkPrefix !== false) {
247
+ if (stripPrefix) {
229
248
  if (scratch) {
230
249
  if (relative) pwd = relative;
231
250
  } else {
@@ -237,17 +256,12 @@ const pathSegment: StatusLineSegment = {
237
256
  pwd = shortenPath(pwd);
238
257
  }
239
258
 
240
- const maxLen = opts.maxLength ?? 40;
241
- if (pwd.length > maxLen) {
242
- const ellipsis = "…";
243
- const sliceLen = Math.max(0, maxLen - ellipsis.length);
244
- pwd = `${ellipsis}${pwd.slice(-sliceLen)}`;
245
- }
259
+ pwd = clampPathLength(pwd, opts.maxLength ?? 40);
246
260
  if (repoSuffix) {
247
261
  pwd = `${pwd}${repoSuffix}`;
248
262
  }
249
263
 
250
- const showScratchIcon = scratch && opts.stripWorkPrefix !== false;
264
+ const showScratchIcon = scratch && stripPrefix;
251
265
  const icon = showScratchIcon ? theme.icon.scratchFolder : theme.icon.folder;
252
266
  const content = withIcon(icon, pwd);
253
267
  return { content: theme.fg("statusLinePath", content), visible: true };
@@ -97,6 +97,13 @@ export interface SegmentContext {
97
97
  status: { staged: number; unstaged: number; untracked: number } | null;
98
98
  pr: { number: number; url: string } | null;
99
99
  };
100
+ /**
101
+ * Set when the path cwd is a *linked* git worktree, naming the shared
102
+ * primary checkout (the project). Lets the path segment collapse the
103
+ * base-prefixed `<base>/<project>/<worktree>` path to the project name —
104
+ * the worktree/branch is already shown by the git segment.
105
+ */
106
+ worktree: { projectName: string; worktreeName: string } | null;
100
107
  usage: {
101
108
  tier?: string;
102
109
  fiveHour?: { percent: number; resetMinutes?: number };
@@ -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> {
@@ -1145,7 +1145,7 @@ export class InputController {
1145
1145
 
1146
1146
  /** Send editor text as a follow-up message (queued behind current stream). */
1147
1147
  async handleFollowUp(): Promise<void> {
1148
- let text = this.ctx.editor.getText().trim();
1148
+ let text = this.ctx.editor.getExpandedText().trim();
1149
1149
  const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
1150
1150
  const imageLinks =
1151
1151
  images && this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
@@ -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 type SymbolKey =
96
96
  | "icon.pause"
97
97
  | "icon.loop"
98
98
  | "icon.folder"
99
+ | "icon.worktree"
99
100
  | "icon.search"
100
101
  | "icon.scratchFolder"
101
102
  | "icon.file"
@@ -299,6 +300,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
299
300
  "icon.pause": "⏸",
300
301
  "icon.loop": "↻",
301
302
  "icon.folder": "📁",
303
+ "icon.worktree": "🌳",
302
304
  "icon.search": "🔍",
303
305
  "icon.scratchFolder": "🗑",
304
306
  "icon.file": "📄",
@@ -561,6 +563,8 @@ const NERD_SYMBOLS: SymbolMap = {
561
563
  "icon.search": "\uf002",
562
564
  // pick: | alt:
563
565
  "icon.scratchFolder": "\uf014",
566
+ // pick: nf-fa-sitemap | alt: nf-cod-list_tree
567
+ "icon.worktree": "\uf0e8",
564
568
  // pick:  | alt:  
565
569
  "icon.file": "\uf15b",
566
570
  // pick:  | alt:  ⎇
@@ -808,6 +812,7 @@ const ASCII_SYMBOLS: SymbolMap = {
808
812
  "icon.pause": "||",
809
813
  "icon.loop": "loop",
810
814
  "icon.folder": "[D]",
815
+ "icon.worktree": "[wt]",
811
816
  "icon.search": "[/]",
812
817
  "icon.scratchFolder": "[T]",
813
818
  "icon.file": "[F]",
@@ -1797,6 +1802,7 @@ export class Theme {
1797
1802
  pause: this.#symbols["icon.pause"],
1798
1803
  loop: this.#symbols["icon.loop"],
1799
1804
  folder: this.#symbols["icon.folder"],
1805
+ worktree: this.#symbols["icon.worktree"],
1800
1806
  scratchFolder: this.#symbols["icon.scratchFolder"],
1801
1807
  file: this.#symbols["icon.file"],
1802
1808
  git: this.#symbols["icon.git"],
@@ -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>
package/src/sdk.ts CHANGED
@@ -130,6 +130,7 @@ import {
130
130
  loadProjectContextFiles as loadContextFilesInternal,
131
131
  } from "./system-prompt";
132
132
  import { AgentOutputManager } from "./task/output-manager";
133
+ import { wrapStreamFnWithProviderConcurrency } from "./task/provider-concurrency";
133
134
  import {
134
135
  AUTO_THINKING,
135
136
  type ConfiguredThinkingLevel,
@@ -2535,11 +2536,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2535
2536
  // One-shot launch-latency marker: fired the first time the loop dispatches
2536
2537
  // a chat request to the provider transport. See onFirstChatDispatch.
2537
2538
  let notifyFirstChatDispatch = options.onFirstChatDispatch;
2538
- // Shared, settings-aware stream wrapper used by both the main agent and
2539
- // the advisor (via AgentSessionConfig.streamFn). Keeps OpenRouter
2540
- // sticky-routing variants, antigravity endpoint routing, in-flight caps,
2541
- // and the loop guard consistent across every agent the session drives.
2542
- const settingsAwareStreamFn = createSettingsAwareStreamFn(settings);
2539
+ // Shared, settings-aware stream wrapper used by the main agent, advisor,
2540
+ // and side-channel requests (`/btw`, `/omfg`, IRC auto-replies, handoff).
2541
+ // Keeps OpenRouter sticky-routing variants, antigravity endpoint routing,
2542
+ // in-flight caps, and the loop guard consistent across every provider call
2543
+ // the session drives. Wrapped in a per-provider concurrency limiter so
2544
+ // each LLM HTTP request — not the whole subagent lifecycle — holds the
2545
+ // slot, preventing the nested-spawn deadlock from issue #3749.
2546
+ const settingsAwareStreamFn = wrapStreamFnWithProviderConcurrency(
2547
+ settings,
2548
+ createSettingsAwareStreamFn(settings),
2549
+ );
2543
2550
  agent = new Agent({
2544
2551
  initialState: {
2545
2552
  systemPrompt,
@@ -2709,6 +2716,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2709
2716
  transformProviderContext,
2710
2717
  onPayload,
2711
2718
  onResponse,
2719
+ sideStreamFn: settingsAwareStreamFn,
2712
2720
  advisorStreamFn: settingsAwareStreamFn,
2713
2721
  convertToLlm: convertToLlmFinal,
2714
2722
  rebuildSystemPrompt,