pi-pstack-adapter 0.4.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project are documented here. release-please writes new entries from Conventional Commit PR titles.
4
4
 
5
+ ## [0.5.0](https://github.com/elderfo/pi-pstack-adapter/compare/v0.4.0...v0.5.0) (2026-10-15)
6
+
7
+
8
+ ### Features
9
+
10
+ * **models:** add a per-role thinking level ([#12](https://github.com/elderfo/pi-pstack-adapter/issues/12)) ([ae99d0c](https://github.com/elderfo/pi-pstack-adapter/commit/ae99d0cb9ab6c516a6847eb47ea44a0b5ec7c141))
11
+
5
12
  ## [0.4.0](https://github.com/elderfo/pi-pstack-adapter/compare/v0.3.0...v0.4.0) (2026-10-14)
6
13
 
7
14
 
package/README.md CHANGED
@@ -150,6 +150,20 @@ In a noninteractive session, set roles with `PISTACK_MODELS`:
150
150
  PISTACK_MODELS='feature=provider/model,bug-fix=provider/other-model' pi
151
151
  ```
152
152
 
153
+ Add a thinking level to a role with a `:level` suffix. The levels are `off`, `minimal`, `low`,
154
+ `medium`, `high`, `xhigh`, and `max`:
155
+
156
+ ```bash
157
+ PISTACK_MODELS='feature=provider/model:medium,judgment=provider/other-model:high' pi
158
+ ```
159
+
160
+ Role names are lowercase letters, digits, and hyphens. A model value is printable ASCII with no spaces or markup characters, at most 128 characters. A config entry that breaks these rules stops the adapter at startup with an error that names the file, so fix the key there and restart Pi.
161
+
162
+ A role without a level passes none, so the delegation backend applies its own default. A role you leave unset inherits the
163
+ session model and the session thinking level, so a session at high thinking makes every
164
+ inheriting delegate expensive. The `pstack_adapter` tool's `set_model` action takes the same
165
+ level in its `thinking` parameter. A level the model does not offer is clamped by Pi at run time.
166
+
153
167
  ## Run Pi noninteractively
154
168
 
155
169
  A noninteractive session, such as `pi --print`, cannot answer the download question. To allow
@@ -188,7 +202,7 @@ settings to `.pi/pi-pstack-adapter.json`. Environment variables override both fi
188
202
  | `PISTACK_UPSTREAM_PLUGIN_PATH` | Selects the plugin directory inside the checkout. |
189
203
  | `PISTACK_ALLOW_BOOTSTRAP` | Allows a noninteractive first download. |
190
204
  | `PISTACK_CACHE_DIR` | Changes the cache directory. |
191
- | `PISTACK_MODELS` | Assigns models as comma-separated `role=provider/model` pairs. |
205
+ | `PISTACK_MODELS` | Assigns models as comma-separated `role=provider/model[:thinking]` pairs. |
192
206
  | `PISTACK_DELEGATION` | Sets the delegation provider to try first: `t3` (default) or `pi-subagents`. The config file key is `delegation`. |
193
207
 
194
208
  ## Security and trust
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.0
1
+ 0.5.0
@@ -27,6 +27,13 @@ Roles used by pstack workflows.
27
27
  The status report lists the same roles, so read it back rather than trusting this table if the
28
28
  adapter has been upgraded.
29
29
 
30
+ Each role also has a thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or
31
+ `max`. Upstream pstack calls this a reasoning budget and encodes it in the model slug. A role with
32
+ no thinking level passes none, so the delegation backend applies its own default. A role left on
33
+ `inherit` uses the parent session's model and thinking level, so a parent at high thinking makes
34
+ every inheriting child expensive. Model choice often affects cost more than thinking level does,
35
+ so consider a cheaper model for the bulk roles as well.
36
+
30
37
  Steps.
31
38
 
32
39
  1. Call the adapter tool with `action: "models"` to list the provider and model ids this Pi
@@ -34,13 +41,40 @@ Steps.
34
41
  2. Ask the user which model each role should use. Offer the listed ids, and offer
35
42
  `inherit` for any role they do not care about. Use the structured question tool when it is
36
43
  available; otherwise ask in plain text. Only ids from step 1 are accepted.
37
- 3. For each answered role, call the adapter tool with `action: "set_model"`, the role, and the
38
- chosen `provider/model` id. Pass `scope: "project"` when the user wants the choice to apply
39
- only to this repository.
40
- 4. Read back `action: "status"` and show the user what was written.
44
+ 3. Ask for a thinking level. Do not assume the user wants none. Skip this step only when every
45
+ role is on `inherit`. It applies to every role that got a model in step 2. A role on
46
+ `inherit` cannot have a level, so tell the user that and say that those roles follow the
47
+ parent session's level. Ask one single-select question first. Put the whole choice in each option
48
+ title so the user can pick without opening anything else, for example "Balanced: code roles
49
+ medium, judgment high, strongest-judgment xhigh (suggested)".
50
+
51
+ | choice | code roles (`feature`, `refactoring`, `bug-fix`, `perf-issue`, `hillclimb`) | `judgment` | `strongest-judgment` |
52
+ | --- | --- | --- | --- |
53
+ | Balanced (suggest this first) | `medium` | `high` | `xhigh` |
54
+ | Low cost | `low` | `medium` | `high` |
55
+ | Maximum quality | `high` | `xhigh` | `max` |
56
+ | Backend default | none | none | none |
57
+ | Custom | ask once per column, as a single-select of level names | | |
58
+
59
+ These are starting points, not measurements. They keep the heaviest reasoning on the roles
60
+ that make the hardest calls. Offer Custom for a user who wants different levels. Ask once for
61
+ the code roles, once for `judgment`, and once for `strongest-judgment`. Ask a role on its own
62
+ only when the user says it should differ from its group. Backend default passes no level and
63
+ leaves the choice to the delegation backend. A Custom user who wants thinking disabled picks
64
+ `off`.
65
+ A model may not offer every level. A level it lacks is clamped when it runs, so say that
66
+ instead of asking again. The tool rejects any level other than `off` on a model that has no reasoning support.
67
+ When it does, set that role with no level and tell the user which role has none.
68
+ 4. For each role, call the adapter tool with `action: "set_model"`, the role, the chosen
69
+ `provider/model` id, and `thinking` when step 3 picked a level. Pass `scope: "project"` when
70
+ the user wants the choice to apply only to this repository. Calling `set_model` for a role
71
+ again without `thinking` clears a level set earlier, so always pass it again.
72
+ 5. Read back `action: "status"` and show the user what was written, including the thinking
73
+ column.
41
74
 
42
75
  For a scripted environment, set `PISTACK_MODELS` instead, for example
43
- `PISTACK_MODELS=feature=anthropic/claude-sonnet-4-5,bug-fix=openai/gpt-5`. The environment wins
76
+ `PISTACK_MODELS=feature=anthropic/claude-sonnet-4-5:medium,bug-fix=openai/gpt-5:high`. The
77
+ optional `:level` suffix sets the thinking level. The environment wins
44
78
  over both config files, so CI never needs this dialog.
45
79
 
46
80
  ## Upstream source
package/index.ts CHANGED
@@ -13,13 +13,20 @@ import {
13
13
  type AdapterConfig,
14
14
  type ConfigFile,
15
15
  type ConfigLayer,
16
+ THINKING_LEVELS,
16
17
  environmentLayer,
18
+ invalidRoleMessage,
19
+ isValidModelSpec,
20
+ isValidRole,
21
+ joinModelSpec,
17
22
  mergeConfig,
23
+ planModelAssignment,
18
24
  readConfigFile,
19
25
  trustWarning,
20
26
  updateConfigFile,
21
27
  } from "./src/config.ts";
22
28
  import { generate } from "./src/generate.ts";
29
+ import { safeEcho } from "./src/echo.ts";
23
30
  import { MODE_ENTRY_TYPE, modeFromEntries, modeInstruction } from "./src/mode.ts";
24
31
  import { CONFIG_FILE_NAME, agentDir, cacheRoot } from "./src/paths.ts";
25
32
  import { bashToolContext, probeExecutables } from "./src/executables.ts";
@@ -56,9 +63,8 @@ export default function (pi: ExtensionAPI) {
56
63
  startupError = undefined;
57
64
  modeActive = modeFromEntries(ctx.sessionManager.getBranch() as never[]);
58
65
 
59
- const config = loadConfig(ctx);
60
66
  try {
61
- state = await bringUpUpstream(config, ctx);
67
+ state = await bringUpUpstream(loadConfig(ctx), ctx);
62
68
  } catch (error) {
63
69
  startupError = error as Error;
64
70
  if (ctx.hasUI) ctx.ui.notify(`pstack adapter: ${(error as Error).message}`, "error");
@@ -135,6 +141,11 @@ export default function (pi: ExtensionAPI) {
135
141
  skill: Type.Optional(Type.String({ description: "Skill name for `check`, with or without the namespace prefix" })),
136
142
  role: Type.Optional(Type.String({ description: "pstack model role for `set_model`" })),
137
143
  model: Type.Optional(Type.String({ description: "`provider/model` id, or `inherit` to clear the role" })),
144
+ thinking: Type.Optional(
145
+ Type.String({
146
+ description: `Thinking level for \`set_model\`: ${THINKING_LEVELS.join(", ")}. Omit to pass no level, which also clears a level set earlier.`,
147
+ }),
148
+ ),
138
149
  repo: Type.Optional(Type.String({ description: "Upstream repository URL for `set_source`" })),
