@oh-my-pi/pi-coding-agent 13.7.6 → 13.9.1

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 (46) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +7 -7
  3. package/scripts/generate-docs-index.ts +3 -3
  4. package/src/capability/context-file.ts +6 -3
  5. package/src/capability/fs.ts +18 -0
  6. package/src/capability/index.ts +3 -2
  7. package/src/capability/rule.ts +0 -4
  8. package/src/capability/types.ts +2 -0
  9. package/src/cli/agents-cli.ts +1 -1
  10. package/src/cli/args.ts +7 -12
  11. package/src/commands/launch.ts +3 -2
  12. package/src/config/model-resolver.ts +118 -33
  13. package/src/config/settings-schema.ts +14 -2
  14. package/src/config/settings.ts +1 -17
  15. package/src/discovery/agents-md.ts +3 -4
  16. package/src/discovery/agents.ts +104 -84
  17. package/src/discovery/builtin.ts +28 -15
  18. package/src/discovery/claude.ts +27 -9
  19. package/src/discovery/helpers.ts +10 -17
  20. package/src/extensibility/extensions/loader.ts +1 -2
  21. package/src/extensibility/extensions/types.ts +2 -1
  22. package/src/extensibility/skills.ts +2 -2
  23. package/src/internal-urls/docs-index.generated.ts +1 -1
  24. package/src/main.ts +21 -10
  25. package/src/modes/components/agent-dashboard.ts +12 -13
  26. package/src/modes/components/model-selector.ts +157 -59
  27. package/src/modes/components/read-tool-group.ts +36 -2
  28. package/src/modes/components/settings-defs.ts +11 -8
  29. package/src/modes/components/settings-selector.ts +1 -1
  30. package/src/modes/components/thinking-selector.ts +3 -15
  31. package/src/modes/controllers/selector-controller.ts +6 -4
  32. package/src/modes/rpc/rpc-client.ts +2 -2
  33. package/src/modes/rpc/rpc-types.ts +2 -2
  34. package/src/modes/theme/theme.ts +2 -1
  35. package/src/patch/hashline.ts +113 -0
  36. package/src/patch/index.ts +27 -18
  37. package/src/prompts/tools/hashline.md +9 -10
  38. package/src/prompts/tools/read.md +2 -2
  39. package/src/sdk.ts +21 -25
  40. package/src/session/agent-session.ts +54 -59
  41. package/src/task/executor.ts +10 -8
  42. package/src/task/types.ts +1 -2
  43. package/src/tools/fetch.ts +152 -4
  44. package/src/tools/read.ts +88 -264
  45. package/src/utils/frontmatter.ts +25 -4
  46. package/src/web/scrapers/choosealicense.ts +1 -1
@@ -710,3 +710,116 @@ export function applyHashlineEdits(
710
710
  }
711
711
  }
712
712
  }
