@oh-my-pi/pi-coding-agent 11.2.3 → 11.3.0

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 (92) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/examples/extensions/plan-mode.ts +1 -1
  3. package/examples/hooks/qna.ts +1 -1
  4. package/examples/hooks/status-line.ts +1 -1
  5. package/examples/sdk/11-sessions.ts +1 -1
  6. package/package.json +8 -8
  7. package/src/cli/args.ts +9 -6
  8. package/src/cli/update-cli.ts +2 -2
  9. package/src/commands/index/index.ts +2 -5
  10. package/src/commit/agentic/agent.ts +1 -1
  11. package/src/commit/changelog/index.ts +2 -2
  12. package/src/config/keybindings.ts +16 -1
  13. package/src/config/model-registry.ts +25 -20
  14. package/src/config/model-resolver.ts +8 -8
  15. package/src/config/resolve-config-value.ts +92 -0
  16. package/src/config/settings-schema.ts +9 -0
  17. package/src/config.ts +14 -1
  18. package/src/export/html/template.css +7 -0
  19. package/src/export/html/template.generated.ts +1 -1
  20. package/src/export/html/template.js +33 -16
  21. package/src/extensibility/custom-commands/bundled/review/index.ts +1 -1
  22. package/src/extensibility/extensions/index.ts +18 -0
  23. package/src/extensibility/extensions/loader.ts +15 -0
  24. package/src/extensibility/extensions/runner.ts +78 -1
  25. package/src/extensibility/extensions/types.ts +131 -5
  26. package/src/extensibility/extensions/wrapper.ts +1 -1
  27. package/src/extensibility/plugins/git-url.ts +270 -0
  28. package/src/extensibility/plugins/index.ts +2 -0
  29. package/src/extensibility/slash-commands.ts +45 -0
  30. package/src/index.ts +7 -0
  31. package/src/lsp/render.ts +50 -43
  32. package/src/lsp/utils.ts +2 -2
  33. package/src/main.ts +11 -10
  34. package/src/mcp/transports/stdio.ts +3 -5
  35. package/src/modes/components/custom-message.ts +0 -8
  36. package/src/modes/components/diff.ts +1 -7
  37. package/src/modes/components/footer.ts +4 -4
  38. package/src/modes/components/model-selector.ts +4 -0
  39. package/src/modes/components/todo-display.ts +13 -3
  40. package/src/modes/components/tool-execution.ts +30 -16
  41. package/src/modes/components/tree-selector.ts +50 -19
  42. package/src/modes/controllers/event-controller.ts +1 -0
  43. package/src/modes/controllers/extension-ui-controller.ts +34 -2
  44. package/src/modes/controllers/input-controller.ts +47 -33
  45. package/src/modes/controllers/selector-controller.ts +10 -15
  46. package/src/modes/interactive-mode.ts +50 -38
  47. package/src/modes/print-mode.ts +6 -0
  48. package/src/modes/rpc/rpc-client.ts +4 -4
  49. package/src/modes/rpc/rpc-mode.ts +17 -2
  50. package/src/modes/rpc/rpc-types.ts +2 -2
  51. package/src/modes/types.ts +1 -0
  52. package/src/modes/utils/ui-helpers.ts +3 -1
  53. package/src/patch/applicator.ts +2 -3
  54. package/src/patch/fuzzy.ts +1 -1
  55. package/src/patch/shared.ts +74 -61
  56. package/src/prompts/system/system-prompt.md +1 -0
  57. package/src/prompts/tools/task.md +6 -0
  58. package/src/sdk.ts +15 -11
  59. package/src/session/agent-session.ts +72 -23
  60. package/src/session/auth-storage.ts +2 -1
  61. package/src/session/blob-store.ts +105 -0
  62. package/src/session/session-manager.ts +107 -44
  63. package/src/task/executor.ts +19 -9
  64. package/src/task/render.ts +80 -58
  65. package/src/tools/ask.ts +28 -5
  66. package/src/tools/bash.ts +47 -39
  67. package/src/tools/browser.ts +248 -26
  68. package/src/tools/calculator.ts +42 -23
  69. package/src/tools/fetch.ts +33 -16
  70. package/src/tools/find.ts +57 -22
  71. package/src/tools/grep.ts +54 -25
  72. package/src/tools/index.ts +5 -5
  73. package/src/tools/notebook.ts +19 -6
  74. package/src/tools/path-utils.ts +26 -1
  75. package/src/tools/python.ts +20 -14
  76. package/src/tools/read.ts +21 -8
  77. package/src/tools/render-utils.ts +5 -45
  78. package/src/tools/ssh.ts +59 -53
  79. package/src/tools/submit-result.ts +2 -2
  80. package/src/tools/todo-write.ts +32 -14
  81. package/src/tools/truncate.ts +1 -1
  82. package/src/tools/write.ts +39 -24
  83. package/src/tui/output-block.ts +61 -3
  84. package/src/tui/tree-list.ts +4 -4
  85. package/src/tui/utils.ts +71 -1
  86. package/src/utils/frontmatter.ts +1 -1
  87. package/src/utils/title-generator.ts +1 -1
  88. package/src/utils/tools-manager.ts +18 -2
  89. package/src/web/scrapers/osv.ts +4 -1
  90. package/src/web/scrapers/youtube.ts +1 -1
  91. package/src/web/search/index.ts +1 -1
  92. package/src/web/search/render.ts +96 -90