139
150
  ref: Type.Optional(Type.String({ description: "Branch, tag, or commit for `set_source`" })),
140
151
  localPath: Type.Optional(Type.String({ description: "Local pstack checkout for `set_source`" })),
@@ -195,6 +206,7 @@ export default function (pi: ExtensionAPI) {
195
206
  skill?: string;
196
207
  role?: string;
197
208
  model?: string;
209
+ thinking?: string;
198
210
  repo?: string;
199
211
  ref?: string;
200
212
  localPath?: string;
@@ -234,7 +246,7 @@ export default function (pi: ExtensionAPI) {
234
246
  case "models":
235
247
  return renderModels(ctx);
236
248
  case "set_model":
237
- return setModel(params.role, params.model, params.scope ?? "user", ctx);
249
+ return setModel(params.role, params.model, params.thinking, params.scope ?? "user", ctx);
238
250
  case "set_source":
239
251
  return setSource(params, params.scope ?? "user", ctx);
240
252
  default:
@@ -254,7 +266,7 @@ export default function (pi: ExtensionAPI) {
254
266
  (candidate) => candidate.generatedName === requested || candidate.generatedName === `${prefix}${bare}`,
255
267
  );
256
268
  if (!skill) {
257
- return `No pstack skill named ${JSON.stringify(requested)}. Run the adapter tool with action "status" for the full list.`;
269
+ return `No pstack skill named ${safeEcho(requested)}. Run the adapter tool with action "status" for the full list.`;
258
270
  }
259
271
  const resolved = shellState(ctx, pi.getActiveTools());
260
272
  // An inactive bash tool or an unresolved shell is already a blocker, so do not spawn anything.
@@ -274,27 +286,36 @@ export default function (pi: ExtensionAPI) {
274
286
  function renderModels(ctx: ExtensionContext): string {
275
287
  const scoped = ctx.scopedModels ?? [];
276
288
  const models = scoped.length > 0 ? scoped.map((entry) => entry.model) : ctx.modelRegistry.getAvailable();
277
- const ids = models.map((model) => `${model.provider}/${model.id}`).sort();
289
+ const ids = models.map((model) => `${model.provider}/${model.id}`).filter(isValidModelSpec).sort();
278
290
  if (ids.length === 0) return "No models are available in this Pi installation.";
279
291
  return `Available model ids.\n\n${ids.map((id) => `- \`${id}\``).join("\n")}`;
280
292
  }
281
293
 
282
- function setModel(role: string | undefined, model: string | undefined, scope: "user" | "project", ctx: ExtensionContext): string {
294
+ function setModel(
295
+ role: string | undefined,
296
+ model: string | undefined,
297
+ thinking: string | undefined,
298
+ scope: "user" | "project",
299
+ ctx: ExtensionContext,
300
+ ): string {
283
301
  if (!role) return "set_model needs a `role`.";
284
302
  if (!model) return "set_model needs a `model`, or `inherit` to clear the role.";
285
- const clearing = model.trim().toLowerCase() === "inherit";
286
- if (!clearing && !ctx.modelRegistry.getAvailable().some((candidate) => `${candidate.provider}/${candidate.id}` === model)) {
287
- return `No model \`${model}\` is available in this Pi installation. Call this tool with action "models" for real ids, or pass "inherit".`;
288
- }
303
+ if (!isValidRole(role)) return invalidRoleMessage(role);
304
+ const available = ctx.modelRegistry
305
+ .getAvailable()
306
+ .map((candidate) => ({ id: `${candidate.provider}/${candidate.id}`, reasoning: candidate.reasoning }));
307
+ const plan = planModelAssignment(model, thinking, available);
308
+ if (plan.kind === "rejected") return plan.message;
289
309
  const path = configPath(scope, ctx);
290
- const current = readConfigFile(path)?.values.models ?? {};
291
- const models = { ...current };
292
- if (clearing) delete models[role];
293
- else models[role] = model;
310
+ const stored = readConfigFile(path)?.values.models;
311
+ const models = typeof stored === "object" && stored !== null && !Array.isArray(stored) ? { ...stored } : {};
312
+ if (plan.kind === "inherit") delete models[role];
313
+ else models[role] = joinModelSpec(plan.spec);
294
314
  updateConfigFile(path, { models });
295
- return clearing
296
- ? `Role \`${role}\` now inherits the parent session model. Written to ${path}.`
297
- : `Role \`${role}\` now uses \`${model}\`. Written to ${path}.`;
315
+ if (plan.kind === "inherit") return `Role \`${role}\` now inherits the parent session model and thinking. Written to ${path}.`;
316
+ return plan.spec.thinking === undefined
317
+ ? `Role \`${role}\` now uses \`${plan.spec.model}\` at the model's default thinking. Written to ${path}.`
318
+ : `Role \`${role}\` now uses \`${plan.spec.model}\` with thinking \`${plan.spec.thinking}\`. Written to ${path}.`;
298
319
  }