713
+
714
+ export interface CompactHashlineDiffPreview {
715
+ preview: string;
716
+ addedLines: number;
717
+ removedLines: number;
718
+ }
719
+
720
+ export interface CompactHashlineDiffOptions {
721
+ maxUnchangedRun?: number;
722
+ maxAdditionRun?: number;
723
+ maxDeletionRun?: number;
724
+ maxOutputLines?: number;
725
+ }
726
+
727
+ const NUMBERED_DIFF_LINE_RE = /^([ +-])(\d+)\|(.*)$/;
728
+
729
+ type DiffRunKind = " " | "+" | "-" | "meta";
730
+ type DiffRun = { kind: DiffRunKind; lines: string[] };
731
+
732
+ function collapseFromStart(lines: string[], maxLines: number, label: string): string[] {
733
+ if (lines.length <= maxLines) return lines;
734
+ const hidden = lines.length - maxLines;
735
+ return [...lines.slice(0, maxLines), ` ... ${hidden} more ${label} lines`];
736
+ }
737
+
738
+ function collapseFromEnd(lines: string[], maxLines: number, label: string): string[] {
739
+ if (lines.length <= maxLines) return lines;
740
+ const hidden = lines.length - maxLines;
741
+ return [` ... ${hidden} more ${label} lines`, ...lines.slice(-maxLines)];
742
+ }
743
+
744
+ function collapseFromMiddle(lines: string[], maxLines: number, label: string): string[] {
745
+ if (lines.length <= maxLines * 2) return lines;
746
+ const hidden = lines.length - maxLines * 2;
747
+ return [...lines.slice(0, maxLines), ` ... ${hidden} more ${label} lines`, ...lines.slice(-maxLines)];
748
+ }
749
+
750
+ function splitDiffRuns(lines: string[]): DiffRun[] {
751
+ const runs: DiffRun[] = [];
752
+ for (const line of lines) {
753
+ const match = NUMBERED_DIFF_LINE_RE.exec(line);
754
+ const kind = (match?.[1] as " " | "+" | "-" | undefined) ?? "meta";
755
+ const prev = runs[runs.length - 1];
756
+ if (prev && prev.kind === kind) {
757
+ prev.lines.push(line);
758
+ continue;
759
+ }
760
+ runs.push({ kind, lines: [line] });
761
+ }
762
+ return runs;
763
+ }
764
+
765
+ /**
766
+ * Build a compact diff preview suitable for model-visible tool responses.
767
+ *
768
+ * Collapses long unchanged runs and long consecutive additions/removals so the
769
+ * model sees the shape of edits without replaying full file content.
770
+ */
771
+ export function buildCompactHashlineDiffPreview(
772
+ diff: string,
773
+ options: CompactHashlineDiffOptions = {},
774
+ ): CompactHashlineDiffPreview {
775
+ const maxUnchangedRun = options.maxUnchangedRun ?? 2;
776
+ const maxAdditionRun = options.maxAdditionRun ?? 2;
777
+ const maxDeletionRun = options.maxDeletionRun ?? 2;
778
+ const maxOutputLines = options.maxOutputLines ?? 16;
779
+
780
+ const inputLines = diff.length === 0 ? [] : diff.split("\n");
781
+ const runs = splitDiffRuns(inputLines);
782
+
783
+ const out: string[] = [];
784
+ let addedLines = 0;
785
+ let removedLines = 0;
786
+
787
+ for (let runIndex = 0; runIndex < runs.length; runIndex++) {
788
+ const run = runs[runIndex];
789
+ switch (run.kind) {
790
+ case "meta":
791
+ out.push(...run.lines);
792
+ break;
793
+ case "+":
794
+ addedLines += run.lines.length;
795
+ out.push(...collapseFromStart(run.lines, maxAdditionRun, "added"));
796
+ break;
797
+ case "-":
798
+ removedLines += run.lines.length;
799
+ out.push(...collapseFromStart(run.lines, maxDeletionRun, "removed"));
800
+ break;
801
+ case " ":
802
+ if (runIndex === 0) {
803
+ out.push(...collapseFromEnd(run.lines, maxUnchangedRun, "unchanged"));
804
+ break;
805
+ }
806
+ if (runIndex === runs.length - 1) {
807
+ out.push(...collapseFromStart(run.lines, maxUnchangedRun, "unchanged"));
808
+ break;
809
+ }
810
+ out.push(...collapseFromMiddle(run.lines, maxUnchangedRun, "unchanged"));
811
+ break;
812
+ }
813
+ }
814
+
815
+ if (out.length > maxOutputLines) {
816
+ const hidden = out.length - maxOutputLines;
817
+ return {
818
+ preview: [...out.slice(0, maxOutputLines), ` ... ${hidden} more preview lines`].join("\n"),
819
+ addedLines,
820
+ removedLines,
821
+ };
822
+ }
823
+
824
+ return { preview: out.join("\n"), addedLines, removedLines };
825
+ }
@@ -34,7 +34,14 @@ import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard"
34
34
  import { applyPatch } from "./applicator";
35
35
  import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff";
36
36
  import { findMatch } from "./fuzzy";
37
- import { type Anchor, applyHashlineEdits, computeLineHash, type HashlineEdit, parseTag } from "./hashline";
37
+ import {
38
+ type Anchor,
39
+ applyHashlineEdits,
40
+ buildCompactHashlineDiffPreview,
41
+ computeLineHash,
42
+ type HashlineEdit,
43
+ parseTag,
44
+ } from "./hashline";
38
45
  import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
