gentle-pi 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -246,7 +246,7 @@ Saved at:
246
246
 
247
247
  Run `/reload` or start a new Pi session after switching persona.
248
248
 
249
- ## Model assignment
249
+ ## Model and effort assignment
250
250
 
251
251
  ```text
252
252
  /gentle:models
@@ -258,15 +258,15 @@ The modal discovers:
258
258
  - user agents in `~/.pi/agent/agents/` and `~/.agents/`;
259
259
  - built-in agents from `pi-subagents`.
260
260
 
261
- Recommended model shape:
261
+ Recommended model/effort shape:
262
262
 
263
- | Agent kind | Recommended model |
264
- | -------------------------- | ---------------------------------------------------- |
265
- | Explore, proposal, archive | Fast and cheap is usually enough. |
266
- | Spec, design, tasks | Strong reasoning model. |
267
- | Apply | Strong coding and tool-use model. |
268
- | Verify / review | Strong fresh-context model. |
269
- | Tiny utilities | Inherit active/default model unless they bottleneck. |
263
+ | Agent kind | Recommended model | Recommended effort (`thinking`) |
264
+ | -------------------------- | ---------------------------------------------------- | ------------------------------- |
265
+ | Explore, proposal, archive | Fast and cheap is usually enough. | `off` to `low` |
266
+ | Spec, design, tasks | Strong reasoning model. | `medium` to `high` |
267
+ | Apply | Strong coding and tool-use model. | `medium` to `high` |
268
+ | Verify / review | Strong fresh-context model. | `high` |
269
+ | Tiny utilities | Inherit active/default model unless they bottleneck. | `inherit` |
270
270
 
271
271
  Saved at:
272
272
 
@@ -274,12 +274,28 @@ Saved at:
274
274
  .pi/gentle-ai/models.json
275
275
  ```
276
276
 
277
+ Config shape (per agent):
278
+
279
+ ```json
280
+ {
281
+ "sdd-design": {
282
+ "model": "anthropic/claude-sonnet-4",
283
+ "thinking": "high"
284
+ },
285
+ "sdd-archive": {
286
+ "model": "openai/gpt-5-mini"
287
+ }
288
+ }
289
+ ```
290
+
291
+ Legacy string entries are still accepted and treated as `model`-only config.
292
+
277
293
  ## Commands
278
294
 
279
295
  | Command | What it does |
280
296
  | -------------------------------- | ------------------------------------------------------------ |
281
297
  | `/gentle-ai:status` | Shows package, SDD asset, OpenSpec, and model config status. |
282
- | `/gentle:models` | Opens model assignment UI. |
298
+ | `/gentle:models` | Opens model + effort assignment UI. |
283
299
  | `/gentle:persona` | Switches persona mode. |
284
300
  | `/sdd-init` | Initializes or refreshes `openspec/config.yaml`. |
285
301
  | `/gentle-ai:install-sdd` | Reinstalls SDD assets without overwriting local files. |
@@ -101,7 +101,12 @@ const SDD_AGENT_NAMES = [
101
101
  ] as const;
102
102
 
103
103
  type SddAgentName = (typeof SDD_AGENT_NAMES)[number];
104
- type AgentModelConfig = Record<string, string>;
104
+ type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
105
+ interface AgentRoutingEntry {
106
+ model?: string;
107
+ thinking?: ThinkingLevel;
108
+ }
109
+ type AgentModelConfig = Record<string, AgentRoutingEntry>;
105
110
  type AgentSource = "project" | "user" | "builtin";
106
111
 