299
320
 
300
321
  async function setSource(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pstack-adapter",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Run Cursor's official pstack plugin inside Pi from an unmodified upstream checkout",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, isAbsolute, resolve } from "node:path";
3
3
  import { CERTIFIED } from "./certified.ts";
4
+ import { safeEcho } from "./echo.ts";
4
5
  import { DEFAULT_NAMESPACE, validateNamespace } from "./namespace.ts";
5
6
  import { expandHome } from "./paths.ts";
6
7
  import { DEFAULT_DELEGATION, DELEGATION_PROVIDERS, isDelegationProvider } from "./delegation.ts";
@@ -66,8 +67,9 @@ export function readConfigFile(path: string): ConfigLayer | undefined {
66
67
  let parsed: unknown;
67
68
  try {
68
69
  parsed = JSON.parse(raw);
69
- } catch (error) {
70
- throw new Error(`${path} is not valid JSON: ${(error as Error).message}`);
70
+ } catch {
71
+ // The parser's message quotes raw file text, which can reach the agent. Say only where to look.
72
+ throw new Error(`${path} is not valid JSON. Fix the syntax in that file and restart Pi.`);
71
73
  }
72
74
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
73
75
  throw new Error(`${path} must contain a JSON object.`);
@@ -87,7 +89,139 @@ export function updateConfigFile(path: string, patch: ConfigFile): ConfigFile {
87
89
  return next;
88
90
  }
89
91
 
90
- /** `role=provider/model,role2=provider/model2` */
92
+ /** Thinking levels Pi accepts as a `:level` suffix on a model id. */
93
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
94
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
95
+
96
+ export function isThinkingLevel(value: string): value is ThinkingLevel {
97
+ return (THINKING_LEVELS as readonly string[]).includes(value);
98
+ }
99
+
100
+ export interface ModelSpec {
101
+ readonly model: string;
102
+ readonly thinking?: ThinkingLevel;
103
+ }
104
+
105
+ /**
106
+ * Splits `provider/model[:thinking]`. Only a known level is a suffix, so a model id that
107
+ * itself contains a colon, such as `ollama/qwen:7b`, stays whole. Pi parses ids the same way.
108
+ */
109
+ export function splitModelSpec(spec: string): ModelSpec {
110
+ const separator = spec.lastIndexOf(":");
111
+ if (separator > 0) {
112
+ const suffix = spec.slice(separator + 1).toLowerCase();
113
+ if (isThinkingLevel(suffix)) return { model: spec.slice(0, separator), thinking: suffix };
114
+ }
115
+ return { model: spec };
116
+ }
117
+
118
+ export function joinModelSpec(spec: ModelSpec): string {
119
+ return spec.thinking === undefined ? spec.model : `${spec.model}:${spec.thinking}`;
120
+ }
121
+
122
+ export interface AvailableModel {
123
+ readonly id: string;
124
+ readonly reasoning?: boolean;
125
+ }
126
+
127
+ export type ModelAssignment =
128
+ | { readonly kind: "inherit" }
129
+ | { readonly kind: "assign"; readonly spec: ModelSpec }
130
+ | { readonly kind: "rejected"; readonly message: string };
131
+
132
+ /** Validates a `set_model` request. `available` holds `provider/id` entries from the registry. */
133
+ export function planModelAssignment(
134
+ model: string,
135
+ thinking: string | undefined,
136
+ available: readonly AvailableModel[],
137
+ ): ModelAssignment {
138
+ if (model.trim().toLowerCase() === "inherit") {
139
+ if (thinking?.trim()) {
140
+ return {
141
+ kind: "rejected",
142
+ message:
143
+ "A role that inherits the parent session has no thinking level of its own. Pass a `provider/model` with `thinking`, or drop `thinking`.",
144
+ };
145
+ }
146
+ return { kind: "inherit" };
147
+ }
148
+ const whole = model.trim();
149
+ // A registry id that itself ends in a level word, such as `x/model:low`, wins over a split.
150
+ const exact = available.some((candidate) => candidate.id === whole);
151
+ const spec = exact ? { model: whole } : splitModelSpec(whole);
152
+ const requested = thinking?.trim().toLowerCase() || undefined;
153
+ if (requested !== undefined && !isThinkingLevel(requested)) {
154
+ return {
155
+ kind: "rejected",
156
+ message: `Unknown thinking level \`${safeEcho(thinking).replaceAll('"', "")}\`. Use one of ${THINKING_LEVELS.map((level) => `\`${level}\``).join(", ")}.`,
157
+ };
158
+ }
159
+ if (requested !== undefined && spec.thinking !== undefined && spec.thinking !== requested) {
160
+ return {
161
+ kind: "rejected",
162
+ message: `\`model\` names thinking \`${spec.thinking}\` but \`thinking\` says \`${requested}\`. Pass one of them.`,
163
+ };
164
+ }
165
+ const level = requested !== undefined && isThinkingLevel(requested) ? requested : spec.thinking;
166
+ if (!isValidModelSpec(spec.model)) return { kind: "rejected", message: invalidModelMessage(spec.model) };
167
+ if (exact && requested === undefined && splitModelSpec(whole).thinking !== undefined) {
168
+ // Stored whole, `x/model:low` would read back as model `x/model` at thinking `low`.
169
+ return {
170
+ kind: "rejected",
171
+ message: `Model \`${whole}\` ends in a thinking-level word, so a plain assignment would be misread. Pass \`thinking\` explicitly, for example \`high\`.`,
172
+ };
173
+ }
174
+ const known = available.find((candidate) => candidate.id === spec.model);
175
+ if (!known) {
176
+ return {
177
+ kind: "rejected",
178
+ message: `No model \`${spec.model}\` is available in this Pi installation. Call this tool with action "models" for real ids, or pass "inherit".`,
179
+ };
180
+ }
181
+ if (level !== undefined && level !== "off" && known.reasoning === false) {
182
+ return {
183
+ kind: "rejected",
184
+ message: `Model \`${spec.model}\` does not support thinking. Drop \`thinking\`, or pick a reasoning model.`,
185
+ };
186
+ }
187
+ const assigned: ModelSpec = { model: spec.model, thinking: level };
188
+ if (!isValidModelSpec(joinModelSpec(assigned))) return { kind: "rejected", message: invalidModelMessage(joinModelSpec(assigned)) };
189
+ return { kind: "assign", spec: assigned };
190
+ }
191
+
192
+ /** Role names reach generated markdown and status tables, so they stay plain kebab-case. */
193
+ const ROLE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
194
+ /**
195
+ * A model spec is also rendered into markdown the agent reads, so it is printable ASCII with the
196
+ * markup characters removed. An allow-list of ASCII keeps invisible text (tag characters, zero-width
197
+ * and bidi controls) out; catalogue ids such as OpenRouter's `~anthropic/...` aliases are ASCII.
198
+ * A trailing colon is never a valid id or level.
199
+ */
200
+ const PRINTABLE_ASCII = /^[\x21-\x7e]+$/;
201
+ const MARKUP_CHARACTERS = /[`|<>[\]()\\*]/;
202
+
203
+ const MAX_MODEL_SPEC_LENGTH = 128;
204
+
205
+ export function isValidModelSpec(model: string): boolean {
206
+ const plain = (value: string) => PRINTABLE_ASCII.test(value) && !MARKUP_CHARACTERS.test(value) && !value.endsWith(":");
207
+ if (model.length > MAX_MODEL_SPEC_LENGTH || !plain(model)) return false;
208
+ // `x/model::high` splits to `x/model:`, which is no model id.
209
+ return plain(splitModelSpec(model).model);
210
+ }
211
+
212
+ export function isValidRole(role: string): boolean {
213
+ return ROLE_PATTERN.test(role);
214
+ }
215
+
216
+ export function invalidRoleMessage(role: string): string {
217
+ return `Invalid role name ${safeEcho(role)}. Use lowercase letters, digits, and hyphens, starting with a letter, for example "bug-fix".`;
218
+ }
219
+
220
+ function invalidModelMessage(model: unknown): string {
221
+ return `Invalid model value ${typeof model === "string" ? `(${model.length} characters)` : safeEcho(model)}. Use provider/model or provider/model:thinking with no spaces or markup characters, at most 128 characters.`;
222
+ }
223
+
224
+ /** `role=provider/model[:thinking],role2=provider/model2` */
91
225
  export function parseModelRoles(value: string): Record<string, string> {
92
226
  const roles: Record<string, string> = {};
93
227
  for (const pair of value.split(",")) {
@@ -95,9 +229,15 @@ export function parseModelRoles(value: string): Record<string, string> {
95
229
  if (trimmed === "") continue;
96
230
  const separator = trimmed.indexOf("=");
97
231
  if (separator <= 0) {
98
- throw new Error(`Invalid model role assignment ${JSON.stringify(trimmed)}. Use role=provider/model.`);
232
+ throw new Error(`Invalid model role assignment ${safeEcho(trimmed)}. Use role=provider/model or role=provider/model:thinking.`);
99
233
  }
100
- roles[trimmed.slice(0, separator).trim()] = trimmed.slice(separator + 1).trim();
234
+ const role = trimmed.slice(0, separator).trim();
235
+ const model = trimmed.slice(separator + 1).trim();
236
+ if (!isValidRole(role)) throw new Error(invalidRoleMessage(role));
237
+ // An empty value is an unset variable that expanded to nothing, not a model.
238
+ if (model === "") continue;
239
+ if (!isValidModelSpec(model)) throw new Error(invalidModelMessage(model));
240
+ roles[role] = model;
101
241
  }
102
242
  return roles;
103
243
  }
@@ -111,7 +251,13 @@ export function environmentLayer(env: NodeJS.ProcessEnv): ConfigLayer {
111
251
  if (env[ENV.pluginPath]) values.pluginPath = env[ENV.pluginPath];
112
252
  if (env[ENV.cacheDir]) values.cacheDir = env[ENV.cacheDir];
113
253
  if (env[ENV.allowBootstrap]) values.allowBootstrap = !NEGATIVE.has((env[ENV.allowBootstrap] as string).trim().toLowerCase());
114
- if (env[ENV.models]) values.models = parseModelRoles(env[ENV.models] as string);
254
+ if (env[ENV.models]) {
255
+ try {
256
+ values.models = parseModelRoles(env[ENV.models] as string);
257
+ } catch (error) {
258
+ throw new Error(`${ENV.models}: ${(error as Error).message}`);
259
+ }
260
+ }
115
261
  if (env[ENV.delegation]) values.delegation = (env[ENV.delegation] as string).trim().toLowerCase();
116
262
  return { label: "environment", values };
117
263
  }
@@ -134,7 +280,7 @@ export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
134
280
  merged.ref = undefined;
135
281
  }
136
282
  if (key === "models") {
137
- merged.models = { ...merged.models, ...(value as Record<string, string>) };
283
+ merged.models = { ...merged.models, ...validatedModels(value, layer.label) };
138
284
  } else {
139
285
  (merged as Record<string, unknown>)[key] = value;
140
286
  }
@@ -162,6 +308,25 @@ export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
162
308
  };
163
309
  }
164
310
 
311
+ /** A bad `models` entry fails with the layer that holds it, so status can still be reached after a fix. */
312
+ function validatedModels(value: unknown, label: string): Record<string, string> {
313
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
314
+ throw new Error(`${label} sets \`models\` to something other than an object of role to provider/model.`);
315
+ }
316
+ const models: Record<string, string> = {};
317
+ for (const [role, model] of Object.entries(value)) {
318
+ if (!isValidRole(role)) {
319
+ throw new Error(`${label}: ${invalidRoleMessage(role)} Edit that key in \`models\` in the named file, then restart Pi.`);
320
+ }
321
+ if (model === "") continue;
322
+ if (typeof model !== "string" || !isValidModelSpec(model)) {
323
+ throw new Error(`${label}: role ${safeEcho(role)}. ${invalidModelMessage(model)}`);
324
+ }
325
+ Object.defineProperty(models, role, { value: model, enumerable: true, writable: true, configurable: true });
326
+ }
327
+ return models;
328
+ }
329
+
165
330
  /**
166
331
  * The certified commit is the default, not a floor. Naming a ref or a repository without a
167
332
  * commit means the user asked for that ref, so the pin must get out of the way and trust
@@ -179,7 +344,7 @@ function validateDelegation(value: string | undefined, layers: readonly ConfigLa
179
344
  if (isDelegationProvider(value)) return value;
180
345
  const owner = [...layers].reverse().find((layer) => layer.values.delegation === value);
181
346
  throw new Error(
182
- `${owner?.label ?? "config"} sets \`delegation\` to ${JSON.stringify(value)}. Use one of ${DELEGATION_PROVIDERS.map((id) => `"${id}"`).join(", ")}, or remove it to prefer "${DEFAULT_DELEGATION}".`,
347
+ `${owner?.label ?? "config"} sets \`delegation\` to ${safeEcho(value)}. Use one of ${DELEGATION_PROVIDERS.map((id) => `"${id}"`).join(", ")}, or remove it to prefer "${DEFAULT_DELEGATION}".`,
183
348
  );
184
349
  }
185
350
 
package/src/echo.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Rejected input is attacker-shaped text that can reach the agent through an error message, so echo
3
+ * only a short, plain, quoted form of it. Non-strings are reported by type alone.
4
+ */
5
+ export function safeEcho(value: unknown): string {
6
+ if (typeof value !== "string") return `(a value of type ${value === null ? "null" : typeof value})`;
7
+ const cleaned = value.slice(0, 24).replace(/[^A-Za-z0-9._/:~@+-]/g, "?");
8
+ return `"${cleaned}${value.length > 24 ? "..." : ""}"`;
9
+ }
package/src/generate.ts CHANGED
@@ -483,9 +483,18 @@ function t3Section(namespace: string): string {
483
483
  path of \`agents/${namespace}-poteto-agent.md\` beside this file. The \`agent prompts\` row of
484
484
  the status report gives that directory.
485
485
  - Model: a role configured as \`provider/model\` becomes
486
- \`target: { providerInstanceId: "pi", model: "provider/model" }\`. An unconfigured role omits
487
- \`target\`, so the child inherits the parent. For a different model family, call
488
- \`${T3_CAPABILITIES_TOOL}\` and use a provider and model it lists.
486
+ \`target: { providerInstanceId: "pi", model: "provider/model" }\`. Read the thinking column of
487
+ the \`model roles\` table in the \`pstack_adapter\` \`status\` report. When it names a level, add it to
488
+ \`target.options\` under the option id that \`${T3_CAPABILITIES_TOOL}\` lists for that model's
489
+ thinking setting. For Pi models the id is \`thinking\`, as in \`options: { thinking: "<level>" }\`.
490
+ A role whose thinking reads "backend default" omits \`options\`, so the backend applies its own
491
+ default. An unconfigured role omits \`target\`, so the child inherits the parent model and
492
+ thinking.
493
+ - If the model does not list the level, use the highest level it lists that is below the requested
494
+ one, and tell the user. If it lists none below the requested level, or lists no thinking option,
495
+ omit \`options\` and tell the user. If the level is \`off\` and the model does not list \`off\`, omit
496
+ \`options\` and tell the user, because the closest listed level would turn thinking on.
497
+ For a different model family, call \`${T3_CAPABILITIES_TOOL}\` and use a provider and model it lists.
489
498
  - Background: \`mode: "async"\`. A finished child wakes this thread, so end the turn instead of
490
499
  polling. Call \`${T3_STATUS_TOOL}\` only when you need a result mid-turn.
491
500
  - Call \`${T3_CAPABILITIES_TOOL}\` or \`${T3_STATUS_TOOL}\` only when the \`check\` report allows it.
@@ -508,7 +517,10 @@ function subagentSection(namespace: string): string {
508
517
 
509
518
  - Agent names are namespaced, for example \`${namespace}-poteto-agent\`. Call
510
519
  \`${SUBAGENT_TOOL}({ action: "list" })\` to see them.
511
- - Model: pass a role's \`provider/model\` as \`model\`. An unconfigured role omits \`model\`.
520
+ - Model: pass a role's \`provider/model\` as \`model\`. When the thinking column of the \`model roles\`
521
+ table in the \`pstack_adapter\` \`status\` report names a level for the role, append it as
522
+ \`provider/model:<level>\`. A role whose thinking reads "backend default" gets no suffix, so
523
+ pi-subagents applies its own default. An unconfigured role omits \`model\`.
512
524
  - Background: \`async: true\`.`;
513
525
  }
