gentle-pi 0.10.1 → 0.10.3

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
@@ -68,7 +68,7 @@ pi install npm:gentle-pi
68
68
  Recommended companion packages:
69
69
 
70
70
  ```bash
71
- pi install npm:pi-subagents
71
+ pi install npm:pi-subagents-j0k3r
72
72
  pi install npm:pi-intercom
73
73
  pi install npm:gentle-engram
74
74
  pi install npm:pi-web-access
@@ -115,19 +115,19 @@ Typical flow:
115
115
  | Unknown codebase area or context-heavy investigation | Focused subagent delegation. |
116
116
  | Large, ambiguous, architectural, product-facing, or high-review-risk change | SDD/OpenSpec flow. |
117
117
 
118
- The goal is not ceremony. The goal is to avoid accidental chaos. Once a task stops being small, delegation is expected rather than optional.
118
+ The goal is not ceremony. The goal is to avoid accidental chaos. Once a task stops being small, delegation is mandatory.
119
119
 
120
120
  ### Delegation triggers
121
121
 
122
- `gentle-pi` keeps the parent session thin and uses subagents at the narrowest useful point:
122
+ `gentle-pi` keeps the parent session thin and delegates at the narrowest useful point. When the Pi Subagents extension is installed, the preferred runtime is the `subagent_*` tool family because it runs the user's configured project/global subagent definitions and preserves history/background behavior. If those tools are unavailable, the parent should fall back to Pi's native `Agent` tool or another available delegation mechanism. The requirement is delegation; the runtime is capability-dependent.
123
123
 
124
- | Trigger | Expected behavior |
125
- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
126
- | Reading 4+ files to understand a flow | Launch `scout` or `context-builder` and synthesize its handoff. |
127
- | Touching 2+ non-trivial code files | Use one `worker`, or require fresh review before completion. |
128
- | Commit, push, or PR after code changes | Run a fresh-context `reviewer` unless the diff is trivial docs/text. |
129
- | Wrong cwd, worktree/git accident, merge recovery, confusing test/env issue | Stop and run a fresh audit reviewer before continuing. |
130
- | Long monolithic session with accumulating complexity, roughly 20 tool calls, 5 exploratory reads, or 2 non-mechanical edits | Pause and delegate or explain why not. |
124
+ | Trigger | Required behavior |
125
+ | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
126
+ | Reading 4+ files to understand a flow | Launch `scout`, `context-builder`, or the closest read-only mapping subagent. |
127
+ | Touching 2+ non-trivial code files | Delegate one writer; do not continue inline unless delegation is unavailable. |
128
+ | Commit, push, or PR after code changes | Run a fresh-context `reviewer` unless the diff is trivial docs/text. |
129
+ | Wrong cwd, worktree/git accident, merge recovery, confusing test/env issue | Stop and run a fresh audit reviewer before continuing. |
130
+ | Long monolithic session with accumulating complexity, roughly 20 tool calls, 5 exploratory reads, or 2 non-mechanical edits | Pause and delegate the remaining work, or stop and explain the exact blocker. |
131
131
 
132
132
  The intended balanced loop for a bounded bugfix is:
133
133
 
@@ -346,7 +346,7 @@ The modal discovers:
346
346
 
347
347
  - project agents in `.pi/agents/` and `.agents/`;
348
348
  - user agents in `~/.pi/agent/agents/` and `~/.agents/`;
349
- - built-in agents from `pi-subagents`.
349
+ - built-in agents from `pi-subagents-j0k3r` when present.
350
350
 
351
351
  Recommended model/effort shape:
352
352
 
@@ -82,7 +82,17 @@ Examples:
82
82
  - run tests/builds and summarize results;
83
83
  - fresh-context review.
84
84
 
85
- Use `pi-subagents` when available. Prefer background/async for long exploration, implementation, tests, or review when the parent has independent work.
85
+ Use the configured subagent runtime when available. Prefer the `subagent_*` tools (`subagent_run`, status/result helpers) when the Pi Subagents extension is installed, because they run the user's configured project/global subagent definitions and preserve history/background behavior. Prefer `subagent_run` with `mode: "background"` for long independent exploration, implementation, tests, or review, and `mode: "task"` when the parent needs the result before continuing.
86
+
87
+ If `subagent_*` tools are unavailable, fall back to Pi's native `Agent` tool or another available delegation mechanism. The delegation trigger remains mandatory; the fallback changes the runtime, not the requirement to delegate. If no delegation mechanism is available, stop the complex work and explain the blocker instead of silently continuing inline.
88
+
89
+ ### Pi Subagent Model Routing
90
+
91
+ For generic Pi subagents (`delegate`, `worker`, `scout`, `reviewer`, `context-builder`, `oracle`, `planner`, `researcher`, or other non-SDD agents), do not pass the `model` parameter by default. Let `pi-subagents` resolve model and thinking from `.pi/settings.json`, `.pi/subagents.json`, global subagent config, and runtime defaults.
92
+
93
+ SDD model assignment tables apply only to SDD/Judgment-Day phase agents. They must not be used for generic Pi delegation.
94
+
95
+ Only pass `model` for generic subagents when the user explicitly requests a model override for that launch.
86
96
 
87
97
  Default balanced pattern for bounded implementation:
88
98
 
@@ -125,14 +135,14 @@ Core question: does this inflate parent context without need?
125
135
 
126
136
  ### Mandatory Delegation Triggers
127
137
 
128
- These are parent-orchestrator stop rules. Once any trigger fires, the parent must either delegate or explicitly tell the user why delegation would be unsafe or wasteful for this exact case. Do not inject these as child-agent permission to spawn subagents; children receive concrete role work and must not orchestrate.
138
+ These are parent-orchestrator stop rules. Once any trigger fires, the parent MUST delegate through the best available subagent runtime. Prefer `subagent_run` when present; otherwise use Pi's native `Agent` or another available delegation mechanism. Do not replace a required delegation with inline execution. Do not inject these as child-agent permission to spawn subagents; children receive concrete role work and must not orchestrate.
129
139
 
130
- 1. **4-file rule**: if understanding requires reading 4+ files, launch `scout` or `context-builder` with fresh context and a narrow mapping task.
131
- 2. **Multi-file write rule**: if implementation will touch 2+ non-trivial files, use one `worker` or keep writing inline only if a fresh reviewer will audit before completion.
132
- 3. **PR rule**: before commit/push/PR for code changes, run a fresh-context `reviewer` unless the diff is a trivial docs/text-only change.
133
- 4. **Incident rule**: after wrong `cwd`, accidental repo/worktree mutation, failed merge recovery, confusing test command, or environment workaround, stop and run a fresh audit reviewer.
134
- 5. **Long-session rule**: if accumulating work is no longer clearly local — roughly 20 tool calls, 5 exploratory file reads, or 2 non-mechanical edits without delegation — pause and choose `scout`, `worker`, or `reviewer` instead of silently continuing monolithically.
135
- 6. **Fresh review rule**: use `context: "fresh"` for adversarial review of diffs, conflicts, PR readiness, and incident audits. Use forked context for continuity-oriented `worker`/`oracle` tasks.
140
+ 1. **4-file rule**: if understanding requires reading 4+ files, launch `scout`, `context-builder`, or the closest read-only mapping subagent with fresh context and a narrow mapping task. State the fallback agent/runtime if the preferred one is unavailable.
141
+ 2. **Multi-file write rule**: if implementation will touch 2+ non-trivial files, delegate one writer; inline writing is allowed only for trivial/mechanical edits or when the parent explicitly records why no delegation runtime is available. A fresh review still follows delegated implementation.
142
+ 3. **PR rule**: before commit/push/PR for code changes, run a fresh-context reviewer unless the diff is trivial docs/text-only.
143
+ 4. **Incident rule**: after wrong `cwd`, accidental repo/worktree mutation, failed merge recovery, confusing test command, or environment workaround, stop and run a fresh audit reviewer before continuing.
144
+ 5. **Long-session rule**: if accumulating work is no longer clearly local — roughly 20 tool calls, 5 exploratory file reads, or 2 non-mechanical edits without delegation — pause and delegate the remaining work instead of silently continuing monolithically.
145
+ 6. **Fresh review rule**: use fresh-context reviewer/audit subagents for adversarial review of diffs, conflicts, PR readiness, and incidents. Use continuity-oriented workers only for implementation work that needs inherited state.
136
146
 
137
147
  ### Cost and Context Balance
138
148
 
@@ -734,7 +734,9 @@ function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined {
734
734
  if (!isRecord(value)) return undefined;
735
735
  const model = normalizeModelId(value.model);
736
736
  const thinking = isThinkingLevel(value.thinking) ? value.thinking : undefined;
737
- if (!model && !thinking) return undefined;
737
+ if (!model && !thinking) {
738
+ return Object.keys(value).length === 0 ? {} : undefined;
739
+ }
738
740
  return { model, thinking };
739
741
  }
740
742
 
@@ -987,13 +989,38 @@ async function listAgentsFromDirAsync(
987
989
  return entries;
988
990
  }
989
991
 
990
- function listDiscoverableAgents(cwd: string): AgentEntry[] {
991
- const globalAgentDir = join(gentlePiAgentHome(), "agents");
992
- const builtinDirs = [
992
+ function builtinAgentDirs(cwd: string): string[] {
993
+ return [
994
+ join(PACKAGE_ROOT, "..", "pi-subagents-j0k3r", "agents"),
995
+ join(cwd, ".pi", "npm", "node_modules", "pi-subagents-j0k3r", "agents"),
996
+ join(homedir(), ".local", "lib", "node_modules", "pi-subagents-j0k3r", "agents"),
993
997
  join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
994
998
  join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
995
999
  join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"),
996
1000
  ];
1001
+ }
1002
+
1003
+ function listBuiltinAgentNames(cwd: string): Set<string> {
1004
+ return new Set(
1005
+ builtinAgentDirs(cwd).flatMap((dir) =>
1006
+ listAgentsFromDir(dir, "builtin").map((agent) => agent.name),
1007
+ ),
1008
+ );
1009
+ }
1010
+
1011
+ async function listBuiltinAgentNamesAsync(cwd: string): Promise<Set<string>> {
1012
+ const names = new Set<string>();
1013
+ for (const dir of builtinAgentDirs(cwd)) {
1014
+ for (const agent of await listAgentsFromDirAsync(dir, "builtin")) {
1015
+ names.add(agent.name);
1016
+ }
1017
+ }
1018
+ return names;
1019
+ }
1020
+
1021
+ function listDiscoverableAgents(cwd: string): AgentEntry[] {
1022
+ const globalAgentDir = join(gentlePiAgentHome(), "agents");
1023
+ const builtinDirs = builtinAgentDirs(cwd);
997
1024
  const agents = [
998
1025
  ...builtinDirs.flatMap((dir) => listAgentsFromDir(dir, "builtin")),
999
1026
  ...listAgentsFromDir(globalAgentDir, "user"),
@@ -1008,11 +1035,7 @@ function listDiscoverableAgents(cwd: string): AgentEntry[] {
1008
1035
 
1009
1036
  async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
1010
1037
  const globalAgentDir = join(gentlePiAgentHome(), "agents");
1011
- const builtinDirs = [
1012
- join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
1013
- join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
1014
- join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"),
1015
- ];
1038
+ const builtinDirs = builtinAgentDirs(cwd);
1016
1039
  const agents: AgentEntry[] = [];
1017
1040
  for (const dir of builtinDirs) {
1018
1041
  agents.push(...(await listAgentsFromDirAsync(dir, "builtin")));
@@ -1045,6 +1068,10 @@ function projectSettingsPath(cwd: string): string {
1045
1068
  return join(cwd, ".pi", "settings.json");
1046
1069
  }
1047
1070
 
1071
+ function isClearRoutingEntry(entry: AgentRoutingEntry): boolean {
1072
+ return entry.model === undefined && entry.thinking === undefined;
1073
+ }
1074
+
1048
1075
  function updateBuiltinModelOverride(
1049
1076
  cwd: string,
1050
1077
  name: string,
@@ -1131,13 +1158,28 @@ export function applyModelConfig(
1131
1158
  ): { updated: number; skipped: number } {
1132
1159
  let updated = 0;
1133
1160
  let skipped = 0;
1161
+ const builtinNames = listBuiltinAgentNames(cwd);
1162
+ const seenAgents = new Set<string>();
1134
1163
  for (const agent of listDiscoverableAgents(cwd)) {
1164
+ seenAgents.add(agent.name);
1135
1165
  const entry = config[agent.name];
1136
1166
  if (agent.source === "builtin") {
1167
+ if (entry === undefined) {
1168
+ skipped += 1;
1169
+ continue;
1170
+ }
1137
1171
  if (updateBuiltinModelOverride(cwd, agent.name, entry)) updated += 1;
1138
1172
  else skipped += 1;
1139
1173
  continue;
1140
1174
  }
1175
+ if (entry === undefined) {
1176
+ skipped += 1;
1177
+ continue;
1178
+ }
1179
+ if (builtinNames.has(agent.name) || isClearRoutingEntry(entry)) {
1180
+ if (updateBuiltinModelOverride(cwd, agent.name, entry)) updated += 1;
1181
+ else skipped += 1;
1182
+ }
1141
1183
  if (!agent.filePath || !existsSync(agent.filePath)) {
1142
1184
  skipped += 1;
1143
1185
  continue;
@@ -1151,6 +1193,12 @@ export function applyModelConfig(
1151
1193
  writeFileSync(agent.filePath, next);
1152
1194
  updated += 1;
1153
1195
  }
1196
+ for (const [name, entry] of Object.entries(config)) {
1197
+ if (!seenAgents.has(name) && isClearRoutingEntry(entry)) {
1198
+ if (updateBuiltinModelOverride(cwd, name, entry)) updated += 1;
1199
+ else skipped += 1;
1200
+ }
1201
+ }
1154
1202
  return { updated, skipped };
1155
1203
  }
1156
1204
 
@@ -1160,14 +1208,30 @@ export async function applyModelConfigAsync(
1160
1208
  ): Promise<{ updated: number; skipped: number }> {
1161
1209
  let updated = 0;
1162
1210
  let skipped = 0;
1211
+ const builtinNames = await listBuiltinAgentNamesAsync(cwd);
1212
+ const seenAgents = new Set<string>();
1163
1213
  for (const agent of await listDiscoverableAgentsAsync(cwd)) {
1214
+ seenAgents.add(agent.name);
1164
1215
  const entry = config[agent.name];
1165
1216
  if (agent.source === "builtin") {
1217
+ if (entry === undefined) {
1218
+ skipped += 1;
1219
+ continue;
1220
+ }
1166
1221
  if (await updateBuiltinModelOverrideAsync(cwd, agent.name, entry))
1167
1222
  updated += 1;
1168
1223
  else skipped += 1;
1169
1224
  continue;
1170
1225
  }
1226
+ if (entry === undefined) {
1227
+ skipped += 1;
1228
+ continue;
1229
+ }
1230
+ if (builtinNames.has(agent.name) || isClearRoutingEntry(entry)) {
1231
+ if (await updateBuiltinModelOverrideAsync(cwd, agent.name, entry))
1232
+ updated += 1;
1233
+ else skipped += 1;
1234
+ }
1171
1235
  if (!agent.filePath || !(await pathExists(agent.filePath))) {
1172
1236
  skipped += 1;
1173
1237
  continue;
@@ -1181,6 +1245,13 @@ export async function applyModelConfigAsync(
1181
1245
  await writeFile(agent.filePath, next);
1182
1246
  updated += 1;
1183
1247
  }
1248
+ for (const [name, entry] of Object.entries(config)) {
1249
+ if (!seenAgents.has(name) && isClearRoutingEntry(entry)) {
1250
+ if (await updateBuiltinModelOverrideAsync(cwd, name, entry))
1251
+ updated += 1;
1252
+ else skipped += 1;
1253
+ }
1254
+ }
1184
1255
  return { updated, skipped };
1185
1256
  }
1186
1257
 
@@ -1500,7 +1571,7 @@ class SddModelPanel implements OverlayComponent {
1500
1571
  const current = this.draft[name] ?? {};
1501
1572
  if (model === undefined) delete current.model;
1502
1573
  else current.model = model;
1503
- if (!current.model && !current.thinking) delete this.draft[name];
1574
+ if (!current.model && !current.thinking) this.draft[name] = {};
1504
1575
  else this.draft[name] = current;
1505
1576
  }
1506
1577
 
@@ -1508,12 +1579,12 @@ class SddModelPanel implements OverlayComponent {
1508
1579
  const current = this.draft[name] ?? {};
1509
1580
  if (thinking === undefined) delete current.thinking;
1510
1581
  else current.thinking = thinking;
1511
- if (!current.model && !current.thinking) delete this.draft[name];
1582
+ if (!current.model && !current.thinking) this.draft[name] = {};
1512
1583
  else this.draft[name] = current;
1513
1584
  }
1514
1585
 
1515
1586
  private clearEntry(name: string): void {
1516
- delete this.draft[name];
1587
+ this.draft[name] = {};
1517
1588
  }
1518
1589
 
1519
1590
  private filteredModelOptions(): string[] {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
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",
@@ -197,6 +197,10 @@ async function run() {
197
197
  const promptResult = await promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
198
198
  assert.match(promptResult.systemPrompt, /base/);
199
199
  assert.match(promptResult.systemPrompt, /el Gentleman/);
200
+ assert.match(promptResult.systemPrompt, /do not pass the `model` parameter by default/);
201
+ assert.match(promptResult.systemPrompt, /SDD model assignment tables apply only to SDD\/Judgment-Day phase agents/);
202
+ assert.doesNotMatch(promptResult.systemPrompt, /Every Agent tool call MUST include `model`/);
203
+ assert.doesNotMatch(promptResult.systemPrompt, /default\s*\|\s*sonnet\s*\|\s*Non-SDD general delegation/);
200
204
  assert.match(promptResult.systemPrompt, /openspec\/config\.yaml.*not session preflight/s);
201
205
  assert.match(promptResult.systemPrompt, /Do not mark SDD preflight complete/);
202
206
  await writeFile(
@@ -765,21 +769,102 @@ async function run() {
765
769
  assert.equal(await readFile(globalModelsPath, "utf8"), "{ invalid json");
766
770
  assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
767
771
  assert.match(legacyCtx.ui.notifications.at(-1).message, /cannot open model config/);
772
+ await writeFile(
773
+ join(legacyModelsCwd, ".pi", "settings.json"),
774
+ JSON.stringify(
775
+ {
776
+ subagents: {
777
+ agentOverrides: {
778
+ "sdd-apply": { model: "settings/provider-model", thinking: "high" },
779
+ },
780
+ },
781
+ },
782
+ null,
783
+ 2,
784
+ ),
785
+ );
768
786
  await writeFile(globalModelsPath, JSON.stringify({}, null, 2));
769
787
  await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
770
- const emptyGlobalSuppressesLegacyAgent = await readFile(
788
+ const emptyGlobalPreservesAgent = await readFile(
789
+ join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
790
+ "utf8",
791
+ );
792
+ assert.match(emptyGlobalPreservesAgent, /model: global\/provider-model/);
793
+ const emptyGlobalPreservesSettings = JSON.parse(
794
+ await readFile(join(legacyModelsCwd, ".pi", "settings.json"), "utf8"),
795
+ );
796
+ assert.equal(
797
+ emptyGlobalPreservesSettings.subagents.agentOverrides["sdd-apply"].model,
798
+ "settings/provider-model",
799
+ );
800
+ await writeFile(
801
+ globalModelsPath,
802
+ JSON.stringify({ "sdd-apply": { model: "bad\nmodel: injected" } }, null, 2),
803
+ );
804
+ await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
805
+ const invalidEntryPreservesAgent = await readFile(
771
806
  join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
772
807
  "utf8",
773
808
  );
774
- assert.doesNotMatch(emptyGlobalSuppressesLegacyAgent, /model:/);
809
+ assert.match(invalidEntryPreservesAgent, /model: global\/provider-model/);
810
+ const invalidEntryPreservesSettings = JSON.parse(
811
+ await readFile(join(legacyModelsCwd, ".pi", "settings.json"), "utf8"),
812
+ );
813
+ assert.equal(
814
+ invalidEntryPreservesSettings.subagents.agentOverrides["sdd-apply"].model,
815
+ "settings/provider-model",
816
+ );
817
+ await writeFile(globalModelsPath, JSON.stringify({ "sdd-apply": {} }, null, 2));
818
+ await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
819
+ const explicitInheritClearsAgent = await readFile(
820
+ join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
821
+ "utf8",
822
+ );
823
+ assert.doesNotMatch(explicitInheritClearsAgent, /model:/);
824
+ const explicitInheritClearsSettings = JSON.parse(
825
+ await readFile(join(legacyModelsCwd, ".pi", "settings.json"), "utf8"),
826
+ );
827
+ assert.equal(explicitInheritClearsSettings.subagents, undefined);
775
828
  } finally {
776
829
  await rm(legacyModelsCwd, { recursive: true, force: true });
777
830
  await rm(globalModelsPath, { force: true });
778
831
  }
779
832
 
833
+ const staleSettingsOnlyCwd = await tempWorkspace();
834
+ try {
835
+ await mkdir(join(staleSettingsOnlyCwd, ".pi"), { recursive: true });
836
+ await writeFile(
837
+ join(staleSettingsOnlyCwd, ".pi", "settings.json"),
838
+ JSON.stringify(
839
+ {
840
+ subagents: {
841
+ agentOverrides: {
842
+ worker: { model: "stale/model", thinking: "high" },
843
+ },
844
+ },
845
+ },
846
+ null,
847
+ 2,
848
+ ),
849
+ );
850
+ await writeFile(globalModelsPath, JSON.stringify({ worker: {} }, null, 2));
851
+ await hooks.get("session_start")[0]({ reason: "startup" }, createCtx(staleSettingsOnlyCwd, true));
852
+ const staleOnlyClearedSettings = JSON.parse(
853
+ await readFile(join(staleSettingsOnlyCwd, ".pi", "settings.json"), "utf8"),
854
+ );
855
+ assert.equal(staleOnlyClearedSettings.subagents, undefined);
856
+ } finally {
857
+ await rm(staleSettingsOnlyCwd, { recursive: true, force: true });
858
+ await rm(globalModelsPath, { force: true });
859
+ }
860
+
780
861
  const modelsCwd = await tempWorkspace();
781
862
  try {
782
863
  await mkdir(join(modelsCwd, ".pi", "agents"), { recursive: true });
864
+ await mkdir(
865
+ join(modelsCwd, ".pi", "npm", "node_modules", "pi-subagents-j0k3r", "agents"),
866
+ { recursive: true },
867
+ );
783
868
  await mkdir(
784
869
  join(modelsCwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
785
870
  { recursive: true },
@@ -790,12 +875,28 @@ async function run() {
790
875
  ".pi",
791
876
  "npm",
792
877
  "node_modules",
793
- "pi-subagents",
878
+ "pi-subagents-j0k3r",
794
879
  "agents",
795
880
  "worker.md",
796
881
  ),
797
882
  `---\nname: worker\ndescription: Builtin worker\n---\n`,
798
883
  );
884
+ await writeFile(
885
+ join(modelsCwd, ".pi", "agents", "worker.md"),
886
+ `---\nname: worker\ndescription: Project worker\nmodel: existing/project-worker\nthinking: high\n---\n`,
887
+ );
888
+ await writeFile(
889
+ join(
890
+ modelsCwd,
891
+ ".pi",
892
+ "npm",
893
+ "node_modules",
894
+ "pi-subagents",
895
+ "agents",
896
+ "researcher.md",
897
+ ),
898
+ `---\nname: researcher\ndescription: Legacy builtin researcher\n---\n`,
899
+ );
799
900
  await writeFile(
800
901
  join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
801
902
  `---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
