pi-pstack-adapter 0.8.0 → 0.9.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.9.0](https://github.com/elderfo/pi-pstack-adapter/compare/v0.8.0...v0.9.0) (2026-11-05)
6
+
7
+
8
+ ### Features
9
+
10
+ * add role model pools and session delegation filters ([#30](https://github.com/elderfo/pi-pstack-adapter/issues/30)) ([b2bf25a](https://github.com/elderfo/pi-pstack-adapter/commit/b2bf25a577d9ae90b548894cf7f79bb128c30902))
11
+
5
12
  ## [0.8.0](https://github.com/elderfo/pi-pstack-adapter/compare/v0.7.0...v0.8.0) (2026-11-01)
6
13
 
7
14
 
package/README.md CHANGED
@@ -173,6 +173,71 @@ Upstream pstack defaults each panel to two model families. A panel you leave uns
173
173
  `judgment`, then `general`, so it covers two families only when those roles do. The status
174
174
  report warns when a panel's seats all run on one model.
175
175
 
176
+ ### Give a role a pool of models
177
+
178
+ An ordinary role can list several models instead of one. Each delegation for that role picks one
179
+ model from the list. A pool is not a panel: a panel runs one seat per entry, but a pool starts one
180
+ child per task. A pool is not a retry list either: when a delegation fails, the agent reports the
181
+ failure and does not try the next model. In a config file a pool is a JSON list:
182
+
183
+ ```json
184
+ { "models": { "feature": ["provider/model-a:high", "provider/model-b:high", "provider/model-c"] } }
185
+ ```
186
+
187
+ The `set_model` action takes the same list in `model`, and its `thinking` applies to each entry
188
+ without its own `:level`. Every entry must be a model in your Pi installation, or nothing is
189
+ written. In `PISTACK_MODELS`, separate a pool's entries with `|`:
190
+
191
+ ```bash
192
+ PISTACK_MODELS='feature=provider/model-a:high|provider/model-b' pi
193
+ ```
194
+
195
+ A panel keeps its string of seats. An unset panel's two default seats each pick one model from the
196
+ `judgment` and `general` pools.
197
+
198
+ ### Restrict which models delegation uses
199
+
200
+ A model filter limits delegation to some models without changing the role assignments, for
201
+ example to run an experiment on one model family only. Ask the agent, or call the
202
+ `pstack_adapter` tool's `set_filter` action:
203
+
204
+ - `include: ["gpt-*"]` allows only models whose id starts with `gpt-`.
205
+ - `exclude: ["*claude*"]` excludes model ids containing `claude`, including Bedrock-style ids.
206
+ - `clear: true` removes the filter at that scope.
207
+
208
+ A pattern with a `/` matches `provider/model`, and one without matches the model id after the
209
+ provider. `*` is the only wildcard, and matching ignores case. A model must match an `include`
210
+ pattern when there are any, and must match no `exclude` pattern.
211
+
212
+ A filter applies to the current session by default and is not saved. Pass `scope: "user"` or
213
+ `scope: "project"` to save it as `modelFilter` in that config file. A saved filter takes effect
214
+ at once, and a filter set for the session wins over a saved one until you clear it. The
215
+ `model filter` row of `/pistack-status` shows the filter in force and where it came from.
216
+ A trusted project's filter replaces the user filter as a whole, including an empty filter.
217
+ Empty lists set an unrestricted override; clearing the session filter restores the saved filter.
218
+ Wait for `set_filter` to finish before starting delegations, rather than issuing them together.
219
+
220
+ Under a filter, the status report and `/pistack-check` mark each pool entry as allowed or
221
+ excluded. A role with no allowed entry is blocked: its work is not moved to `general` or to the
222
+ session's model. A role that inherits is blocked when the filter excludes the session's model.
223
+ A panel seat on an excluded model is blocked, and the other seats still run.
224
+
225
+ The adapter also checks every `delegate_task` and `subagent` call in the session, including
226
+ calls that no pstack workflow made, and blocks one whose model the filter excludes or that it
227
+ cannot check. That includes a call with no model while the session's model is excluded, a model
228
+ Pi does not know, a T3 provider other than Pi, a `subagent` child without a model, a `subagent`
229
+ workflow script, and a `subagent` action that restarts, refines, steers, or schedules a child,
230
+ opens a project session, or configures a watchdog. A T3 target with a named environment is also
231
+ blocked because its registry may differ. A blocked call is not retried. These limits apply:
232
+
233
+ - A child does not inherit a session filter, so the agent puts it into each brief as an
234
+ instruction. A saved filter applies in a child where the adapter loads the same config files.
235
+ - Other tools that start agents or processes, such as T3 Code's thread tools or a shell command,
236
+ are not checked. The filter does not block a provider or a model inside Pi.
237
+ - Changing models with `/model` is not affected.
238
+ - The filter is a model-selection guard, not an access-control boundary. The agent can change it
239
+ through `set_filter`, but is instructed to keep it unchanged unless the user requests a change.
240
+
176
241
  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.
177
242
 
178
243
  A role without a level passes none, so the delegation backend applies its own default. A role's
@@ -220,7 +285,7 @@ settings to `.pi/pi-pstack-adapter.json`. Environment variables override both fi
220
285
  | `PISTACK_UPSTREAM_PLUGIN_PATH` | Selects the plugin directory inside the checkout. |
221
286
  | `PISTACK_ALLOW_BOOTSTRAP` | Allows a noninteractive first download. |
222
287
  | `PISTACK_CACHE_DIR` | Changes the cache directory. |
223
- | `PISTACK_MODELS` | Assigns models as comma-separated `role=provider/model[:thinking]` pairs. A panel role takes its entries separated by spaces. |
288
+ | `PISTACK_MODELS` | Assigns models as comma-separated `role=provider/model[:thinking]` pairs. A pool separates its entries with `\|`. A panel role takes its entries separated by spaces. |
224
289
  | `PISTACK_DELEGATION` | Sets the delegation provider to try first: `t3` (default) or `pi-subagents`. The config file key is `delegation`. |
225
290
 
226
291
  ## Security and trust
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.8.0
1
+ 0.9.0
@@ -4,7 +4,8 @@ Upstream pstack ships a `setup-pstack` skill that writes `~/.cursor/rules/pstack
4
4
  a Cursor-only always-applied rule full of Cursor model slugs. Pi has neither that file nor
5
5
  those models, so this adapter replaces the body with the Pi equivalent.
6
6
 
7
- Three things are configurable. Model roles, the upstream source, and the delegation order.
7
+ Four things are configurable. Model roles, the model filter, the upstream source, and the
8
+ delegation order.
8
9
 
9
10
  ## Model roles
10
11
 
@@ -64,7 +65,9 @@ Steps.
64
65
  installation can actually reach. Do not invent ids.
65
66
  2. Ask the user which model each role should use. Offer the listed ids, and offer
66
67
  `inherit` for any role they do not care about. Use the structured question tool when it is
67
- available; otherwise ask in plain text. Only ids from step 1 are accepted.
68
+ available; otherwise ask in plain text. Only ids from step 1 are accepted. A user who wants
69
+ alternatives for a role gets a pool: a list of models, of which each delegation picks one.
70
+ A pool is a set of choices, not a retry list and not a panel, so say that when you offer it.
68
71
  3. Ask for a thinking level. Do not assume the user wants none. Skip this step only when every
69
72
  role is on `inherit`. It applies to every role that got a model in step 2. A role on
70
73
  `inherit` cannot have a level, so tell the user that and say that those roles follow the
@@ -90,9 +93,12 @@ Steps.
90
93
  instead of asking again. The tool rejects any level other than `off` on a model that has no reasoning support.
91
94
  When it does, set that role with no level and tell the user which role has none.
92
95
  4. For each role, call the adapter tool with `action: "set_model"`, the role, the chosen
93
- `provider/model` id, and `thinking` when step 3 picked a level. Pass `scope: "project"` when
94
- the user wants the choice to apply only to this repository. Calling `set_model` for a role
95
- again without `thinking` clears a level set earlier, so always pass it again.
96
+ `provider/model` id, and `thinking` when step 3 picked a level. For a pool, pass `model` as a
97
+ list of ids, for example `model: ["provider/model-a", "provider/model-b"]`. `thinking`
98
+ applies to each entry without its own `:level`, and one unknown entry rejects the whole pool.
99
+ Pass `scope: "project"` when the user wants the choice to apply only to this repository.
100
+ Calling `set_model` for a role again without `thinking` clears a level set earlier, so always
101
+ pass it again.
96
102
  5. Set the panels. Read the `panel roles` table of `action: "status"` and show the user each
97
103
  panel's seats. When a panel's seats share one model, or one family, say so and suggest two
98
104
  families from step 1, as upstream's defaults do. Never say a family is missing unless the
@@ -106,10 +112,35 @@ Steps.
106
112
 
107
113
  For a scripted environment, set `PISTACK_MODELS` instead, for example
108
114
  `PISTACK_MODELS=feature=anthropic/claude-sonnet-4-5:medium,bug-fix=openai/gpt-5:high`. The
109
- optional `:level` suffix sets the thinking level. A panel's entries are separated by spaces, as
115
+ optional `:level` suffix sets the thinking level. A pool's entries are separated by `|`, as in
116
+ `feature=anthropic/claude-sonnet-4-5:high|openai/gpt-5:high`. A panel's entries are separated by spaces, as
110
117
  in `PISTACK_MODELS='arena-runners=anthropic/claude-sonnet-4-5:high openai/gpt-5:high'`. The environment wins
111
118
  over both config files, so CI never needs this dialog.
112
119
 
120
+ ## Model filter
121
+
122
+ A model filter restricts delegation to some models, such as one family for an experiment,
123
+ without rewriting the roles. Call the adapter tool with `action: "set_filter"` and:
124
+
125
+ - `include`, a list of patterns a model must match, such as `["gpt-*"]` for GPT only.
126
+ - `exclude`, a list of patterns no model may match, such as `["*claude*"]` for no Claude, including Bedrock-style ids.
127
+ - `clear: true` to remove the filter at that scope.
128
+
129
+ A pattern with `/` matches `provider/model`; one without matches the model id. `*` is the only
130
+ wildcard. The filter applies to this session only, unless the user asks to save it: then pass
131
+ `scope: "user"` or `scope: "project"`. A session filter wins over a saved one until it is cleared.
132
+ A trusted project's filter replaces the user filter as a whole. Empty lists set an unrestricted
133
+ override; clearing the session filter restores the saved filter. Wait for `set_filter` to finish
134
+ before issuing delegation calls. Keep the active filter unchanged unless the user requests a change.
135
+
136
+ Read back `action: "status"`. Report the `model filter` row, and any role or panel seat the
137
+ `model roles` or `panel roles` table marks BLOCKED, because a blocked role does not delegate.
138
+ Tell the user the limits: the adapter blocks `delegate_task` and `subagent` calls in this session
139
+ that the filter does not allow. A child does not inherit a session filter and follows it only as
140
+ an instruction in its brief. A saved filter applies in a child where the adapter loads the same
141
+ config files. Other tools that start agents are not checked. This is a model-selection guard,
142
+ not an access-control boundary.
143
+
113
144
  ## Upstream source
114
145
 
115
146
  By default the adapter uses the pstack revision this release was tested against. Change it only
@@ -138,5 +169,6 @@ the next Pi start.
138
169
 
139
170
  ## Finish
140
171
 
141
- End by reporting, in your own words, which roles were set, which still inherit, the resolved
142
- upstream commit, and the trust level from the status output.
172
+ End by reporting, in your own words, which roles were set, which still inherit, which have
173
+ pools, the model filter in force, the resolved upstream commit, and the trust level from the
174
+ status output.
package/index.ts CHANGED
@@ -7,7 +7,17 @@ import { Type } from "typebox";
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import { registerDelegationActivation } from "./src/activation.ts";
9
9
  import { registerAgents, unregisteredAgentDiagnostics, type RuntimeRegistration } from "./src/agents.ts";
10
- import { DEFAULT_DELEGATION } from "./src/delegation.ts";
10
+ import { DEFAULT_DELEGATION, SUBAGENT_TOOL, T3_DELEGATE_TOOL } from "./src/delegation.ts";
11
+ import {
12
+ FILTER_ENTRY_TYPE,
13
+ type ModelFilter,
14
+ activeFilter,
15
+ describeOrigin,
16
+ filterOf,
17
+ parseModelFilter,
18
+ sessionFilterFromEntries,
19
+ } from "./src/filter.ts";
20
+ import { gateDelegation } from "./src/gate.ts";
11
21
  import { checkPrerequisites, detectCapabilities, type ToolDescriptor } from "./src/capabilities.ts";
12
22
  import {
13
23
  type AdapterConfig,
@@ -22,6 +32,7 @@ import {
22
32
  mergeConfig,
23
33
  planModelAssignment,
24
34
  planPanelAssignment,
35
+ planPoolAssignment,
25
36
  readConfigFile,
26
37
  trustWarning,
27
38
  updateConfigFile,
@@ -32,7 +43,15 @@ import { MODE_ENTRY_TYPE, modeFromEntries, modeInstruction } from "./src/mode.ts
32
43
  import { CONFIG_FILE_NAME, agentDir, cacheRoot } from "./src/paths.ts";
33
44
  import { bashToolContext, probeExecutables } from "./src/executables.ts";
34
45
  import { SKILL_PANELS, entryFor, isPanelRole } from "./src/registry.ts";
35
- import { platformSupport, renderCheck, renderPanels, renderStatus, shellDiagnostics, type ShellState } from "./src/status.ts";
46
+ import {
47
+ platformSupport,
48
+ renderCheck,
49
+ renderModelPolicy,
50
+ renderPanels,
51
+ renderStatus,
52
+ shellDiagnostics,
53
+ type ShellState,
54
+ } from "./src/status.ts";
36
55
  import { BootstrapRequiredError, resolveRefToCommit, resolveSource } from "./src/upstream.ts";
37
56
  import { normalizeRepo } from "./src/config.ts";
38
57
  import type { CapabilityStatus, Diagnostic, GeneratedAgent, GenerationResult, ResolvedSource } from "./src/types.ts";
@@ -56,6 +75,11 @@ export default function (pi: ExtensionAPI) {
56
75
  let agentDiagnostics: readonly Diagnostic[] = [];
57
76
  let unregisteredAgents: readonly GeneratedAgent[] = [];
58
77
  let modeActive = false;
78
+ /** This session's own model filter, restored from the active branch. It wins over a saved one. */
79
+ let sessionFilter: ModelFilter | undefined;
80
+ let savedFilter: AdapterConfig["modelFilter"];
81
+ /** Why the config files could not be read, so the saved filter is unknown. */
82
+ let configError: string | undefined;
59
83
  const commandNamespace = startupNamespace();
60
84
 
61
85
  pi.on("session_start", async (_event, ctx) => {
@@ -63,9 +87,27 @@ export default function (pi: ExtensionAPI) {
63
87
  state = undefined;
64
88
  startupError = undefined;
65
89
  modeActive = modeFromEntries(ctx.sessionManager.getBranch() as never[]);
90
+ sessionFilter = sessionFilterFromEntries(ctx.sessionManager.getBranch() as never[]);
91
+ savedFilter = undefined;
92
+ configError = undefined;
93
+
94
+ let config: AdapterConfig;
95
+ try {
96
+ config = loadConfig(ctx);
97
+ } catch (error) {
98
+ startupError = error as Error;
99
+ try {
100
+ savedFilter = loadConfig(ctx, true).modelFilter;
101
+ } catch (filterError) {
102
+ configError = (filterError as Error).message;
103
+ }
104
+ if (ctx.hasUI) ctx.ui.notify(`pstack adapter: ${(error as Error).message}`, "error");
105
+ return;
106
+ }
107
+ savedFilter = config.modelFilter;
66
108
 
67
109
  try {
68
- state = await bringUpUpstream(loadConfig(ctx), ctx);
110
+ state = await bringUpUpstream(config, ctx);
69
111
  } catch (error) {
70
112
  startupError = error as Error;
71
113
  if (ctx.hasUI) ctx.ui.notify(`pstack adapter: ${(error as Error).message}`, "error");
@@ -102,6 +144,29 @@ export default function (pi: ExtensionAPI) {
102
144
  }
103
145
  });
104
146
 
147
+ // `/tree` moves the active branch, and with it the session filter that branch recorded.
148
+ pi.on("session_tree", async (_event, ctx) => {
149
+ sessionFilter = sessionFilterFromEntries(ctx.sessionManager.getBranch() as never[]);
150
+ });
151
+
152
+ // The model filter applies to every delegation in this session, pstack or not, because a
153
+ // delegation tool call names no pstack role. A blocked call fails; nothing retries it.
154
+ pi.on("tool_call", async (event, ctx) => {
155
+ if (event.toolName !== T3_DELEGATE_TOOL && event.toolName !== SUBAGENT_TOOL) return;
156
+ if (sessionFilter === undefined && configError !== undefined) {
157
+ return {
158
+ block: true,
159
+ reason: `Blocked by the pstack adapter: its config could not be read, so it cannot tell whether a saved model filter applies. ${configError} Fix the config and reload, or ask the user whether to set an explicit session filter with \`set_filter\`.`,
160
+ };
161
+ }
162
+ const decision = gateDelegation(event.toolName, event.input, {
163
+ filter: filterOf(activeFilter(sessionFilter, savedFilter)),
164
+ parentModel: parentModelOf(ctx),
165
+ resolveModel: (model) => resolveRegistryModel(ctx, model),
166
+ });
167
+ if (decision.kind === "block") return { block: true, reason: decision.reason };
168
+ });
169
+
105
170
  pi.on("resources_discover", async () => {
106
171
  if (!state) return {};
107
172
  return { skillPaths: [state.generation.skillsDir] };
@@ -127,7 +192,7 @@ export default function (pi: ExtensionAPI) {
127
192
  name: "pstack_adapter",
128
193
  label: "pstack adapter",
129
194
  description:
130
- "Inspect and configure the pstack adapter. Use `status` for the full report, `check` before starting a pstack workflow, `models` to list real Pi model ids, `set_model` to assign a pstack role, and `set_source` to select another upstream revision.",
195
+ "Inspect and configure the pstack adapter. Use `status` for the full report, `check` before starting a pstack workflow, `models` to list real Pi model ids, `set_model` to assign a pstack role or a pool of models, `set_filter` to restrict which models delegation may use, and `set_source` to select another upstream revision.",
131
196
  parameters: Type.Object({
132
197
  action: Type.Union(
133
198
  [
@@ -135,6 +200,7 @@ export default function (pi: ExtensionAPI) {
135
200
  Type.Literal("check"),
136
201
  Type.Literal("models"),
137
202
  Type.Literal("set_model"),
203
+ Type.Literal("set_filter"),
138
204
  Type.Literal("set_source"),
139
205
  ],
140
206
  { description: "Operation to run" },
@@ -142,9 +208,9 @@ export default function (pi: ExtensionAPI) {
142
208
  skill: Type.Optional(Type.String({ description: "Skill name for `check`, with or without the namespace prefix" })),
143
209
  role: Type.Optional(Type.String({ description: "pstack model role for `set_model`" })),
144
210
  model: Type.Optional(
145
- Type.String({
211
+ Type.Union([Type.String(), Type.Array(Type.String())], {
146
212
  description:
147
- "`provider/model` id, or `inherit` to clear the role. For a panel role such as `arena-runners`, a list of entries separated by commas, one seat each.",
213
+ "`provider/model` id, or `inherit` to clear the role. A list of ids gives an ordinary role a pool: each delegation picks one. For a panel role such as `arena-runners`, a string of entries separated by commas, one seat each.",
148
214
  }),
149
215
  ),
150
216
  thinking: Type.Optional(
@@ -155,9 +221,16 @@ export default function (pi: ExtensionAPI) {
155
221
  repo: Type.Optional(Type.String({ description: "Upstream repository URL for `set_source`" })),
156
222
  ref: Type.Optional(Type.String({ description: "Branch, tag, or commit for `set_source`" })),
157
223
  localPath: Type.Optional(Type.String({ description: "Local pstack checkout for `set_source`" })),
224
+ include: Type.Optional(
225
+ Type.Array(Type.String(), {
226
+ description: "`set_filter`: patterns a delegation model must match, such as `gpt-*`. `*` is the only wildcard; a pattern with `/` matches `provider/model`.",
227
+ }),
228
+ ),
229
+ exclude: Type.Optional(Type.Array(Type.String(), { description: "`set_filter`: patterns no delegation model may match, such as `claude-*`. Exclude wins." })),
230
+ clear: Type.Optional(Type.Boolean({ description: "`set_filter`: remove the filter at `scope` instead of setting one." })),
158
231
  scope: Type.Optional(
159
- Type.Union([Type.Literal("user"), Type.Literal("project")], {
160
- description: "Config file to write. Defaults to user.",
232
+ Type.Union([Type.Literal("session"), Type.Literal("user"), Type.Literal("project")], {
233
+ description: "`set_filter` defaults to session, this session only. `set_model` and `set_source` write a config file: user (default) or project.",
161
234
  }),
162
235
  ),
163
236
  }),
@@ -211,15 +284,20 @@ export default function (pi: ExtensionAPI) {
211
284
  action: string;
212
285
  skill?: string;
213
286
  role?: string;
214
- model?: string;
287
+ model?: string | string[];
215
288
  thinking?: string;
216
289
  repo?: string;
217
290
  ref?: string;
218
291
  localPath?: string;
219
- scope?: "user" | "project";
292
+ include?: string[];
293
+ exclude?: string[];
294
+ clear?: boolean;
295
+ scope?: "session" | "user" | "project";
220
296
  },
221
297
  ctx: ExtensionContext,
222
298
  ): Promise<string> {
299
+ // A restriction must be settable even when the upstream checkout did not resolve.
300
+ if (params.action === "set_filter") return setFilter(params, params.scope ?? "session", ctx);
223
301
  if (startupError) return `The pstack adapter did not start: ${startupError.message}`;
224
302
  if (!state) return "The pstack adapter has not resolved its upstream checkout yet.";
225
303
 
@@ -239,6 +317,8 @@ export default function (pi: ExtensionAPI) {
239
317
  modeActive,
240
318
  cacheRoot: state.cacheRoot,
241
319
  bootstrapped: state.bootstrapped,
320
+ filter: activeFilter(sessionFilter, savedFilter),
321
+ parentModel: parentModelOf(ctx),
242
322
  runtimeDiagnostics: [
243
323
  ...agentDiagnostics,
244
324
  // Judged now, because T3's tools can connect after the agents were registered.
@@ -251,10 +331,14 @@ export default function (pi: ExtensionAPI) {
251
331
  return checkSkill(params.skill ?? "", ctx);
252
332
  case "models":
253
333
  return renderModels(ctx);
254
- case "set_model":
334
+ case "set_model": {
335
+ if (params.scope === "session") return SESSION_SCOPE_ONLY_FILTER;
255
336
  return setModel(params.role, params.model, params.thinking, params.scope ?? "user", ctx);
256
- case "set_source":
337
+ }
338
+ case "set_source": {
339
+ if (params.scope === "session") return SESSION_SCOPE_ONLY_FILTER;
257
340
  return setSource(params, params.scope ?? "user", ctx);
341
+ }
258
342
  default:
259
343
  return `Unknown action ${params.action}.`;
260
344
  }
@@ -282,12 +366,15 @@ export default function (pi: ExtensionAPI) {
282
366
  const shellProblems = shellDiagnostics(shell).map((diagnostic) => `${diagnostic.message} ${diagnostic.action ?? ""}`.trim());
283
367
  const current = capabilities();
284
368
  const panels = Object.hasOwn(SKILL_PANELS, skill.upstreamName) ? SKILL_PANELS[skill.upstreamName] : undefined;
369
+ const origin = activeFilter(sessionFilter, savedFilter);
370
+ const parentModel = parentModelOf(ctx);
285
371
  return renderCheck(
286
372
  skill,
287
373
  entryFor(skill.upstreamName, skill.generatedName.slice(prefix.length)),
288
374
  checkPrerequisites(skill.capabilities, current, skill.executables, executables.has, shellProblems),
289
375
  current.find((capability) => capability.id === "delegation"),
290
- panels ? renderPanels(state.config, panels) : undefined,
376
+ panels ? renderPanels(state.config, panels, { filter: filterOf(origin), parentModel }) : undefined,
377
+ renderModelPolicy(state.config, origin, parentModel),
291
378
  );
292
379
  }
293
380
 
@@ -301,17 +388,23 @@ export default function (pi: ExtensionAPI) {
301
388
 
302
389
  function setModel(
303
390
  role: string | undefined,
304
- model: string | undefined,
391
+ model: string | string[] | undefined,
305
392
  thinking: string | undefined,
306
393
  scope: "user" | "project",
307
394
  ctx: ExtensionContext,
308
395
  ): string {
309
396
  if (!role) return "set_model needs a `role`.";
310
- if (!model) return "set_model needs a `model`, or `inherit` to clear the role.";
397
+ if (!model || model.length === 0) return "set_model needs a `model`, or `inherit` to clear the role.";
311
398
  if (!isValidRole(role)) return invalidRoleMessage(role);
312
399
  const available = ctx.modelRegistry
313
400
  .getAvailable()
314
401
  .map((candidate) => ({ id: `${candidate.provider}/${candidate.id}`, reasoning: candidate.reasoning }));
402
+ if (Array.isArray(model)) {
403
+ if (isPanelRole(role)) {
404
+ return `Panel \`${role}\` takes one string of entries separated by commas, one seat each, not a list. A list is a pool, which only ordinary roles have.`;
405
+ }
406
+ return setPool(role, model, thinking, available, scope, ctx);
407
+ }
315
408
  if (isPanelRole(role)) return setPanel(role, model, thinking, available, scope, ctx);
316
409
  const plan = planModelAssignment(model, thinking, available);
317
410
  if (plan.kind === "rejected") return plan.message;
@@ -339,13 +432,80 @@ export default function (pi: ExtensionAPI) {
339
432
  ? " This project is not trusted, so Pi ignores the file until you trust it, and the status report still shows the earlier value."
340
433
  : "";
341
434
  try {
342
- if (state) state = { ...state, config: { ...state.config, models: loadConfig(ctx).models } };
435
+ const fresh = loadConfig(ctx);
436
+ savedFilter = fresh.modelFilter;
437
+ configError = undefined;
438
+ if (state) state = { ...state, config: { ...state.config, models: fresh.models, modelFilter: fresh.modelFilter } };
343
439
  } catch (error) {
344
- return `${untrusted} The write succeeded, but the session view was not refreshed: ${(error as Error).message}`;
440
+ try {
441
+ savedFilter = loadConfig(ctx, true).modelFilter;
442
+ configError = undefined;
443
+ } catch (filterError) {
444
+ configError = (filterError as Error).message;
445
+ }
446
+ return `${untrusted} The write succeeded, but the model role view was not refreshed: ${(error as Error).message}`;
345
447
  }
346
448
  return untrusted;
347
449
  }
348
450
 
451
+ function setPool(
452
+ role: string,
453
+ entries: readonly string[],
454
+ thinking: string | undefined,
455
+ available: readonly { id: string; reasoning?: boolean }[],
456
+ scope: "user" | "project",
457
+ ctx: ExtensionContext,
458
+ ): string {
459
+ const plan = planPoolAssignment(entries, thinking, available);
460
+ if (plan.kind === "rejected") return plan.message;
461
+ const path = configPath(scope, ctx);
462
+ const stored = readConfigFile(path)?.values.models;
463
+ const models = typeof stored === "object" && stored !== null && !Array.isArray(stored) ? { ...stored } : {};
464
+ models[role] = plan.value;
465
+ updateConfigFile(path, { models });
466
+ const note = refreshModels(scope, ctx);
467
+ const listed = (typeof plan.value === "string" ? [plan.value] : plan.value).map((entry) => `\`${entry}\``).join(", ");
468
+ return typeof plan.value === "string"
469
+ ? `Role \`${role}\` now uses ${listed}. A pool of one is written as a plain model. Written to ${path}.${note}`
470
+ : `Role \`${role}\` now has a pool of ${plan.value.length} models: ${listed}. Each delegation picks one model the filter allows. Written to ${path}.${note}`;
471
+ }
472
+
473
+ function setFilter(
474
+ params: { include?: string[]; exclude?: string[]; clear?: boolean },
475
+ scope: "session" | "user" | "project",
476
+ ctx: ExtensionContext,
477
+ ): string {
478
+ const setting = params.include !== undefined || params.exclude !== undefined;
479
+ if (params.clear && setting) return "set_filter takes either `clear: true` or `include` and `exclude`, not both.";
480
+ if (!params.clear && !setting) {
481
+ return "set_filter needs `include`, `exclude`, or `clear: true`. Empty lists set an unrestricted filter at that scope.";
482
+ }
483
+ let filter: ModelFilter | undefined;
484
+ if (setting) {
485
+ const raw: Record<string, unknown> = {};
486
+ if (params.include !== undefined) raw.include = params.include;
487
+ if (params.exclude !== undefined) raw.exclude = params.exclude;
488
+ const parsed = parseModelFilter(raw);
489
+ if ("problem" in parsed) return parsed.problem;
490
+ filter = parsed.filter;
491
+ }
492
+ if (scope === "session") {
493
+ sessionFilter = filter;
494
+ pi.appendEntry(FILTER_ENTRY_TYPE, filter ? { filter } : { cleared: true });
495
+ const origin = activeFilter(sessionFilter, savedFilter);
496
+ return `${filter ? "This session's model filter is set" : "This session's model filter is cleared"}. Filter in force: ${describeOrigin(origin)}. It applies to every \`delegate_task\` and \`subagent\` call in this session, and is not saved to a config file.`;
497
+ }
498
+ const path = configPath(scope, ctx);
499
+ updateConfigFile(path, { modelFilter: filter });
500
+ const note = refreshModels(scope, ctx);
501
+ const origin = activeFilter(sessionFilter, savedFilter);
502
+ const shadowed =
503
+ sessionFilter !== undefined
504
+ ? " This session's own filter still wins over the saved one. Clear it with `set_filter`, `clear: true`, and `scope: \"session\"` to use the saved filter here."
505
+ : "";
506
+ return `${filter ? "Saved model filter" : "Removed the saved model filter"} in ${path}.${note}${shadowed} Filter in force: ${describeOrigin(origin)}.`;
507
+ }
508
+
349
509
  function setPanel(
350
510
  role: string,
351
511
  model: string,
@@ -442,7 +602,24 @@ function startupNamespace(): string {
442
602
  }
443
603
  }
444
604
 
445
- function loadConfig(ctx: ExtensionContext): AdapterConfig {
605
+ const SESSION_SCOPE_ONLY_FILTER =
606
+ "Only `set_filter` takes `scope: \"session\"`. `set_model` and `set_source` write a config file, so pass `scope: \"user\"` or `scope: \"project\"`.";
607
+
608
+ function parentModelOf(ctx: ExtensionContext): string | undefined {
609
+ return ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
610
+ }
611
+
612
+ /** A requested model as its registry `provider/id`. A bare id counts only when exactly one model has it. */
613
+ function resolveRegistryModel(ctx: ExtensionContext, model: string): string | undefined {
614
+ const ids = ctx.modelRegistry.getAvailable().map((candidate) => ({ full: `${candidate.provider}/${candidate.id}`, id: candidate.id }));
615
+ const exact = ids.find((candidate) => candidate.full === model);
616
+ if (exact) return exact.full;
617
+ if (model.includes("/")) return undefined;
618
+ const bare = ids.filter((candidate) => candidate.id === model);
619
+ return bare.length === 1 ? bare[0]?.full : undefined;
620
+ }
621
+
622
+ function loadConfig(ctx: ExtensionContext, filterOnly = false): AdapterConfig {
446
623
  const layers: ConfigLayer[] = [];
447
624
  const global = readConfigFile(userConfigPath());
448
625
  if (global) layers.push(global);
@@ -452,8 +629,8 @@ function loadConfig(ctx: ExtensionContext): AdapterConfig {
452
629
  // without desynchronizing `/<ns>-status` from the generated `<ns>-*` skills.
453
630
  if (project) layers.push({ ...project, values: { ...project.values, namespace: undefined } });
454
631
  }
455
- layers.push(environmentLayer(process.env));
456
- return mergeConfig(layers);
632
+ layers.push(environmentLayer(filterOnly ? { ...process.env, PISTACK_MODELS: undefined } : process.env));
633
+ return mergeConfig(filterOnly ? layers.map((layer) => ({ ...layer, values: { modelFilter: layer.values.modelFilter } })) : layers);
457
634
  }
458
635
 
459
636
  function configPath(scope: "user" | "project", ctx: ExtensionContext): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pstack-adapter",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",