107
112
  interface AgentEntry {
@@ -113,6 +118,16 @@ interface AgentEntry {
113
118
  const KEEP_CURRENT = "Keep current";
114
119
  const INHERIT_MODEL = "Inherit active/default model";
115
120
  const CUSTOM_MODEL = "Custom model id";
121
+ const INHERIT_THINKING = "Inherit effort";
122
+ const THINKING_OPTIONS: (ThinkingLevel | typeof INHERIT_THINKING)[] = [
123
+ INHERIT_THINKING,
124
+ "off",
125
+ "minimal",
126
+ "low",
127
+ "medium",
128
+ "high",
129
+ "xhigh",
130
+ ];
116
131
 
117
132
  const MODEL_CONTROL_OPTIONS = [
118
133
  KEEP_CURRENT,
@@ -253,6 +268,32 @@ function writePersonaMode(cwd: string, mode: PersonaMode): void {
253
268
  writeFileSync(path, `${JSON.stringify({ mode }, null, 2)}\n`);
254
269
  }
255
270
 
271
+ function isThinkingLevel(value: unknown): value is ThinkingLevel {
272
+ return (
273
+ value === "off" ||
274
+ value === "minimal" ||
275
+ value === "low" ||
276
+ value === "medium" ||
277
+ value === "high" ||
278
+ value === "xhigh"
279
+ );
280
+ }
281
+
282
+ function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined {
283
+ if (typeof value === "string") {
284
+ const model = value.trim();
285
+ return model.length > 0 ? { model } : undefined;
286
+ }
287
+ if (!isRecord(value)) return undefined;
288
+ const model =
289
+ typeof value.model === "string" && value.model.trim().length > 0
290
+ ? value.model.trim()
291
+ : undefined;
292
+ const thinking = isThinkingLevel(value.thinking) ? value.thinking : undefined;
293
+ if (!model && !thinking) return undefined;
294
+ return { model, thinking };
295
+ }
296
+
256
297
  function readModelConfig(cwd: string): AgentModelConfig {
257
298
  const path = modelConfigPath(cwd);
258
299
  if (!existsSync(path)) return {};
@@ -261,9 +302,8 @@ function readModelConfig(cwd: string): AgentModelConfig {
261
302
  if (!isRecord(parsed)) return {};
262
303
  const config: AgentModelConfig = {};
263
304
  for (const [name, value] of Object.entries(parsed)) {
264
- if (typeof value === "string" && value.trim().length > 0) {
265
- config[name] = value.trim();
266
- }
305
+ const entry = normalizeRoutingEntry(value);
306
+ if (entry) config[name] = entry;
267
307
  }
268
308
  return config;
269
309
  } catch {
@@ -274,12 +314,23 @@ function readModelConfig(cwd: string): AgentModelConfig {
274
314
  function writeModelConfig(cwd: string, config: AgentModelConfig): void {
275
315
  const path = modelConfigPath(cwd);
276
316
  mkdirSync(dirname(path), { recursive: true });
277
- writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
317
+ const cleaned: AgentModelConfig = {};
318
+ for (const [name, value] of Object.entries(config)) {
319
+ const entry = normalizeRoutingEntry(value);
320
+ if (entry) cleaned[name] = entry;
321
+ }
322
+ writeFileSync(path, `${JSON.stringify(cleaned, null, 2)}\n`);
323
+ }
324
+
325
+ function cloneModelConfig(config: AgentModelConfig): AgentModelConfig {
326
+ return Object.fromEntries(
327
+ Object.entries(config).map(([name, entry]) => [name, { ...entry }]),
328
+ );
278
329
  }
279
330
 
280
- function updateFrontmatterModel(
331
+ function updateFrontmatterRouting(
281
332
  content: string,
282
- model: string | undefined,
333
+ entry: AgentRoutingEntry | undefined,
283
334
  ): string {
284
335
  if (!content.startsWith("---\n")) return content;
285
336
  const endIndex = content.indexOf("\n---", 4);
@@ -288,14 +339,19 @@ function updateFrontmatterModel(
288
339
  const body = content.slice(endIndex);
289
340
  const lines = frontmatter
290
341
  .split("\n")
291
- .filter((line) => !line.startsWith("model:"));
292
- if (model !== undefined) {
342
+ .filter(
343
+ (line) => !line.startsWith("model:") && !line.startsWith("thinking:"),
344
+ );
345
+ const toInsert: string[] = [];
346
+ if (entry?.model) toInsert.push(`model: ${entry.model}`);
347
+ if (entry?.thinking) toInsert.push(`thinking: ${entry.thinking}`);
348
+ if (toInsert.length > 0) {
293
349
  const descriptionIndex = lines.findIndex((line) =>
294
350
  line.startsWith("description:"),
295
351
  );
296
352
  const insertIndex =
297
353
  descriptionIndex >= 0 ? descriptionIndex + 1 : Math.min(1, lines.length);
298
- lines.splice(insertIndex, 0, `model: ${model}`);
354
+ lines.splice(insertIndex, 0, ...toInsert);
299
355
  }
300
356
  return `---\n${lines.join("\n")}${body}`;
301
357
  }
@@ -372,7 +428,7 @@ function projectSettingsPath(cwd: string): string {
372
428
  function updateBuiltinModelOverride(
373
429
  cwd: string,
374
430
  name: string,
375
- model: string | undefined,
431
+ entry: AgentRoutingEntry | undefined,
376
432
  ): boolean {
377
433
  const path = projectSettingsPath(cwd);
378
434
  let settings: Record<string, unknown> = {};
@@ -393,8 +449,10 @@ function updateBuiltinModelOverride(
393
449
  const current = isRecord(agentOverrides[name])
394
450
  ? { ...agentOverrides[name] }
395
451
  : {};
396
- if (model === undefined) delete current.model;
397
- else current.model = model;
452
+ if (entry?.model === undefined) delete current.model;
453
+ else current.model = entry.model;
454
+ if (entry?.thinking === undefined) delete current.thinking;
455
+ else current.thinking = entry.thinking;
398
456
  if (Object.keys(current).length > 0) agentOverrides[name] = current;
399
457
  else delete agentOverrides[name];
400
458
  if (Object.keys(agentOverrides).length > 0)
@@ -414,9 +472,9 @@ function applyModelConfig(
414
472
  let updated = 0;
415
473
  let skipped = 0;
416
474
  for (const agent of listDiscoverableAgents(cwd)) {
417
- const model = config[agent.name];
475
+ const entry = config[agent.name];
418
476
  if (agent.source === "builtin") {
419
- if (updateBuiltinModelOverride(cwd, agent.name, model)) updated += 1;
477
+ if (updateBuiltinModelOverride(cwd, agent.name, entry)) updated += 1;
420
478
  else skipped += 1;
421
479
  continue;
422
480
  }
@@ -425,7 +483,7 @@ function applyModelConfig(
425
483
  continue;
426
484
  }
427
485
  const original = readFileSync(agent.filePath, "utf8");
428
- const next = updateFrontmatterModel(original, model);
486
+ const next = updateFrontmatterRouting(original, entry);
429
487
  if (next === original) {
430
488
  skipped += 1;
431
489
  continue;
@@ -437,9 +495,12 @@ function applyModelConfig(
437
495
  }
438
496
 
439
497
  function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
440
- return listDiscoverableAgents(cwd).map(
441
- (agent) => `${agent.name}: ${config[agent.name] ?? "inherit"}`,
442
- );
498
+ return listDiscoverableAgents(cwd).map((agent) => {
499
+ const entry = config[agent.name];
500
+ const model = entry?.model ?? "inherit";
501
+ const thinking = entry?.thinking ?? "inherit";
502
+ return `${agent.name}: model=${model}, effort=${thinking}`;
503
+ });
443
504
  }
444
505
 
445
506
  async function getPiModelOptions(ctx: ExtensionContext): Promise<string[]> {
@@ -458,16 +519,17 @@ interface OverlayComponent {
458
519
 
459
520
  type ModelPanelResult =
460
521
  | { type: "save"; config: AgentModelConfig }
461
- | { type: "custom"; agent: string | "all" }
522
+ | { type: "custom"; agent: string | "all"; config: AgentModelConfig }
462
523
  | { type: "cancel" };
463
524
 
464
525
  const SET_ALL_AGENTS = "Set all agents";
465
526
 
466
527
  class SddModelPanel implements OverlayComponent {
467
528
  private cursor = 0;
468
- private mode: "agents" | "models" = "agents";
529
+ private mode: "agents" | "models" | "effort" = "agents";
469
530
  private selectedRow = SET_ALL_AGENTS;
470
531
  private modelCursor = 0;
532
+ private effortCursor = 0;
471
533
  private query = "";
472
534
  private readonly draft: AgentModelConfig;
473
535
  private readonly rows: string[];
@@ -480,7 +542,7 @@ class SddModelPanel implements OverlayComponent {
480
542
  agents: string[],
481
543
  done: (result: ModelPanelResult) => void,
482
544
  ) {
483
- this.draft = { ...initialConfig };
545
+ this.draft = cloneModelConfig(initialConfig);
484
546
  this.rows = [SET_ALL_AGENTS, ...agents];
485
547
  this.modelOptions = modelOptions;
486
548
  this.done = done;
@@ -493,13 +555,17 @@ class SddModelPanel implements OverlayComponent {
493
555
  this.handleModelInput(data);
494
556
  return;
495
557
  }
558
+ if (this.mode === "effort") {
559
+ this.handleEffortInput(data);
560
+ return;
561
+ }
496
562
  this.handleAgentInput(data);
497
563
  }
498
564
 
499
565
  render(width: number): string[] {
500
- return this.mode === "models"
501
- ? this.renderModelPicker(width)
502
- : this.renderAgentList(width);
566
+ if (this.mode === "models") return this.renderModelPicker(width);
567
+ if (this.mode === "effort") return this.renderEffortPicker(width);
568
+ return this.renderAgentList(width);
503
569
  }
504
570
 
505
571
  private handleAgentInput(data: string): void {
@@ -521,13 +587,21 @@ class SddModelPanel implements OverlayComponent {
521
587
  return;
522
588
  }
523
589
  if (data === "i") {
524
- this.applySelection(undefined);
590
+ this.applyInherit();
591
+ return;
592
+ }
593
+ if (data === "e") {
594
+ this.selectedRow = this.rows[this.cursor] ?? SET_ALL_AGENTS;
595
+ this.mode = "effort";
596
+ this.effortCursor = 0;
525
597
  return;
526
598
  }
527
599
  if (data === "c") {
528
600
  const row = this.rows[this.cursor];
529
- if (row === SET_ALL_AGENTS) this.done({ type: "custom", agent: "all" });
530
- else if (row) this.done({ type: "custom", agent: row });
601
+ if (row === SET_ALL_AGENTS)
602
+ this.done({ type: "custom", agent: "all", config: this.draft });
603
+ else if (row)
604
+ this.done({ type: "custom", agent: row, config: this.draft });
531
605
  return;
532
606
  }
533
607
  if (!matchesKey(data, "return")) return;
@@ -582,6 +656,7 @@ class SddModelPanel implements OverlayComponent {
582
656
  this.done({
583
657
  type: "custom",
584
658
  agent: this.selectedRow === SET_ALL_AGENTS ? "all" : this.selectedRow,
659
+ config: this.draft,
585
660
  });
586
661
  return;
587
662
  }
@@ -589,7 +664,9 @@ class SddModelPanel implements OverlayComponent {
589
664
  this.mode = "agents";
590
665
  return;
591
666
  }
592
- this.applySelection(selected === INHERIT_MODEL ? undefined : selected);
667
+ this.applyModelSelection(
668
+ selected === INHERIT_MODEL ? undefined : selected,
669
+ );
593
670
  this.mode = "agents";
594
671
  return;
595
672
  }
@@ -599,18 +676,52 @@ class SddModelPanel implements OverlayComponent {
599
676
  }
600
677
  }
601
678
 
602
- private applySelection(model: string | undefined): void {
679
+ private applyModelSelection(model: string | undefined): void {
603
680
  const row = this.rows[this.cursor];
604
681
  if (row === SET_ALL_AGENTS) {
605
- for (const name of this.rows.slice(1)) {
606
- if (model === undefined) delete this.draft[name];
607
- else this.draft[name] = model;
608
- }
682
+ for (const name of this.rows.slice(1)) this.setModel(name, model);
609
683
  return;
610
684
  }
611
685
  if (!row) return;
612
- if (model === undefined) delete this.draft[row];
613
- else this.draft[row] = model;
686
+ this.setModel(row, model);
687
+ }
688
+
689
+ private applyThinkingSelection(thinking: ThinkingLevel | undefined): void {
690
+ const row = this.selectedRow;
691
+ if (row === SET_ALL_AGENTS) {
692
+ for (const name of this.rows.slice(1)) this.setThinking(name, thinking);
693
+ return;
694
+ }
695
+ this.setThinking(row, thinking);
696
+ }
697
+
698
+ private applyInherit(): void {
699
+ const row = this.rows[this.cursor];
700
+ if (row === SET_ALL_AGENTS) {
701
+ for (const name of this.rows.slice(1)) this.clearEntry(name);
702
+ return;
703
+ }
704
+ if (row) this.clearEntry(row);
705
+ }
706
+
707
+ private setModel(name: string, model: string | undefined): void {
708
+ const current = this.draft[name] ?? {};
709
+ if (model === undefined) delete current.model;
710
+ else current.model = model;
711
+ if (!current.model && !current.thinking) delete this.draft[name];
712
+ else this.draft[name] = current;
713
+ }
714
+
715
+ private setThinking(name: string, thinking: ThinkingLevel | undefined): void {
716
+ const current = this.draft[name] ?? {};
717
+ if (thinking === undefined) delete current.thinking;
718
+ else current.thinking = thinking;
719
+ if (!current.model && !current.thinking) delete this.draft[name];
720
+ else this.draft[name] = current;
721
+ }
722
+
723
+ private clearEntry(name: string): void {
724
+ delete this.draft[name];
614
725
  }
615
726
 
616
727
  private filteredModelOptions(): string[] {
@@ -648,7 +759,7 @@ class SddModelPanel implements OverlayComponent {
648
759
  lines.push("");
649
760
  lines.push(
650
761
  line(
651
- "j/k: navigate • enter: change model / confirm • i: inherit • c: custom • ctrl+s: save • esc: back",
762
+ "j/k: navigate • enter: change model / confirm • e: change effort • i: inherit all • c: custom model • ctrl+s: save • esc: back",
652
763
  ),
653
764
  );
654
765
  return lines;
@@ -684,17 +795,70 @@ class SddModelPanel implements OverlayComponent {
684
795
  return lines;
685
796
  }
686
797
 
798
+ private handleEffortInput(data: string): void {
799
+ if (matchesKey(data, "ctrl+c")) {
800
+ this.done({ type: "cancel" });
801
+ return;
802
+ }
803
+ if (matchesKey(data, "escape")) {
804
+ this.mode = "agents";
805
+ return;
806
+ }
807
+ if (matchesKey(data, "down") || data === "j") {
808
+ this.effortCursor = Math.min(
809
+ Math.max(0, THINKING_OPTIONS.length - 1),
810
+ this.effortCursor + 1,
811
+ );
812
+ return;
813
+ }
814
+ if (matchesKey(data, "up") || data === "k") {
815
+ this.effortCursor = Math.max(0, this.effortCursor - 1);
816
+ return;
817
+ }
818
+ if (!matchesKey(data, "return")) return;
819
+ const selected = THINKING_OPTIONS[this.effortCursor];
820
+ if (selected === INHERIT_THINKING) this.applyThinkingSelection(undefined);
821
+ else this.applyThinkingSelection(selected);
822
+ this.mode = "agents";
823
+ }
824
+
825
+ private renderEffortPicker(width: number): string[] {
826
+ const lines: string[] = [];
827
+ const line = (text = "") =>
828
+ truncateToWidth(text, Math.max(1, width), "…", true);
829
+ lines.push(line(`Select effort for ${this.selectedRow}`));
830
+ lines.push("");
831
+ for (let i = 0; i < THINKING_OPTIONS.length; i++) {
832
+ const focused = i === this.effortCursor;
833
+ lines.push(line(`${focused ? "▸" : " "} ${THINKING_OPTIONS[i]}`));
834
+ }
835
+ lines.push("");
836
+ lines.push(line("j/k: navigate • enter: select • esc: back"));
837
+ return lines;
838
+ }
839
+
687
840
  private renderSetAllLabel(row: string): string {
688
- const values = this.rows
841
+ const models = this.rows
842
+ .slice(1)
843
+ .map((name) => this.draft[name]?.model ?? "inherit");
844
+ const efforts = this.rows
689
845
  .slice(1)
690
- .map((name) => this.draft[name] ?? "inherit");
691
- const first = values[0] ?? "inherit";
692
- const allSame = values.every((value) => value === first);
693
- return `${row.padEnd(20)} ${allSame ? first : "mixed"}`;
846
+ .map((name) => this.draft[name]?.thinking ?? "inherit");
847
+ const firstModel = models[0] ?? "inherit";
848
+ const firstEffort = efforts[0] ?? "inherit";
849
+ const modelLabel = models.every((value) => value === firstModel)
850
+ ? firstModel
851
+ : "mixed";
852
+ const effortLabel = efforts.every((value) => value === firstEffort)
853
+ ? firstEffort
854
+ : "mixed";
855
+ return `${row.padEnd(20)} model=${modelLabel}, effort=${effortLabel}`;
694
856
  }
695
857
 
696
858
  private renderAgentLabel(row: string): string {
697
- return `${row.padEnd(20)} ${this.draft[row] ?? "inherit"}`;
859
+ const model = this.draft[row]?.model ?? "inherit";
860
+ const effort = this.draft[row]?.thinking ?? "inherit";
861
+ return `${row.padEnd(20)} model=${model}, effort=${effort}`;
698
862
  }
699
863
  }
700
864
 
@@ -723,8 +887,11 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
723
887
  let config = readModelConfig(ctx.cwd);
724
888
  let result = await showSddModelPanel(ctx, config);
725
889
  while (result.type === "custom") {
890
+ config = cloneModelConfig(result.config);
726
891
  const current =
727
- result.agent === "all" ? "inherit" : (config[result.agent] ?? "inherit");
892
+ result.agent === "all"
893
+ ? "inherit"
894
+ : (config[result.agent]?.model ?? "inherit");
728
895
  const custom = await ctx.ui.input(
729
896
  `${result.agent === "all" ? "all agents" : result.agent} custom model id`,
730
897
  current === "inherit" ? "provider/model" : current,
@@ -733,11 +900,22 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
733
900
  const trimmed = custom.trim();
734
901
  if (trimmed.length > 0) {
735
902
  if (result.agent === "all") {
736
- config = Object.fromEntries(
737
- listDiscoverableAgents(ctx.cwd).map((agent) => [agent.name, trimmed]),
738
- );
903
+ const next: AgentModelConfig = { ...config };
904
+ for (const agent of listDiscoverableAgents(ctx.cwd)) {
905
+ next[agent.name] = {
906
+ ...(next[agent.name] ?? {}),
907
+ model: trimmed,
908
+ };
909
+ }
910
+ config = next;
739
911
  } else {
740
- config = { ...config, [result.agent]: trimmed };
912
+ config = {
913
+ ...config,
914
+ [result.agent]: {
915
+ ...(config[result.agent] ?? {}),
916
+ model: trimmed,
917
+ },
918
+ };
741
919
  }
742
920
  }
743
921
  result = await showSddModelPanel(ctx, config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import assert from "node:assert/strict";
3
- import { mkdtemp, rm } from "node:fs/promises";
3
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -176,6 +176,106 @@ async function run() {
176
176
  await rm(sddCwd, { recursive: true, force: true });
177
177
  }
178
178
 
179
+ const modelsCwd = await tempWorkspace();
180
+ try {
181
+ await mkdir(join(modelsCwd, ".pi", "agents"), { recursive: true });
182
+ await mkdir(
183
+ join(modelsCwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
184
+ { recursive: true },
185
+ );
186
+ await writeFile(
187
+ join(
188
+ modelsCwd,
189
+ ".pi",
190
+ "npm",
191
+ "node_modules",
192
+ "pi-subagents",
193
+ "agents",
194
+ "worker.md",
195
+ ),
196
+ `---\nname: worker\ndescription: Builtin worker\n---\n`,
197
+ );
198
+ await writeFile(
199
+ join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
200
+ `---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
201
+ );
202
+ await mkdir(join(modelsCwd, ".pi", "gentle-ai"), { recursive: true });
203
+ await writeFile(
204
+ join(modelsCwd, ".pi", "gentle-ai", "models.json"),
205
+ JSON.stringify({ "sdd-apply": "openai/gpt-5" }, null, 2),
206
+ );
207
+
208
+ const ctx = createCtx(modelsCwd, true);
209
+ await hooks.get("session_start")[0]({ reason: "startup" }, ctx);
210
+ const legacyAppliedAgent = await readFile(
211
+ join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
212
+ "utf8",
213
+ );
214
+ assert.match(legacyAppliedAgent, /model: openai\/gpt-5/);
215
+ assert.doesNotMatch(legacyAppliedAgent, /thinking:/);
216
+
217
+ ctx.ui.custom = () =>
218
+ Promise.resolve({
219
+ type: "save",
220
+ config: {
221
+ "sdd-apply": { model: "openai/gpt-5", thinking: "high" },
222
+ worker: { model: "openai/gpt-5-mini", thinking: "low" },
223
+ },
224
+ });
225
+ await commands.get("gentle:models").handler("", ctx);
226
+
227
+ const savedConfig = JSON.parse(
228
+ await readFile(join(modelsCwd, ".pi", "gentle-ai", "models.json"), "utf8"),
229
+ );
230
+ assert.deepEqual(savedConfig["sdd-apply"], {
231
+ model: "openai/gpt-5",
232
+ thinking: "high",
233
+ });
234
+
235
+ const applyAgent = await readFile(
236
+ join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
237
+ "utf8",
238
+ );
239
+ assert.match(applyAgent, /model: openai\/gpt-5/);
240
+ assert.match(applyAgent, /thinking: high/);
241
+
242
+ const settings = JSON.parse(
243
+ await readFile(join(modelsCwd, ".pi", "settings.json"), "utf8"),
244
+ );
245
+ assert.equal(
246
+ settings.subagents.agentOverrides.worker.model,
247
+ "openai/gpt-5-mini",
248
+ );
249
+ assert.equal(settings.subagents.agentOverrides.worker.thinking, "low");
250
+
251
+ let customPanelCalls = 0;
252
+ ctx.ui.input = async () => "custom/provider-model";
253
+ ctx.ui.custom = (factory) =>
254
+ new Promise((resolve) => {
255
+ customPanelCalls += 1;
256
+ const panel = factory(null, null, null, resolve);
257
+ if (customPanelCalls === 1) {
258
+ panel.handleInput("e"); // effort picker for all agents
259
+ for (let i = 0; i < 4; i++) panel.handleInput("j"); // medium
260
+ panel.handleInput("\r");
261
+ panel.handleInput("c"); // custom model from the same unsaved draft
262
+ return;
263
+ }
264
+ panel.handleInput("\u0013"); // ctrl+s saves the draft reopened after custom model input
265
+ });
266
+ await commands.get("gentle:models").handler("", ctx);
267
+
268
+ const customSavedConfig = JSON.parse(
269
+ await readFile(join(modelsCwd, ".pi", "gentle-ai", "models.json"), "utf8"),
270
+ );
271
+ assert.deepEqual(customSavedConfig["sdd-apply"], {
272
+ model: "custom/provider-model",
273
+ thinking: "medium",
274
+ });
275
+ } finally {
276
+ await rm(modelsCwd, { recursive: true, force: true });
277
+ }
278
+
179
279
  const registryCwd = await tempWorkspace();
180
280
  try {
181
281
  const ctx = createCtx(registryCwd, true);