@@ -286,6 +286,15 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
286
286
  return Promise.resolve({ success: false, error: "Theme switching not supported in RPC mode" });
287
287
  }
288
288
 
289
+ getToolsExpanded() {
290
+ // Tool expansion not supported in RPC mode - no TUI
291
+ return false;
292
+ }
293
+
294
+ setToolsExpanded(_expanded: boolean) {
295
+ // Tool expansion not supported in RPC mode - no TUI
296
+ }
297
+
289
298
  setEditorComponent(): void {
290
299
  // Custom editor components not supported in RPC mode
291
300
  }
@@ -316,6 +325,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
316
325
  getActiveTools: () => session.getActiveToolNames(),
317
326
  getAllTools: () => session.getAllToolNames(),
318
327
  setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
328
+ getCommands: () => [],
319
329
  setModel: async model => {
320
330
  const key = await session.modelRegistry.getApiKey(model);
321
331
  if (!key) return false;
@@ -335,6 +345,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
335
345
  shutdownState.requested = true;
336
346
  },
337
347
  getContextUsage: () => session.getContextUsage(),
348
+ getSystemPrompt: () => session.systemPrompt,
338
349
  compact: async instructionsOrOptions => {
339
350
  const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
340
351
  const options =
@@ -364,6 +375,10 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
364
375
  const result = await session.navigateTree(targetId, { summarize: options?.summarize });
365
376
  return { cancelled: result.cancelled };
366
377
  },
378
+ switchSession: async sessionPath => {
379
+ const success = await session.switchSession(sessionPath);
380
+ return { cancelled: !success };
381
+ },
367
382
  compact: async instructionsOrOptions => {
368
383
  const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
369
384
  const options =
@@ -412,12 +427,12 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
412
427
  }
413
428
 
414
429
  case "steer": {
415
- await session.steer(command.message);
430
+ await session.steer(command.message, command.images);
416
431
  return success(id, "steer");
417
432
  }
418
433
 
419
434
  case "follow_up": {
420
- await session.followUp(command.message);
435
+ await session.followUp(command.message, command.images);
421
436
  return success(id, "follow_up");
422
437
  }
423
438
 
@@ -17,8 +17,8 @@ import type { CompactionResult } from "../../session/compaction";
17
17
  export type RpcCommand =
18
18
  // Prompting
19
19
  | { id?: string; type: "prompt"; message: string; images?: ImageContent[]; streamingBehavior?: "steer" | "followUp" }
20
- | { id?: string; type: "steer"; message: string }
21
- | { id?: string; type: "follow_up"; message: string }
20
+ | { id?: string; type: "steer"; message: string; images?: ImageContent[] }
21
+ | { id?: string; type: "follow_up"; message: string; images?: ImageContent[] }
22
22
  | { id?: string; type: "abort" }
23
23
  | { id?: string; type: "new_session"; parentSession?: string }
24
24
 