39
46
  import { type EditToolDetails, getLspBatchRequest } from "./shared";
40
47
  // Internal imports
@@ -315,18 +322,10 @@ export type EditMode = "replace" | "patch" | "hashline";
315
322
 
316
323
  export const DEFAULT_EDIT_MODE: EditMode = "hashline";
317
324
 
318
- export function normalizeEditMode(mode?: string | null): EditMode | null {
319
- switch (mode) {
320
- case "replace":
321
- return "replace";
322
- case "patch":
323
- return "patch";
324
- case "hashline":
325
- return "hashline";
326
- default:
327
- return null;
328
- }
329
- }
325
+ const EDIT_ID: Record<string, EditMode> = Object.fromEntries(
326
+ ["replace", "patch", "hashline"].map(mode => [mode, mode as EditMode]),
327
+ );
328
+ export const normalizeEditMode = (mode?: string | null): EditMode | undefined => EDIT_ID[mode ?? ""];
330
329
 
331
330
  /**
332
331
  * Edit tool implementation.
@@ -401,11 +400,17 @@ export class EditTool implements AgentTool<TInput> {
401
400
  */
402
401
  get mode(): EditMode {
403
402
  if (this.#editMode) return this.#editMode;
403
+ // 1. Check if edit mode is explicitly set for this model
404
404
  const activeModel = this.session.getActiveModelString?.();
405
- const editVariant =
406
- this.session.settings.getEditVariantForModel(activeModel) ??
407
- normalizeEditMode(this.session.settings.get("edit.mode"));
408
- return editVariant ?? DEFAULT_EDIT_MODE;
405
+ const modelVariant = this.session.settings.getEditVariantForModel(activeModel);
406
+ if (modelVariant) return modelVariant;
407
+ // 2. Check if model contains "-spark" substring (default to replace mode)
408
+ if (activeModel?.includes("-spark")) return "replace";
409
+ // 3. Check if edit mode is explicitly set in session settings
410
+ const settingsMode = normalizeEditMode(this.session.settings.get("edit.mode"));
411
+ if (settingsMode) return settingsMode;
412
+ // 4. Default to DEFAULT_EDIT_MODE
413
+ return DEFAULT_EDIT_MODE;
409
414
  }
410
415
 
411
416
  /**
@@ -597,11 +602,15 @@ export class EditTool implements AgentTool<TInput> {
597
602
  .get();
598
603
 
599
604
  const resultText = move ? `Moved ${path} to ${move}` : `Updated ${path}`;
605
+ const preview = buildCompactHashlineDiffPreview(diffResult.diff);
606
+ const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${preview.preview ? "" : " (no textual diff preview)"}`;
607
+ const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
608
+ const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : "";
600
609
  return {
601
610
  content: [
602
611
  {
603
612
  type: "text",
604
- text: `${resultText}${result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : ""}`,
613
+ text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`,
605
614
  },
606
615
  ],
607
616
  details: {
@@ -12,23 +12,22 @@ Follow these steps in order for every edit:
12
12
  **`move`** — if set, move the file to the given path.
13
13
  **`delete`** — if true, delete the file.
14
14
  **`edits[n].pos`** — the anchor line. Meaning depends on `op`:
15
- - `replace`: start of range (or the single line to replace)
16
- - `prepend`: insert new lines **before** this line; omit for beginning of file
17
- - `append`: insert new lines **after** this line; omit for end of file
15
+ - if `replace`: line to rewrite
16
+ - if `prepend`: line to insert new lines **before**; omit for beginning of file
17
+ - if `append`: line to insert new lines **after**; omit for end of file
18
18
  **`edits[n].end`** — range replace only. The last line of the range (inclusive). Omit for single-line replace.
19
19
  **`edits[n].lines`** — the replacement content:
20
- - `["line1", "line2"]` — replace with these lines (array of strings)
21
- - `"line1"` — shorthand for `["line1"]` (single-line replace)
22
- - `[""]` — replace content with a blank line (line preserved, content cleared)
23
- - `null` or `[]` — **delete** the line(s) entirely
20
+ - `["line1", "line2"]` — insert `line1` and `line2`
21
+ - `[""]` — blank line
22
+ - `null` or `[]` — delete if replace, no-op if append or prepend
24
23
 
25
24
  Tags are applied bottom-up: later edits (by position) are applied first, so earlier tags remain valid even when subsequent ops add or remove lines. Tags **MUST** be referenced from the most recent `read` output.
26
25
  </operations>
27
26
 
28
27
  <rules>
29
- 1. **Anchor on unique, structural lines.** You **SHOULD** choose anchors like function signatures, class declarations, or distinct statements — lines that appear exactly once. Blank lines, `}`, and `return null;` repeat throughout a file; anchoring on them risks matching the wrong location. When inserting between blocks, anchor on the nearest unique declaration using `prepend` or `append`.
30
- 2. **Prefer insertion over neighbor rewrites.** You **SHOULD** use `prepend`/`append` anchored on a structural boundary (`}`, `]`, `},`) rather than replacing adjacent lines when adding code near existing code. This keeps the edit minimal and avoids accidentally rewriting lines that should stay.
31
- 3. **Include boundary lines in the replaced range.** `end` is inclusive and **MUST** point to the final line being replaced. If your replacement `lines` include a closing token (`}`, `]`, `)`, `);`, `},`), `end` **MUST** include the original closing line. Otherwise the original closer survives and you get a duplicate.
28
+ 1. **Anchor on unique, structural lines.** When inserting between blocks, anchor on the nearest unique declaration using `prepend` or `append`.
29
+ 2. **Use `prepend`/`append` only when the anchor line itself is not changing.** Inserting near an unchanged boundary keeps the edit minimal.
30
+ 3. **Use range `replace` when any line in the span changes.** If you need to both insert lines and modify a neighboring line, a range replace covering all lines to remove is way to go.
32
31
  </rules>
33
32
 
34
33
  <recovery>
@@ -1,8 +1,8 @@
1
1
  Reads files from local filesystem or internal URLs.
2
2
 
3
3
  <instruction>
4
- - Reads up to {{DEFAULT_MAX_LINES}} lines default
5
- - Use `offset` and `limit` for large files
4
+ - Reads up to {{DEFAULT_LIMIT}} lines default
5
+ - Use `offset` and `limit` for large files; max {{DEFAULT_MAX_LINES}} lines per call
6
6
  {{#if IS_HASHLINE_MODE}}
7
7
  - Text output is CID prefixed: `LINE#ID:content`
8
8
  {{else}}
package/src/sdk.ts CHANGED
@@ -1,12 +1,6 @@
1
- import {
2
- Agent,
3
- type AgentEvent,
4
- type AgentMessage,
5
- type AgentTool,
6
- INTENT_FIELD,
7
- type ThinkingLevel,
8
- } from "@oh-my-pi/pi-agent-core";
9
- import { type Message, type Model, supportsXhigh } from "@oh-my-pi/pi-ai";
1
+ import { Agent, type AgentEvent, type AgentMessage, type AgentTool, INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
2
+ import { type Message, type Model, supportsXhigh, type ThinkingLevel } from "@oh-my-pi/pi-ai";
3
+
10
4
  import { prewarmOpenAICodexResponses } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
11
5
  import type { Component } from "@oh-my-pi/pi-tui";
12
6
  import { $env, getAgentDbPath, getAgentDir, getProjectDir, logger, postmortem } from "@oh-my-pi/pi-utils";
@@ -15,7 +9,7 @@ import { AsyncJobManager } from "./async";
15
9
  import { loadCapability } from "./capability";
16
10
  import { type Rule, ruleCapability } from "./capability/rule";
17
11
  import { ModelRegistry } from "./config/model-registry";
18
- import { formatModelString, parseModelPattern, parseModelString } from "./config/model-resolver";
12
+ import { formatModelString, parseModelPattern, parseModelString, resolveModelRoleValue } from "./config/model-resolver";
19
13
  import {
20
14
  loadPromptTemplates as loadPromptTemplatesInternal,
21
15
  type PromptTemplate,
@@ -651,6 +645,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
651
645
  const hasThinkingEntry = sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
652
646
 
653
647
  const hasExplicitModel = options.model !== undefined || options.modelPattern !== undefined;
648
+ const modelMatchPreferences = {
649
+ usageOrder: settings.getStorage()?.getModelUsageOrder(),
650
+ };
651
+ const defaultRoleSpec = resolveModelRoleValue(settings.getModelRole("default"), modelRegistry.getAvailable(), {
652
+ settings,
653
+ matchPreferences: modelMatchPreferences,
654
+ });
654
655
  let model = options.model;
655
656
  let modelFallbackMessage: string | undefined;
656
657
  // If session has data, try to restore model from it.
@@ -671,16 +672,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
671
672
 
672
673
  // If still no model, try settings default.
673
674
  // Skip settings fallback when an explicit model was requested.
674
- if (!hasExplicitModel && !model) {
675
- const settingsDefaultModel = settings.getModelRole("default");
676
- if (settingsDefaultModel) {
677
- const parsedModel = parseModelString(settingsDefaultModel);
678
- if (parsedModel) {
679
- const settingsModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
680
- if (settingsModel && (await hasModelApiKey(settingsModel))) {
681
- model = settingsModel;
682
- }
683
- }
675
+ if (!hasExplicitModel && !model && defaultRoleSpec.model) {
676
+ if (await hasModelApiKey(defaultRoleSpec.model)) {
677
+ model = defaultRoleSpec.model;
684
678
  }
685
679
  }
686
680
 
@@ -700,11 +694,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
700
694
 
701
695
  let thinkingLevel = options.thinkingLevel;
702
696
 
703
- // If session has data, restore thinking level from it
704
- if (thinkingLevel === undefined && hasExistingSession) {
705
- thinkingLevel = hasThinkingEntry
706
- ? (existingSession.thinkingLevel as ThinkingLevel)
707
- : ((settings.get("defaultThinkingLevel") ?? "off") as ThinkingLevel);
697
+ // If session has data and includes a thinking entry, restore it
698
+ if (thinkingLevel === undefined && hasExistingSession && hasThinkingEntry) {
699
+ thinkingLevel = existingSession.thinkingLevel as ThinkingLevel;
700
+ }
701
+
702
+ if (thinkingLevel === undefined && !hasExplicitModel && !hasThinkingEntry && defaultRoleSpec.explicitThinkingLevel) {
703
+ thinkingLevel = defaultRoleSpec.thinkingLevel;
708
704
  }
709
705
 
710
706
  // Fall back to settings default
@@ -24,7 +24,6 @@ import {
24
24
  type AgentState,
25
25
  type AgentTool,
26
26
  INTENT_FIELD,
27
- type ThinkingLevel,
28
27
  } from "@oh-my-pi/pi-agent-core";
29
28
  import type {
30
29
  AssistantMessage,
@@ -33,6 +32,7 @@ import type {
33
32
  Model,
34
33
  ProviderSessionState,
35
34
  TextContent,
35
+ ThinkingLevel,
36
36
  ToolCall,
37
37
  ToolChoice,
38
38
  Usage,
@@ -40,6 +40,7 @@ import type {
40
40
  } from "@oh-my-pi/pi-ai";
41
41
  import {
42
42
  calculateRateLimitBackoffMs,
43
+ getAvailableThinkingLevels,
43
44
  isContextOverflow,
44
45
  modelsAreEqual,
45
46
  parseRateLimitReason,
@@ -49,7 +50,7 @@ import { abortableSleep, getAgentDbPath, isEnoent, logger } from "@oh-my-pi/pi-u
49
50
  import type { AsyncJob, AsyncJobManager } from "../async";
50
51
  import type { Rule } from "../capability/rule";
51
52
  import { MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "../config/model-registry";
52
- import { expandRoleAlias, parseModelString } from "../config/model-resolver";
53
+ import { extractExplicitThinkingSelector, parseModelString, resolveModelRoleValue } from "../config/model-resolver";
53
54
  import { expandPromptTemplate, type PromptTemplate, renderPromptTemplate } from "../config/prompt-templates";
54
55
  import type { Settings, SkillsSettings } from "../config/settings";
55
56
  import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
@@ -252,10 +253,6 @@ export interface HandoffResult {
252
253
  // ============================================================================
253
254
 
254
255
  /** Standard thinking levels */
255
- const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"];
256
-
257
- /** Thinking levels including xhigh (for supported models) */
258
- const THINKING_LEVELS_WITH_XHIGH: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
259
256
 
260
257
  const noOpUIContext: ExtensionUIContext = {
261
258
  select: async (_title, _options, _dialogOptions) => undefined,
@@ -295,7 +292,6 @@ export class AgentSession {
295
292
  readonly settings: Settings;
296
293
 
297
294
  #asyncJobManager: AsyncJobManager | undefined = undefined;
298
-
299
295
  #scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>;
300
296
  #promptTemplates: PromptTemplate[];
301
297
  #slashCommands: FileSlashCommand[];
@@ -2630,7 +2626,7 @@ export class AgentSession {
2630
2626
 
2631
2627
  this.#setModelWithProviderSessionReset(model);
2632
2628
  this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role);
2633
- this.settings.setModelRole(role, `${model.provider}/${model.id}`);
2629
+ this.settings.setModelRole(role, this.#formatRoleModelValue(role, model));
2634
2630
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
2635
2631
 
2636
2632
  // Re-clamp thinking level for new model's capabilities without persisting settings
@@ -2684,7 +2680,13 @@ export class AgentSession {
2684
2680
 
2685
2681
  const currentModel = this.model;
2686
2682
  if (!currentModel) return undefined;
2687
- const roleModels: Array<{ role: ModelRole; model: Model }> = [];
2683
+ const matchPreferences = { usageOrder: this.settings.getStorage()?.getModelUsageOrder() };
2684
+ const roleModels: Array<{
2685
+ role: ModelRole;
2686
+ model: Model;
2687
+ thinkingLevel?: ThinkingLevel;
2688
+ explicitThinkingLevel: boolean;
2689
+ }> = [];
2688
2690
 
2689
2691
  for (const role of roleOrder) {
2690
2692
  const roleModelStr =
@@ -2693,18 +2695,18 @@ export class AgentSession {
2693
2695
  : this.settings.getModelRole(role);
2694
2696
  if (!roleModelStr) continue;
2695
2697
 
2696
- const expandedRoleModelStr = expandRoleAlias(roleModelStr, this.settings);
2697
- const parsed = parseModelString(expandedRoleModelStr);
2698
- let match: Model | undefined;
2699
- if (parsed) {
2700
- match = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
2701
- }
2702
- if (!match) {
2703
- match = availableModels.find(m => m.id.toLowerCase() === expandedRoleModelStr.toLowerCase());
2704
- }
2705
- if (!match) continue;
2698
+ const resolved = resolveModelRoleValue(roleModelStr, availableModels, {
2699
+ settings: this.settings,
2700
+ matchPreferences,
2701
+ });
2702
+ if (!resolved.model) continue;
2706
2703
 
2707
- roleModels.push({ role, model: match });
2704
+ roleModels.push({
2705
+ role,
2706
+ model: resolved.model,
2707
+ thinkingLevel: resolved.thinkingLevel,
2708
+ explicitThinkingLevel: resolved.explicitThinkingLevel,
2709
+ });
2708
2710
  }
2709
2711
 
2710
2712
  if (roleModels.length <= 1) return undefined;
@@ -2724,6 +2726,10 @@ export class AgentSession {
2724
2726
  await this.setModel(next.model, next.role);
2725
2727
  }
2726
2728
 
2729
+ if (next.explicitThinkingLevel && next.thinkingLevel !== undefined) {
2730
+ this.setThinkingLevel(next.thinkingLevel);
2731
+ }
2732
+
2727
2733
  return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
2728
2734
  }
2729
2735
 
@@ -2764,7 +2770,7 @@ export class AgentSession {
2764
2770
  // Apply model
2765
2771
  this.#setModelWithProviderSessionReset(next.model);
2766
2772
  this.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
2767
- this.settings.setModelRole("default", `${next.model.provider}/${next.model.id}`);
2773
+ this.settings.setModelRole("default", this.#formatRoleModelValue("default", next.model));
2768
2774
  this.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`);
2769
2775
 
2770
2776
  // Apply thinking level (setThinkingLevel clamps to model capabilities)
@@ -2792,7 +2798,7 @@ export class AgentSession {
2792
2798
 
2793
2799
  this.#setModelWithProviderSessionReset(nextModel);
2794
2800
  this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
2795
- this.settings.setModelRole("default", `${nextModel.provider}/${nextModel.id}`);
2801
+ this.settings.setModelRole("default", this.#formatRoleModelValue("default", nextModel));
2796
2802
  this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
2797
2803
 
2798
2804
  // Re-clamp thinking level for new model's capabilities without persisting settings
@@ -2854,9 +2860,9 @@ export class AgentSession {
2854
2860
  * Get available thinking levels for current model.
2855
2861
  * The provider will clamp to what the specific model supports internally.
2856
2862
  */
2857
- getAvailableThinkingLevels(): ThinkingLevel[] {
2863
+ getAvailableThinkingLevels(): ReadonlyArray<ThinkingLevel> {
2858
2864
  if (!this.supportsThinking()) return ["off"];
2859
- return this.supportsXhighThinking() ? THINKING_LEVELS_WITH_XHIGH : THINKING_LEVELS;
2865
+ return getAvailableThinkingLevels(this.supportsXhighThinking());
2860
2866
  }
2861
2867
 
2862
2868
  /**
@@ -2873,8 +2879,8 @@ export class AgentSession {
2873
2879
  return !!this.model?.reasoning;
2874
2880
  }
2875
2881
 
2876
- #clampThinkingLevel(level: ThinkingLevel, availableLevels: ThinkingLevel[]): ThinkingLevel {
2877
- const ordered = THINKING_LEVELS_WITH_XHIGH;
2882
+ #clampThinkingLevel(level: ThinkingLevel, availableLevels: ReadonlyArray<ThinkingLevel>): ThinkingLevel {
2883
+ const ordered = getAvailableThinkingLevels(true);
2878
2884
  const available = new Set(availableLevels);
2879
2885
  const requestedIndex = ordered.indexOf(level);
2880
2886
  if (requestedIndex === -1) {
@@ -3531,33 +3537,13 @@ Be thorough - include exact file paths, function names, error messages, and tech
3531
3537
  const availableModels = this.#modelRegistry.getAvailable();
3532
3538
  if (availableModels.length === 0) return undefined;
3533
3539
 
3534
- const candidates: Model[] = [];
3535
- const seen = new Set<string>();
3536
- const addCandidate = (candidate: Model | undefined): void => {
3537
- if (!candidate) return;
3538
- const key = this.#getModelKey(candidate);
3539
- if (seen.has(key)) return;
3540
- seen.add(key);
3541
- candidates.push(candidate);
3542
- };
3543
-
3544
- addCandidate(this.#resolveContextPromotionConfiguredTarget(currentModel, availableModels));
3545
-
3546
- const sameProviderLarger = [...availableModels]
3547
- .filter(
3548
- m => m.provider === currentModel.provider && m.api === currentModel.api && m.contextWindow > contextWindow,
3549
- )
3550
- .sort((a, b) => a.contextWindow - b.contextWindow);
3551
- addCandidate(sameProviderLarger[0]);
3552
- for (const candidate of candidates) {
3553
- if (modelsAreEqual(candidate, currentModel)) continue;
3554
- if (candidate.contextWindow <= contextWindow) continue;
3555
- const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
3556
- if (!apiKey) continue;
3557
- return candidate;
3558
- }
3559
-
3560
- return undefined;
3540
+ const candidate = this.#resolveContextPromotionConfiguredTarget(currentModel, availableModels);
3541
+ if (!candidate) return undefined;
3542
+ if (modelsAreEqual(candidate, currentModel)) return undefined;
3543
+ if (candidate.contextWindow <= contextWindow) return undefined;
3544
+ const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
3545
+ if (!apiKey) return undefined;
3546
+ return candidate;
3561
3547
  }
3562
3548
 
3563
3549
  #setModelWithProviderSessionReset(model: Model): void {
@@ -3597,6 +3583,15 @@ Be thorough - include exact file paths, function names, error messages, and tech
3597
3583
  return `${model.provider}/${model.id}`;
3598
3584
  }
3599
3585
 
3586
+ #formatRoleModelValue(role: ModelRole, model: Model): string {
3587
+ const modelKey = `${model.provider}/${model.id}`;
3588
+ const existingRoleValue = this.settings.getModelRole(role);
3589
+ if (!existingRoleValue) return modelKey;
3590
+
3591
+ const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings);
3592
+ if (thinkingLevel === undefined) return modelKey;
3593
+ return `${modelKey}:${thinkingLevel}`;
3594
+ }
3600
3595
  #resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
3601
3596
  const configuredTarget = currentModel.contextPromotionTarget?.trim();
3602
3597
  if (!configuredTarget) return undefined;
@@ -3619,12 +3614,10 @@ Be thorough - include exact file paths, function names, error messages, and tech
3619
3614
 
3620
3615
  if (!roleModelStr) return undefined;
3621
3616
 
3622
- const parsed = parseModelString(roleModelStr);
3623
- if (parsed) {
3624
- return availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
3625
- }
3626
- const roleLower = roleModelStr.toLowerCase();
3627
- return availableModels.find(m => m.id.toLowerCase() === roleLower);
3617
+ return resolveModelRoleValue(roleModelStr, availableModels, {
3618
+ settings: this.settings,
3619
+ matchPreferences: { usageOrder: this.settings.getStorage()?.getModelUsageOrder() },
3620
+ }).model;
3628
3621
  }
3629
3622
 
3630
3623
  #getCompactionModelCandidates(availableModels: Model[]): Model[] {
@@ -4575,6 +4568,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
4575
4568
 
4576
4569
  if (!skipConversationRestore) {
4577
4570
  this.agent.replaceMessages(sessionContext.messages);
4571
+ this.#closeCodexProviderSessionsForHistoryRewrite();
4578
4572
  }
4579
4573
 
4580
4574
  return { selectedText, cancelled: false };
@@ -4729,6 +4723,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
4729
4723
  const sessionContext = this.sessionManager.buildSessionContext();
4730
4724
  this.agent.replaceMessages(sessionContext.messages);
4731
4725
  this.#syncTodoPhasesFromBranch();
4726
+ this.#closeCodexProviderSessionsForHistoryRewrite();
4732
4727
 
4733
4728
  // Emit session_tree event
4734
4729
  if (this.#extensionRunner) {
@@ -4,8 +4,8 @@
4
4
  * Runs each subagent on the main thread and forwards AgentEvents for progress tracking.
5
5
  */
6
6
  import path from "node:path";
7
- import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
8
- import type { Api, Model, ToolChoice } from "@oh-my-pi/pi-ai";
7
+ import type { AgentEvent } from "@oh-my-pi/pi-agent-core";
8
+ import type { Api, Model, ThinkingLevel, ToolChoice } from "@oh-my-pi/pi-ai";
9
9
  import { logger, untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import type { TSchema } from "@sinclair/typebox";
11
11
  import Ajv, { type ValidateFunction } from "ajv";
@@ -938,12 +938,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
938
938
  await modelRegistry.refresh();
939
939
  checkAbort();
940
940
 
941
- const { model, thinkingLevel: resolvedThinkingLevel } = resolveModelOverride(
942
- modelPatterns,
943
- modelRegistry,
944
- settings,
945
- );
946
- const effectiveThinkingLevel = thinkingLevel ?? resolvedThinkingLevel;
941
+ const {
942
+ model,
943
+ thinkingLevel: resolvedThinkingLevel,
944
+ explicitThinkingLevel,
945
+ } = resolveModelOverride(modelPatterns, modelRegistry, settings);
946
+ const effectiveThinkingLevel = explicitThinkingLevel
947
+ ? resolvedThinkingLevel
948
+ : (thinkingLevel ?? resolvedThinkingLevel);
947
949
 
948
950
  const sessionManager = sessionFile
949
951
  ? await SessionManager.open(sessionFile)
package/src/task/types.ts CHANGED
@@ -1,5 +1,4 @@
1
- import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
- import type { Usage } from "@oh-my-pi/pi-ai";
1
+ import type { ThinkingLevel, Usage } from "@oh-my-pi/pi-ai";
3
2
  import { $env } from "@oh-my-pi/pi-utils";
4
3
  import { type Static, Type } from "@sinclair/typebox";
5
4
  import type { NestedRepoPatch } from "./worktree";