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 +7 -0
- package/README.md +66 -1
- package/VERSION +1 -1
- package/adapter-skills/setup.md +40 -8
- package/index.ts +197 -20
- package/package.json +1 -1
- package/src/config.ts +96 -8
- package/src/filter.ts +150 -0
- package/src/gate.ts +139 -0
- package/src/generate.ts +28 -2
- package/src/pools.ts +104 -0
- package/src/status.ts +132 -69
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.
|
|
1
|
+
0.9.0
|
package/adapter-skills/setup.md
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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,
|
|
142
|
-
upstream commit, and the trust level from the
|
|
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 {
|
|
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(
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|