@@ -811,6 +912,52 @@ async function run() {
811
912
  join(modelsCwd, ".pi", "agents", "escape-agent.md"),
812
913
  `---\nname: evil\u001b]52;c;Zm9v\u0007-agent\ndescription: Escape fixture\n---\n`,
813
914
  );
915
+ await writeFile(
916
+ join(modelsCwd, ".pi", "settings.json"),
917
+ JSON.stringify(
918
+ {
919
+ subagents: {
920
+ agentOverrides: {
921
+ worker: { model: "existing/model", thinking: "high" },
922
+ },
923
+ },
924
+ },
925
+ null,
926
+ 2,
927
+ ),
928
+ );
929
+ await writeFile(globalModelsPath, JSON.stringify({}, null, 2));
930
+ await hooks.get("session_start")[0]({ reason: "startup" }, createCtx(modelsCwd, true));
931
+ const preservedSettings = JSON.parse(
932
+ await readFile(join(modelsCwd, ".pi", "settings.json"), "utf8"),
933
+ );
934
+ assert.equal(
935
+ preservedSettings.subagents.agentOverrides.worker.model,
936
+ "existing/model",
937
+ );
938
+ assert.equal(
939
+ preservedSettings.subagents.agentOverrides.worker.thinking,
940
+ "high",
941
+ );
942
+ const preservedProjectWorker = await readFile(
943
+ join(modelsCwd, ".pi", "agents", "worker.md"),
944
+ "utf8",
945
+ );
946
+ assert.match(preservedProjectWorker, /model: existing\/project-worker/);
947
+ assert.match(preservedProjectWorker, /thinking: high/);
948
+ await writeFile(globalModelsPath, JSON.stringify({ worker: {} }, null, 2));
949
+ await hooks.get("session_start")[0]({ reason: "startup" }, createCtx(modelsCwd, true));
950
+ const clearedSettings = JSON.parse(
951
+ await readFile(join(modelsCwd, ".pi", "settings.json"), "utf8"),
952
+ );
953
+ assert.equal(clearedSettings.subagents, undefined);
954
+ const clearedProjectWorker = await readFile(
955
+ join(modelsCwd, ".pi", "agents", "worker.md"),
956
+ "utf8",
957
+ );
958
+ assert.doesNotMatch(clearedProjectWorker, /model:/);
959
+ assert.doesNotMatch(clearedProjectWorker, /thinking:/);
960
+
814
961
  await writeFile(
815
962
  globalModelsPath,
816
963
  JSON.stringify({ "sdd-apply": "openai/gpt-5" }, null, 2),
@@ -891,6 +1038,7 @@ async function run() {
891
1038
  config: {
892
1039
  "sdd-apply": { model: "openai/gpt-5", thinking: "high" },
893
1040
  worker: { model: "openai/gpt-5-mini", thinking: "low" },
1041
+ researcher: { model: "openai/gpt-5-mini", thinking: "low" },
894
1042
  },
895
1043
  });
896
1044
  await commands.get("gentle:models").handler("", ctx);
@@ -928,6 +1076,11 @@ async function run() {
928
1076
  "openai/gpt-5-mini",
929
1077
  );
930
1078
  assert.equal(settings.subagents.agentOverrides.worker.thinking, "low");
1079
+ assert.equal(
1080
+ settings.subagents.agentOverrides.researcher.model,
1081
+ "openai/gpt-5-mini",
1082
+ );
1083
+ assert.equal(settings.subagents.agentOverrides.researcher.thinking, "low");
931
1084
 
932
1085
  const kittyE = "\x1b[101u";
933
1086
  assert.notEqual(kittyE, "e");