@@ -176,6 +176,7 @@ export interface InteractiveModeContext {
176
176
  cycleThinkingLevel(): void;
177
177
  cycleRoleModel(options?: { temporary?: boolean }): Promise<void>;
178
178
  toggleToolOutputExpansion(): void;
179
+ setToolsExpanded(expanded: boolean): void;
179
180
  toggleThinkingBlockVisibility(): void;
180
181
  openExternalEditor(): void;
181
182
  registerExtensionShortcuts(): void;
@@ -203,7 +203,9 @@ export class UiHelpers {
203
203
 
204
204
  // Render tool call components
205
205
  for (const content of message.content) {
206
- if (content.type !== "toolCall") continue;
206
+ if (content.type !== "toolCall") {
207
+ continue;
208
+ }
207
209
 
208
210
  if (content.name === "read") {
209
211
  if (!readGroup) {
@@ -434,8 +434,7 @@ function formatSequenceMatchPreview(lines: string[], startIdx: number): string {
434
434
  return previewLines
435
435
  .map((line, i) => {
436
436
  const num = start + i + 1;
437
- const truncated =
438
- line.length > MATCH_PREVIEW_MAX_LEN ? `${line.slice(0, MATCH_PREVIEW_MAX_LEN - 3)}...` : line;
437
+ const truncated = line.length > MATCH_PREVIEW_MAX_LEN ? `${line.slice(0, MATCH_PREVIEW_MAX_LEN - 1)}…` : line;
439
438
  return ` ${num} | ${truncated}`;
440
439
  })
441
440
  .join("\n");
@@ -1103,7 +1102,7 @@ function computeReplacements(
1103
1102
  return lines
1104
1103
  .map((line, i) => {
1105
1104
  const num = start + i + 1;
1106
- const truncated = line.length > maxLineLength ? `${line.slice(0, maxLineLength - 3)}...` : line;
1105
+ const truncated = line.length > maxLineLength ? `${line.slice(0, maxLineLength - 1)}…` : line;
1107
1106
  return ` ${num} | ${truncated}`;
1108
1107
  })
1109
1108
  .join("\n");
@@ -241,7 +241,7 @@ export function findMatch(
241
241
  const preview = previewLines
242
242
  .map((line, idx) => {
243
243
  const num = start + idx + 1;
244
- return ` ${num} | ${line.length > OCCURRENCE_PREVIEW_MAX_LEN ? `${line.slice(0, OCCURRENCE_PREVIEW_MAX_LEN - 3)}...` : line}`;
244
+ return ` ${num} | ${line.length > OCCURRENCE_PREVIEW_MAX_LEN ? `${line.slice(0, OCCURRENCE_PREVIEW_MAX_LEN - 1)}…` : line}`;
245
245
  })
246
246
  .join("\n");
247
247
  occurrencePreviews.push(preview);
@@ -13,12 +13,13 @@ import {
13
13
  formatExpandHint,
14
14
  formatStatusIcon,
15
15
  getDiffStats,
16
+ PREVIEW_LIMITS,
16
17
  shortenPath,
17
18
  ToolUIKit,
18
19
  truncateDiffByHunk,
19
20
  } from "../tools/render-utils";
20
21
  import type { RenderCallOptions } from "../tools/renderers";
21
- import { renderStatusLine } from "../tui";
22
+ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
22
23
  import type { DiffError, DiffResult, Operation } from "./types";
23
24
 
24
25
  // ═══════════════════════════════════════════════════════════════════════════
@@ -85,8 +86,6 @@ export interface EditRenderContext {
85
86
  renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
86
87
  }
87
88
 
88
- const EDIT_DIFF_PREVIEW_HUNKS = 2;
89
- const EDIT_DIFF_PREVIEW_LINES = 24;
90
89
  const EDIT_STREAMING_PREVIEW_LINES = 12;
91
90
 
92
91
  function countLines(text: string): number {
@@ -140,7 +139,7 @@ function renderDiffSection(
140
139
  hiddenLines,
141
140
  } = expanded
142
141
  ? { text: diff, hiddenHunks: 0, hiddenLines: 0 }
143
- : truncateDiffByHunk(diff, EDIT_DIFF_PREVIEW_HUNKS, EDIT_DIFF_PREVIEW_LINES);
142
+ : truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, PREVIEW_LIMITS.DIFF_COLLAPSED_LINES);
144
143
 
145
144
  text += `\n\n${renderDiffFn(truncatedDiff, { filePath: rawPath })}`;
146
145
  if (!expanded && (hiddenHunks > 0 || hiddenLines > 0)) {
@@ -209,75 +208,89 @@ export const editToolRenderer = {
209
208
  args?: EditRenderArgs,
210
209
  ): Component {
211
210
  const ui = new ToolUIKit(uiTheme);
212
- const { expanded, renderContext } = options;
213
211
  const rawPath = args?.file_path || args?.path || "";
214
212
  const filePath = shortenPath(rawPath);
215
213
  const editLanguage = getLanguageFromPath(rawPath) ?? "text";
216
214
  const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
217
- const editDiffPreview = renderContext?.editDiffPreview;
218
- const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
219
215
 
220
- // Get op and rename from args or details
221
216
  const op = args?.op || result.details?.op;
222
217
  const rename = args?.rename || result.details?.rename;
218
+ const opTitle = op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit";
223
219
 
224
- // Build path display with line number if available
225
- let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
226
- const firstChangedLine =
227
- (editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
228
- (result.details && !result.isError ? result.details.firstChangedLine : undefined);
229
- if (firstChangedLine) {
230
- pathDisplay += uiTheme.fg("warning", `:${firstChangedLine}`);
231
- }
220
+ // Pre-compute metadata line (static across renders)
221
+ const metadataLine =
222
+ op !== "delete"
223
+ ? `\n${formatMetadataLine(countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""), editLanguage, uiTheme)}`
224
+ : "";
232
225
 
233
- // Add arrow for rename operations
234
- if (rename) {
235
- pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(rename))}`;
236
- }
226
+ // Pre-compute error text (static)
227
+ const errorText = result.isError ? (result.content?.find(c => c.type === "text")?.text ?? "") : "";
237
228
 
238
- // Show operation type for patch mode
239
- const opTitle = op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit";
240
- const header = renderStatusLine(
241
- {
242
- icon: result.isError ? "error" : "success",
243
- title: opTitle,
244
- description: `${editIcon} ${pathDisplay}`,
245
- },
246
- uiTheme,
247
- );
248
- let text = header;
249
-
250
- // Skip metadata line for delete operations
251
- if (op !== "delete") {
252
- const editLineCount = countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? "");
253
- text += `\n${formatMetadataLine(editLineCount, editLanguage, uiTheme)}`;
254
- }
229
+ let cached: RenderCache | undefined;
255
230
 
256
- if (result.isError) {
257
- // Show error from result
258
- const errorText = result.content?.find(c => c.type === "text")?.text ?? "";
259
- if (errorText) {
260
- text += `\n\n${uiTheme.fg("error", errorText)}`;
261
- }
262
- } else if (result.details?.diff) {
263
- // Prefer actual diff after execution
264
- text += renderDiffSection(result.details.diff, rawPath, expanded, uiTheme, ui, renderDiffFn);
265
- } else if (editDiffPreview) {
266
- // Use cached diff preview when no actual diff is available
267
- if ("error" in editDiffPreview) {
268
- text += `\n\n${uiTheme.fg("error", editDiffPreview.error)}`;
269
- } else if (editDiffPreview.diff) {
270
- text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, ui, renderDiffFn);
271
- }
272
- }
231
+ return {
232
+ render(width) {
233
+ const { expanded, renderContext } = options;
234
+ const editDiffPreview = renderContext?.editDiffPreview;
235
+ const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
236
+ const key = new Hasher().bool(expanded).u32(width).digest();
237
+ if (cached?.key === key) return cached.lines;
273
238
 
274
- // Show LSP diagnostics if available
275
- if (result.details?.diagnostics) {
276
- text += ui.formatDiagnostics(result.details.diagnostics, expanded, (fp: string) =>
277
- uiTheme.getLangIcon(getLanguageFromPath(fp)),
278
- );
279
- }
239
+ // Build path display with line number
240
+ let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
241
+ const firstChangedLine =
242
+ (editDiffPreview && "firstChangedLine" in editDiffPreview
243
+ ? editDiffPreview.firstChangedLine
244
+ : undefined) || (result.details && !result.isError ? result.details.firstChangedLine : undefined);
245
+ if (firstChangedLine) {
246
+ pathDisplay += uiTheme.fg("warning", `:${firstChangedLine}`);
247
+ }
280
248
 
281
- return new Text(text, 0, 0);
249
+ // Add arrow for rename operations
250
+ if (rename) {
251
+ pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(rename))}`;
252
+ }
253
+
254
+ const header = renderStatusLine(
255
+ {
256
+ icon: result.isError ? "error" : "success",
257
+ title: opTitle,
258
+ description: `${editIcon} ${pathDisplay}`,
259
+ },
260
+ uiTheme,
261
+ );
262
+ let text = header;
263
+ text += metadataLine;
264
+
265
+ if (result.isError) {
266
+ if (errorText) {
267
+ text += `\n\n${uiTheme.fg("error", errorText)}`;
268
+ }
269
+ } else if (result.details?.diff) {
270
+ text += renderDiffSection(result.details.diff, rawPath, expanded, uiTheme, ui, renderDiffFn);
271
+ } else if (editDiffPreview) {
272
+ if ("error" in editDiffPreview) {
273
+ text += `\n\n${uiTheme.fg("error", editDiffPreview.error)}`;
274
+ } else if (editDiffPreview.diff) {
275
+ text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, ui, renderDiffFn);
276
+ }
277
+ }
278
+
279
+ // Show LSP diagnostics if available
280
+ if (result.details?.diagnostics) {
281
+ text += ui.formatDiagnostics(result.details.diagnostics, expanded, (fp: string) =>
282
+ uiTheme.getLangIcon(getLanguageFromPath(fp)),
283
+ );
284
+ }
285
+
286
+ const lines =
287
+ width > 0 ? text.split("\n").map(line => truncateToWidth(line, width, Ellipsis.Omit)) : text.split("\n");
288
+ cached = { key, lines };
289
+ return lines;
290
+ },
291
+ invalidate() {
292
+ cached = undefined;
293
+ },
294
+ };
282
295
  },
283
296
  };
@@ -221,6 +221,7 @@ Main branch: {{git.mainBranch}}
221
221
  {{#if skills.length}}
222
222
  <skills>
223
223
  Scan descriptions vs domain. Skill covers output? Read `skill://<name>` first.
224
+ When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.
224
225
 
225
226
  {{#list skills join="\n"}}
226
227
  <skill name="{{name}}">
@@ -70,6 +70,10 @@ Run in isolated git worktree; returns patches. Use when tasks edit overlapping f
70
70
  ### `schema` (optional — recommended for structured output)
71
71
 
72
72
  JTD schema defining expected response structure. Use typed properties. If you care about parsing result, define here — **never describe output format in `context` or `assignment`**.
73
+
74
+ <caution>
75
+ **Schema vs agent mismatch causes null output.** Agents with `output="structured"` (e.g., `explore`) have a built-in schema. If you also pass `schema`, yours takes precedence — but if you describe output format in `context`/`assignment` instead, the agent's built-in schema wins. The agent gets confused trying to fit your requested format into its schema shape and submits `null`. Either: (1) use `schema` to override the built-in one, (2) use `task` agent which has no built-in schema, or (3) match your instructions to the agent's expected output shape.
76
+ </caution>
73
77
  ---
74
78
 
75
79
  ## Writing an assignment
@@ -115,6 +119,8 @@ Use structure every assignment:
115
119
  - "No WASM."
116
120
 
117
121
  If tempted to write above, expand using templates.
122
+ **Output format in prose instead of `schema`** — agent returns null:
123
+ Structured agents (`explore`, `reviewer`) have built-in output schemas. Describing a different output format in `context`/`assignment` without overriding via `schema` creates a mismatch — the agent can't reconcile your prose instructions with its schema and submits null data. Always use `schema` for output structure, or pick an agent whose built-in schema matches your needs.
118
124
  **Test/lint commands in parallel tasks** — edit wars:
119
125
  Parallel agents share working tree. If two agents run `bun check` or `bun test` concurrently, they see each other's half-finished edits, "fix" phantom errors, loop. **Never tell parallel tasks run project-wide build/test/lint commands.** Each task edits, stops. Caller verifies after all tasks complete.
120
126
  **If you can’t specify scope yet**, create **Discovery task** first: enumerate files, find callsites, list candidates. Then fan out with explicit paths.
package/src/sdk.ts CHANGED
@@ -387,7 +387,7 @@ function customToolToDefinition(tool: CustomTool): ToolDefinition {
387
387
  label: tool.label,
388
388
  description: tool.description,
389
389
  parameters: tool.parameters,
390
- execute: (toolCallId, params, onUpdate, ctx, signal) =>
390
+ execute: (toolCallId, params, signal, onUpdate, ctx) =>
391
391
  tool.execute(toolCallId, params, onUpdate, createCustomToolContext(ctx), signal),
392
392
  onSession: tool.onSession ? (event, ctx) => tool.onSession?.(event, createCustomToolContext(ctx)) : undefined,
393
393
  renderCall: tool.renderCall,
@@ -506,6 +506,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
506
506
  const existingSession = sessionManager.buildSessionContext();
507
507
  time("loadSession");
508
508
  const hasExistingSession = existingSession.messages.length > 0;
509
+ const hasThinkingEntry = sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
509
510
 
510
511
  const hasExplicitModel = options.model !== undefined;
511
512
  let model = options.model;
@@ -563,7 +564,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
563
564
 
564
565
  // If session has data, restore thinking level from it
565
566
  if (thinkingLevel === undefined && hasExistingSession) {
566
- thinkingLevel = existingSession.thinkingLevel as ThinkingLevel;
567
+ thinkingLevel = hasThinkingEntry
568
+ ? (existingSession.thinkingLevel as ThinkingLevel)
569
+ : ((settingsInstance.get("defaultThinkingLevel") ?? "off") as ThinkingLevel);
567
570
  }
568
571
 
569
572
  // Fall back to settings default
@@ -698,7 +701,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
698
701
  onConnecting: serverNames => {
699
702
  if (options.hasUI && serverNames.length > 0) {
700
703
  process.stderr.write(
701
- chalk.gray(`Connecting to MCP servers: ${serverNames.join(", ")}...
704
+ chalk.gray(`Connecting to MCP servers: ${serverNames.join(", ")}
702
705
  `),
703
706
  );
704
707
  }
@@ -1016,14 +1019,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1016
1019
  thinkingBudgets: settingsInstance.getGroup("thinkingBudgets"),
1017
1020
  kimiApiFormat: settingsInstance.get("providers.kimiApiFormat") ?? "anthropic",
1018
1021
  getToolContext: tc => toolContextStore.getContext(tc),
1019
- getApiKey: async () => {
1020
- const currentModel = agent.state.model;
1021
- if (!currentModel) {
1022
- throw new Error("No model selected");
1023
- }
1024
- const key = await modelRegistry.getApiKey(currentModel, sessionId);
1022
+ getApiKey: async provider => {
1023
+ // Use the provider argument from the in-flight request;
1024
+ // agent.state.model may already be switched mid-turn.
1025
+ const key = await modelRegistry.getApiKeyForProvider(provider, sessionId);
1025
1026
  if (!key) {
1026
- throw new Error(`No API key found for provider "${currentModel.provider}"`);
1027
+ throw new Error(`No API key found for provider "${provider}"`);
1027
1028
  }
1028
1029
  return key;
1029
1030
  },
@@ -1036,6 +1037,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1036
1037
  // Restore messages if session has existing data
1037
1038
  if (hasExistingSession) {
1038
1039
  agent.replaceMessages(existingSession.messages);
1040
+ if (!hasThinkingEntry) {
1041
+ sessionManager.appendThinkingLevelChange(thinkingLevel);
1042
+ }
1039
1043
  } else {
1040
1044
  // Save initial model and thinking level for new sessions so they can be restored on resume
1041
1045
  if (model) {
@@ -1072,7 +1076,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1072
1076
  const result = await warmupLspServers(cwd, {
1073
1077
  onConnecting: serverNames => {
1074
1078
  if (options.hasUI && serverNames.length > 0) {
1075
- process.stderr.write(chalk.gray(`Starting LSP servers: ${serverNames.join(", ")}...\n`));
1079
+ process.stderr.write(chalk.gray(`Starting LSP servers: ${serverNames.join(", ")}…\n`));
1076
1080
  }
1077
1081
  },
1078
1082
  });
@@ -237,6 +237,8 @@ const noOpUIContext: ExtensionUIContext = {
237
237
  setFooter: () => {},
238
238
  setHeader: () => {},
239
239
  setEditorComponent: () => {},
240
+ getToolsExpanded: () => false,
241
+ setToolsExpanded: () => {},
240
242
  };
241
243
 
242
244
  async function cleanupSshResources(): Promise<void> {
@@ -906,6 +908,11 @@ export class AgentSession {
906
908
  return this.agent.state.isStreaming || this._promptInFlight;
907
909
  }
908
910
 
911
+ /** Current effective system prompt (includes any per-turn extension modifications) */
912
+ get systemPrompt(): string {
913
+ return this.agent.state.systemPrompt;
914
+ }
915
+
909
916
  /** Current retry attempt (0 if not retrying) */
910
917
  get retryAttempt(): number {
911
918
  return this._retryAttempt;
@@ -1426,6 +1433,11 @@ export class AgentSession {
1426
1433
  instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
1427
1434
  await this.compact(instructions, options);
1428
1435
  },
1436
+ switchSession: async sessionPath => {
1437
+ const success = await this.switchSession(sessionPath);
1438
+ return { cancelled: !success };
1439
+ },
1440
+ getSystemPrompt: () => this.systemPrompt,
1429
1441
  };
1430
1442
  }
1431
1443
 
@@ -1477,25 +1489,25 @@ export class AgentSession {
1477
1489
  /**
1478
1490
  * Queue a steering message to interrupt the agent mid-run.
1479
1491
  */
1480
- async steer(text: string): Promise<void> {
1492
+ async steer(text: string, images?: ImageContent[]): Promise<void> {
1481
1493
  if (text.startsWith("/")) {
1482
1494
  this._throwIfExtensionCommand(text);
1483
1495
  }
1484
1496
 
1485
1497
  const expandedText = expandPromptTemplate(text, [...this._promptTemplates]);
1486
- await this._queueSteer(expandedText);
1498
+ await this._queueSteer(expandedText, images);
1487
1499
  }
1488
1500
 
1489
1501
  /**
1490
1502
  * Queue a follow-up message to process after the agent would otherwise stop.
1491
1503
  */
1492
- async followUp(text: string): Promise<void> {
1504
+ async followUp(text: string, images?: ImageContent[]): Promise<void> {
1493
1505
  if (text.startsWith("/")) {
1494
1506
  this._throwIfExtensionCommand(text);
1495
1507
  }
1496
1508
 
1497
1509
  const expandedText = expandPromptTemplate(text, [...this._promptTemplates]);
1498
- await this._queueFollowUp(expandedText);
1510
+ await this._queueFollowUp(expandedText, images);
1499
1511
  }
1500
1512
 
1501
1513
  /**
@@ -1733,6 +1745,9 @@ export class AgentSession {
1733
1745
  this._steeringMessages = [];
1734
1746
  this._followUpMessages = [];
1735
1747
  this._pendingNextTurnMessages = [];
1748
+
1749
+ this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
1750
+
1736
1751
  this._todoReminderCount = 0;
1737
1752
  this._planReferenceSent = false;
1738
1753
  this._reconnectToAgent();
@@ -1934,22 +1949,39 @@ export class AgentSession {
1934
1949
  return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
1935
1950
  }
1936
1951
 
1952
+ private async _getScopedModelsWithApiKey(): Promise<Array<{ model: Model; thinkingLevel: ThinkingLevel }>> {
1953
+ const apiKeysByProvider = new Map<string, string | undefined>();
1954
+ const result: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];
1955
+
1956
+ for (const scoped of this._scopedModels) {
1957
+ const provider = scoped.model.provider;
1958
+ let apiKey: string | undefined;
1959
+ if (apiKeysByProvider.has(provider)) {
1960
+ apiKey = apiKeysByProvider.get(provider);
1961
+ } else {
1962
+ apiKey = await this._modelRegistry.getApiKeyForProvider(provider, this.sessionId);
1963
+ apiKeysByProvider.set(provider, apiKey);
1964
+ }
1965
+
1966
+ if (apiKey) {
1967
+ result.push(scoped);
1968
+ }
1969
+ }
1970
+
1971
+ return result;
1972
+ }
1973
+
1937
1974
  private async _cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
1938
- if (this._scopedModels.length <= 1) return undefined;
1975
+ const scopedModels = await this._getScopedModelsWithApiKey();
1976
+ if (scopedModels.length <= 1) return undefined;
1939
1977
 
1940
1978
  const currentModel = this.model;
1941
- let currentIndex = this._scopedModels.findIndex(sm => modelsAreEqual(sm.model, currentModel));
1979
+ let currentIndex = scopedModels.findIndex(sm => modelsAreEqual(sm.model, currentModel));
1942
1980
 
1943
1981
  if (currentIndex === -1) currentIndex = 0;
1944
- const len = this._scopedModels.length;
1982
+ const len = scopedModels.length;
1945
1983
  const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
1946
- const next = this._scopedModels[nextIndex];
1947
-
1948
- // Validate API key
1949
- const apiKey = await this._modelRegistry.getApiKey(next.model, this.sessionId);
1950
- if (!apiKey) {
1951
- throw new Error(`No API key for ${next.model.provider}/${next.model.id}`);
1952
- }
1984
+ const next = scopedModels[nextIndex];
1953
1985
 
1954
1986
  // Apply model
1955
1987
  this.agent.setModel(next.model);
@@ -2005,15 +2037,22 @@ export class AgentSession {
2005
2037
  /**
2006
2038
  * Set thinking level.
2007
2039
  * Clamps to model capabilities based on available thinking levels.
2008
- * Saves to session, with optional persistence to settings.
2040
+ * Saves to session and settings only if the level actually changes.
2009
2041
  */
2010
2042
  setThinkingLevel(level: ThinkingLevel, persist: boolean = false): void {
2011
2043
  const availableLevels = this.getAvailableThinkingLevels();
2012
2044
  const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels);
2045
+
2046
+ // Only persist if actually changing
2047
+ const isChanging = effectiveLevel !== this.agent.state.thinkingLevel;
2048
+
2013
2049
  this.agent.setThinkingLevel(effectiveLevel);
2014
- this.sessionManager.appendThinkingLevelChange(effectiveLevel);
2015
- if (persist) {
2016
- this.settings.set("defaultThinkingLevel", effectiveLevel);
2050
+
2051
+ if (isChanging) {
2052
+ this.sessionManager.appendThinkingLevelChange(effectiveLevel);
2053
+ if (persist) {
2054
+ this.settings.set("defaultThinkingLevel", effectiveLevel);
2055
+ }
2017
2056
  }
2018
2057
  }
2019
2058
 
@@ -2903,8 +2942,8 @@ Be thorough - include exact file paths, function names, error messages, and tech
2903
2942
  }
2904
2943
 
2905
2944
  private _isRetryableErrorMessage(errorMessage: string): boolean {
2906
- // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed
2907
- return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|fetch failed/i.test(
2945
+ // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed, retry delay exceeded
2946
+ return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|fetch failed|retry delay/i.test(
2908
2947
  errorMessage,
2909
2948
  );
2910
2949
  }
@@ -3354,9 +3393,19 @@ Be thorough - include exact file paths, function names, error messages, and tech
3354
3393
  }
3355
3394
  }
3356
3395
 
3357
- // Restore thinking level if saved (setThinkingLevel clamps to model capabilities)
3358
- if (sessionContext.thinkingLevel) {
3396
+ const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
3397
+ const defaultThinkingLevel = (this.settings.get("defaultThinkingLevel") ?? "off") as ThinkingLevel;
3398
+
3399
+ if (hasThinkingEntry) {
3400
+ // Restore thinking level if saved (setThinkingLevel clamps to model capabilities)
3359
3401
  this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel);
3402
+ } else {
3403
+ const availableLevels = this.getAvailableThinkingLevels();
3404
+ const effectiveLevel = availableLevels.includes(defaultThinkingLevel)
3405
+ ? defaultThinkingLevel
3406
+ : this._clampThinkingLevel(defaultThinkingLevel, availableLevels);
3407
+ this.agent.setThinkingLevel(effectiveLevel);
3408
+ this.sessionManager.appendThinkingLevelChange(effectiveLevel);
3360
3409
  }
3361
3410
 
3362
3411
  this._reconnectToAgent();
@@ -3404,7 +3453,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3404
3453
  await this.sessionManager.flush();
3405
3454
 
3406
3455
  if (!selectedEntry.parentId) {
3407
- this.sessionManager.newSession();
3456
+ this.sessionManager.newSession({ parentSession: previousSessionFile });
3408
3457
  } else {
3409
3458
  this.sessionManager.createBranchedSession(selectedEntry.parentId);
3410
3459
  }
@@ -34,6 +34,7 @@ import {
34
34
  } from "@oh-my-pi/pi-ai";
35
35
  import { logger } from "@oh-my-pi/pi-utils";
36
36
  import { getAgentDbPath } from "../config";
37
+ import { resolveConfigValue } from "../config/resolve-config-value";
37
38
  import { AgentStorage } from "./agent-storage";
38
39
 
39
40
  export type ApiKeyCredential = {
@@ -1294,7 +1295,7 @@ export class AuthStorage {
1294
1295
  const apiKeySelection = this.selectCredentialByType(provider, "api_key", sessionId);
1295
1296
  if (apiKeySelection) {
1296
1297
  this.recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
1297
- return apiKeySelection.credential.key;
1298
+ return resolveConfigValue(apiKeySelection.credential.key);
1298
1299
  }
1299
1300
 
1300
1301
  const oauthKey = await this.resolveOAuthApiKey(provider, sessionId, options);