514
526
 
@@ -524,7 +536,7 @@ that names a Cursor tool, path, or model.
524
536
  | \`run_in_background: true\` | \`mode: "async"\` on \`delegate_task\`, or \`async: true\` on \`subagent\`. |
525
537
  | \`AskUserQuestion\`, \`AskQuestion\` | the \`ask_user\` tool. |
526
538
  | \`TodoWrite\`, \`TaskCreate\`, \`TaskUpdate\` | no Pi equivalent. Keep an uncommitted \`todo.md\` checklist instead. |
527
- | \`claude-opus-5-5-xhigh\`, \`grok-4.7-xhigh-fast\`, other Cursor model slugs | the role models configured by \`/skill:${namespace}-setup\`. Call the \`pstack_adapter\` tool with \`action: "status"\` to read the current role table. An unconfigured role inherits the parent model, so omit \`target\` or \`model\`. |
539
+ | \`claude-opus-5-5-xhigh\`, \`grok-4.7-xhigh-fast\`, other Cursor model slugs | the role models configured by \`/skill:${namespace}-setup\`. Call the \`pstack_adapter\` tool with \`action: "status"\` to read the current role table. An effort word in a slug, such as \`-xhigh\` or \`-max\`, is not a model id. The role table's thinking column carries that level, and \`-fast\` has no equivalent. An unconfigured role inherits the parent model and thinking, so omit \`target\` or \`model\`. |
528
540
  | a role line valued \`auto\` or \`inherit-parent\` | an unconfigured role. Omit \`target\` or \`model\` so the child runs on the parent model. |
529
541
  | \`~/.cursor/rules/pstack-models.mdc\` | adapter configuration written by \`/skill:${namespace}-setup\`. |
530
542
  | role lines \`feature, refactoring\`, \`bug-fix\`, \`perf-issue\`, \`hillclimb\`, \`judgment and prose\`, \`hardest tasks\` | adapter roles \`feature\` or \`refactoring\`, \`bug-fix\`, \`perf-issue\`, \`hillclimb\`, \`judgment\`, and \`strongest-judgment\`. |
package/src/namespace.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { safeEcho } from "./echo.ts";
2
+
1
3
  export const DEFAULT_NAMESPACE = "pistack";
2
4
 
3
5
  const NAMESPACE_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
@@ -5,7 +7,7 @@ const NAMESPACE_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
5
7
  export function validateNamespace(value: string): string {
6
8
  if (!NAMESPACE_PATTERN.test(value)) {
7
9
  throw new Error(
8
- `Invalid pstack adapter namespace ${JSON.stringify(value)}. Use lowercase letters, digits, and single hyphens.`,
10
+ `Invalid pstack adapter namespace ${safeEcho(value)}. Use lowercase letters, digits, and single hyphens.`,
9
11
  );
10
12
  }
11
13
  return value;
package/src/status.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CERTIFIED } from "./certified.ts";
2
2
  import { CAPABILITY_LABELS, MODEL_ROLES, TIER_ORDER } from "./registry.ts";
3
3
  import type { AdapterConfig } from "./config.ts";
4
- import { trustWarning } from "./config.ts";
4
+ import { splitModelSpec, trustWarning } from "./config.ts";
5
5
  import { DELEGATION_BACKENDS, delegationOrder } from "./delegation.ts";
6
6
  import type { PrerequisiteReport } from "./capabilities.ts";
7
7
  import type {
@@ -88,14 +88,17 @@ export function renderStatus(input: StatusInput): string {
88
88
 
89
89
  lines.push(`## Model roles`);
90
90
  lines.push("");
91
- lines.push(`| role | model |`);
92
- lines.push(`| --- | --- |`);
93
- for (const role of MODEL_ROLES) {
94
- lines.push(`| ${role} | ${config.models[role] ?? "inherit (parent session model)"} |`);
95
- }
91
+ lines.push(`| role | model | thinking |`);
92
+ lines.push(`| --- | --- | --- |`);
96
93
  const extra = Object.keys(config.models).filter((role) => !MODEL_ROLES.includes(role)).sort();
97
- for (const role of extra) {
98
- lines.push(`| ${role} | ${config.models[role]} |`);
94
+ for (const role of [...MODEL_ROLES, ...extra]) {
95
+ const configured = config.models[role];
96
+ if (configured === undefined) {
97
+ lines.push(`| ${role} | inherit (parent session model) | inherit (parent session thinking) |`);
98
+ continue;
99
+ }
100
+ const spec = splitModelSpec(configured);
101
+ lines.push(`| ${role} | ${spec.model} | ${spec.thinking ?? "backend default"} |`);
99
102
  }
100
103
  lines.push("");
101
104