pi-pstack-adapter 0.3.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/src/activation.ts CHANGED
@@ -2,23 +2,26 @@ import { homedir } from "node:os";
2
2
  import { posix, win32 } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
- import type { GeneratedSkill } from "./types.ts";
6
-
7
- /** The pi-subagents tool a delegation skill calls. */
8
- export const DELEGATION_TOOL = "subagent";
9
- /** The pi-subagents loader that exposes a registered but inactive `subagent` tool. */
10
- export const DELEGATION_LOADER = "subagents_enable";
5
+ import {
6
+ DEFAULT_DELEGATION,
7
+ type DelegationHost,
8
+ type ToolPresence,
9
+ callableTool,
10
+ delegationHost,
11
+ resolveDelegation,
12
+ } from "./delegation.ts";
13
+ import type { DelegationProvider, GeneratedSkill } from "./types.ts";
11
14
 
12
15
  /** The slice of Pi's tool registry that activation reads and writes. */
13
16
  export interface ToolLoadout {
14
- getAllTools(): readonly { readonly name: string }[];
17
+ getAllTools(): readonly ToolPresence[];
15
18
  getActiveTools(): string[];
16
19
  setActiveTools(toolNames: string[]): void;
17
20
  }
18
21
 
19
22
  export type ActivationResult =
20
- | { readonly kind: "already-active" }
21
- | { readonly kind: "activated" }
23
+ | { readonly kind: "already-active"; readonly backend: DelegationProvider }
24
+ | { readonly kind: "activated"; readonly backend: DelegationProvider }
22
25
  | { readonly kind: "unavailable" };
23
26
 
24
27
  export function needsDelegation(skill: GeneratedSkill): boolean {
@@ -54,37 +57,51 @@ export function skillFromReadPath(
54
57
  }
55
58
 
56
59
  /**
57
- * Adds `subagent` to the active tools, the same selection `subagents_enable` makes, so a
58
- * delegation skill never depends on the model remembering to call the loader first.
59
- * Pi applies the new loadout on the next model request.
60
+ * Adds the delegation backend that capability detection resolves to the active tools, with its
61
+ * companion tools, so a delegation skill never depends on the model loading it first. For
62
+ * pi-subagents that is the selection `subagents_enable` makes; T3's `delegate_task` may be
63
+ * deferred behind `tool_search`. Pi applies the new loadout on the next model request.
60
64
  */
61
- export function activateDelegation(loadout: ToolLoadout): ActivationResult {
62
- if (!loadout.getAllTools().some((tool) => tool.name === DELEGATION_TOOL)) return { kind: "unavailable" };
65
+ export function activateDelegation(
66
+ loadout: ToolLoadout,
67
+ preferred: DelegationProvider = DEFAULT_DELEGATION,
68
+ host: DelegationHost = delegationHost(),
69
+ ): ActivationResult {
70
+ const registered = loadout.getAllTools();
71
+ const resolution = resolveDelegation(registered, preferred, host);
72
+ if (resolution.kind === "unavailable") return { kind: "unavailable" };
73
+ const { backend, companions } = resolution;
74
+ const wanted = [backend.tool, ...companions];
63
75
  const active = loadout.getActiveTools();
64
- if (active.includes(DELEGATION_TOOL)) return { kind: "already-active" };
65
- loadout.setActiveTools([...active, DELEGATION_TOOL]);
66
- return { kind: "activated" };
76
+ const missing = wanted.filter((name) => !active.includes(name));
77
+ if (missing.length === 0) return { kind: "already-active", backend: backend.id };
78
+ loadout.setActiveTools([...active, ...missing]);
79
+ return { kind: "activated", backend: backend.id };
67
80
  }
68
81
 
69
82
  export interface ActivationSources {
70
83
  /** Generated skills, or none while the adapter has no upstream checkout. */
71
84
  readonly skills: () => readonly GeneratedSkill[];
72
85
  readonly modeActive: () => boolean;
86
+ /** The configured backend preference, read at activation time. */
87
+ readonly preferred: () => DelegationProvider;
88
+ /** Defaults to the running process's T3 Code bridge, if any. */
89
+ readonly host?: DelegationHost;
73
90
  }
74
91
 
75
92
  type ActivationHost = Pick<ExtensionAPI, "on" | "getAllTools" | "getActiveTools" | "setActiveTools">;
76
93
 
77
94
  /**
78
- * A delegation skill must not depend on the model remembering to call `subagents_enable`,
79
- * so a slash command, a model-initiated SKILL.md read, and Poteto Mode each activate
80
- * `subagent` in code.
95
+ * A delegation skill must not depend on the model remembering to load its delegation tool,
96
+ * so a slash command, a model-initiated SKILL.md read, and Poteto Mode each activate it in code.
81
97
  */
82
98
  export function registerDelegationActivation(pi: ActivationHost, sources: ActivationSources): void {
99
+ const activate = () => activateDelegation(pi, sources.preferred(), sources.host ?? delegationHost());
83
100
  // `input` runs before Pi copies the tool list for the prompt, so the first model request
84
- // already carries `subagent`. It also covers messages queued while the agent is streaming.
101
+ // already carries the tool. It also covers messages queued while the agent is streaming.
85
102
  pi.on("input", async (event) => {
86
103
  const skill = skillFromPrompt(event.text, sources.skills());
87
- if (sources.modeActive() || (skill && needsDelegation(skill))) activateDelegation(pi);
104
+ if (sources.modeActive() || (skill && needsDelegation(skill))) activate();
88
105
  });
89
106
 
90
107
  pi.on("tool_call", async (event, ctx) => {
@@ -92,12 +109,12 @@ export function registerDelegationActivation(pi: ActivationHost, sources: Activa
92
109
  const path = (event.input as { path?: unknown }).path;
93
110
  if (typeof path !== "string") return;
94
111
  const skill = skillFromReadPath(path, ctx.cwd, sources.skills());
95
- if (skill && needsDelegation(skill)) activateDelegation(pi);
112
+ if (skill && needsDelegation(skill)) activate();
96
113
  });
97
114
 
98
115
  // Backstop for prompts that reach the agent without an `input` event.
99
116
  pi.on("before_agent_start", async () => {
100
- if (sources.modeActive()) activateDelegation(pi);
117
+ if (sources.modeActive()) activate();
101
118
  });
102
119
  }
103
120
 
package/src/agents.ts CHANGED
@@ -1,4 +1,6 @@
1
- import type { Diagnostic, GeneratedAgent } from "./types.ts";
1
+ import { join } from "node:path";
2
+ import { T3_DELEGATE_TOOL } from "./delegation.ts";
3
+ import type { DelegationProvider, Diagnostic, GeneratedAgent } from "./types.ts";
2
4
 
3
5
  export const RUNTIME_AGENT_REGISTER_EVENT = "pi-subagents:runtime-agent-register:v1";
4
6
 
@@ -19,6 +21,9 @@ export interface EventBus {
19
21
 
20
22
  export interface AgentRegistrationResult {
21
23
  readonly registrations: readonly RuntimeRegistration[];
24
+ /** Agents no installed extension accepted as named agents. */
25
+ readonly unregistered: readonly GeneratedAgent[];
26
+ /** Registrations a provider refused. */
22
27
  readonly diagnostics: readonly Diagnostic[];
23
28
  }
24
29
 
@@ -32,6 +37,7 @@ export function registerAgents(
32
37
  namespace: string,
33
38
  ): AgentRegistrationResult {
34
39
  const registrations: RuntimeRegistration[] = [];
40
+ const unregistered: GeneratedAgent[] = [];
35
41
  const diagnostics: Diagnostic[] = [];
36
42
 
37
43
  for (const agent of agents) {
@@ -45,12 +51,7 @@ export function registerAgents(
45
51
  };
46
52
  bus.emit(RUNTIME_AGENT_REGISTER_EVENT, request);
47
53
  if (!request.result) {
48
- diagnostics.push({
49
- level: "warning",
50
- resource: `agent:${agent.upstreamName}`,
51
- message: `No installed delegation provider accepted \`${agent.generatedName}\`, so pstack workflows cannot delegate to it.`,
52
- action: `Install a provider that owns ${RUNTIME_AGENT_REGISTER_EVENT}, for example \`pi install npm:pi-subagents\`.`,
53
- });
54
+ unregistered.push(agent);
54
55
  continue;
55
56
  }
56
57
  if (!request.result.ok) {
@@ -65,5 +66,32 @@ export function registerAgents(
65
66
  registrations.push(request.result.registration);
66
67
  }
67
68
 
68
- return { registrations, diagnostics };
69
+ return { registrations, unregistered, diagnostics };
70
+ }
71
+
72
+ /**
73
+ * T3 Code children have no named agents and load an agent from its prompt file, so a missing
74
+ * pi-subagents registration only blocks delegation when T3 is not the resolved backend.
75
+ */
76
+ export function unregisteredAgentDiagnostics(
77
+ unregistered: readonly GeneratedAgent[],
78
+ backend: DelegationProvider | undefined,
79
+ promptsDir: string,
80
+ ): Diagnostic[] {
81
+ return unregistered.map((agent) => {
82
+ const promptPath = join(promptsDir, `${agent.generatedName}.md`);
83
+ if (backend === "t3") {
84
+ return {
85
+ level: "info",
86
+ resource: `agent:${agent.upstreamName}`,
87
+ message: `\`${agent.generatedName}\` is not a named agent here. T3 Code children run it by reading ${promptPath} as their system prompt.`,
88
+ };
89
+ }
90
+ return {
91
+ level: "warning",
92
+ resource: `agent:${agent.upstreamName}`,
93
+ message: `No installed delegation provider accepted \`${agent.generatedName}\` as a named agent, so pstack workflows cannot delegate to it through \`subagent\`.`,
94
+ action: `Install a provider that owns ${RUNTIME_AGENT_REGISTER_EVENT}, for example \`pi install npm:pi-subagents\`, or run Pi inside T3 Code, whose \`${T3_DELEGATE_TOOL}\` loads the agent from ${promptPath}.`,
95
+ };
96
+ });
69
97
  }
@@ -1,9 +1,18 @@
1
- import type { CapabilityId, CapabilityStatus } from "./types.ts";
1
+ import {
2
+ DEFAULT_DELEGATION,
3
+ type DelegationHost,
4
+ callableTool,
5
+ delegationHost,
6
+ parameterNames,
7
+ resolveDelegation,
8
+ } from "./delegation.ts";
9
+ import type { CapabilityId, CapabilityStatus, DelegationProvider } from "./types.ts";
2
10
 
3
11
  export interface ToolDescriptor {
4
12
  readonly name: string;
13
+ readonly exposure?: string;
5
14
  readonly parameters?: unknown;
6
- readonly sourceInfo?: { readonly source?: string };
15
+ readonly sourceInfo?: { readonly source?: string; readonly path?: string };
7
16
  }
8
17
 
9
18
  interface CapabilityProbe {
@@ -14,13 +23,10 @@ interface CapabilityProbe {
14
23
  readonly recommendation: string;
15
24
  }
16
25
 
26
+ const DELEGATION_RECOMMENDATION =
27
+ "Run Pi inside T3 Code, whose `delegate_task` tool starts child agents, or install a delegation provider with named background agents and model selection, for example `pi install npm:pi-subagents`.";
28
+
17
29
  const PROBES: readonly CapabilityProbe[] = [
18
- {
19
- id: "delegation",
20
- toolName: "subagent",
21
- requiredParameters: ["agent", "task", "async", "model"],
22
- recommendation: "Install a delegation provider that exposes named background agents with model selection, for example `pi install npm:pi-subagents`.",
23
- },
24
30
  {
25
31
  id: "structured-question",
26
32
  toolName: "ask_user",
@@ -29,31 +35,50 @@ const PROBES: readonly CapabilityProbe[] = [
29
35
  },
30
36
  ];
31
37
 
32
- export function detectCapabilities(tools: readonly ToolDescriptor[]): CapabilityStatus[] {
33
- return PROBES.map((probe) => {
34
- const tool = tools.find((candidate) => candidate.name === probe.toolName);
35
- if (!tool) {
36
- return { id: probe.id, available: false, recommendation: probe.recommendation };
37
- }
38
- const properties = parameterNames(tool.parameters);
39
- const missing = probe.requiredParameters.filter((name) => !properties.has(name));
40
- if (missing.length > 0) {
41
- return {
42
- id: probe.id,
43
- available: false,
44
- provider: tool.sourceInfo?.source,
45
- recommendation: `The \`${probe.toolName}\` tool is present but does not accept ${missing.join(", ")}. ${probe.recommendation}`,
46
- };
47
- }
48
- return { id: probe.id, available: true, provider: tool.sourceInfo?.source, recommendation: probe.recommendation };
49
- });
38
+ export function detectCapabilities(
39
+ tools: readonly ToolDescriptor[],
40
+ preferred: DelegationProvider = DEFAULT_DELEGATION,
41
+ host: DelegationHost = delegationHost(),
42
+ ): CapabilityStatus[] {
43
+ return [detectDelegation(tools, preferred, host), ...PROBES.map((probe) => detectTool(tools, probe))];
50
44
  }
51
45
 
52
- function parameterNames(parameters: unknown): Set<string> {
53
- if (typeof parameters !== "object" || parameters === null) return new Set();
54
- const properties = (parameters as { properties?: unknown }).properties;
55
- if (typeof properties !== "object" || properties === null) return new Set();
56
- return new Set(Object.keys(properties as Record<string, unknown>));
46
+ /** The first usable backend in preference order satisfies `delegation`. */
47
+ function detectDelegation(
48
+ tools: readonly ToolDescriptor[],
49
+ preferred: DelegationProvider,
50
+ host: DelegationHost,
51
+ ): CapabilityStatus {
52
+ const resolution = resolveDelegation(tools, preferred, host);
53
+ if (resolution.kind === "unavailable") {
54
+ return { id: "delegation", available: false, recommendation: [...resolution.problems, DELEGATION_RECOMMENDATION].join(" ") };
55
+ }
56
+ return {
57
+ id: "delegation",
58
+ available: true,
59
+ provider: resolution.backend.id === "t3" ? resolution.backend.label : resolution.tool.sourceInfo?.source ?? resolution.backend.label,
60
+ backend: resolution.backend.id,
61
+ tools: [resolution.backend.tool, ...resolution.companions],
62
+ recommendation: DELEGATION_RECOMMENDATION,
63
+ };
64
+ }
65
+
66
+ function detectTool(tools: readonly ToolDescriptor[], probe: CapabilityProbe): CapabilityStatus {
67
+ const tool = callableTool(tools, probe.toolName);
68
+ if (!tool) {
69
+ return { id: probe.id, available: false, recommendation: probe.recommendation };
70
+ }
71
+ const properties = parameterNames(tool.parameters);
72
+ const missing = probe.requiredParameters.filter((name) => !properties.has(name));
73
+ if (missing.length > 0) {
74
+ return {
75
+ id: probe.id,
76
+ available: false,
77
+ provider: tool.sourceInfo?.source,
78
+ recommendation: `The \`${probe.toolName}\` tool is present but does not accept ${missing.join(", ")}. ${probe.recommendation}`,
79
+ };
80
+ }
81
+ return { id: probe.id, available: true, provider: tool.sourceInfo?.source, recommendation: probe.recommendation };
57
82
  }
58
83
 
59
84
  export interface PrerequisiteReport {
package/src/certified.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  export const CERTIFIED = {
6
6
  repo: "https://github.com/cursor/plugins",
7
7
  ref: "main",
8
- commit: "e43c7ee26e0038c6c1fa8380dd34ce86ff94cb2a",
8
+ commit: "dbf49813a39c1a5389c5e28cb8e4c1c2379e2454",
9
9
  pluginPath: "pstack",
10
- pstackVersion: "0.15.9",
11
- skillCount: 50,
10
+ pstackVersion: "0.15.23",
11
+ skillCount: 53,
12
12
  agentCount: 2,
13
13
  } as const;
package/src/config.ts CHANGED
@@ -1,9 +1,11 @@
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
- import type { SourceSelection, Trust } from "./types.ts";
7
+ import { DEFAULT_DELEGATION, DELEGATION_PROVIDERS, isDelegationProvider } from "./delegation.ts";
8
+ import type { DelegationProvider, SourceSelection, Trust } from "./types.ts";
7
9
 
8
10
  export interface AdapterConfig {
9
11
  readonly namespace: string;
@@ -17,6 +19,8 @@ export interface AdapterConfig {
17
19
  readonly allowBootstrap: boolean;
18
20
  readonly cacheDir?: string;
19
21
  readonly models: Readonly<Record<string, string>>;
22
+ /** The delegation backend tried first. The other one is the fallback. */
23
+ readonly delegation: DelegationProvider;
20
24
  readonly sources: readonly string[];
21
25
  }
22
26
 
@@ -30,6 +34,7 @@ export interface ConfigFile {
30
34
  allowBootstrap?: boolean;
31
35
  cacheDir?: string;
32
36
  models?: Record<string, string>;
37
+ delegation?: string;
33
38
  }
34
39
 
35
40
  export interface ConfigLayer {
@@ -49,6 +54,7 @@ const ENV = {
49
54
  allowBootstrap: "PISTACK_ALLOW_BOOTSTRAP",
50
55
  cacheDir: "PISTACK_CACHE_DIR",
51
56
  models: "PISTACK_MODELS",
57
+ delegation: "PISTACK_DELEGATION",
52
58
  } as const;
53
59
 
54
60
  export function readConfigFile(path: string): ConfigLayer | undefined {
@@ -61,8 +67,9 @@ export function readConfigFile(path: string): ConfigLayer | undefined {
61
67
  let parsed: unknown;
62
68
  try {
63
69
  parsed = JSON.parse(raw);
64
- } catch (error) {
65
- 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.`);
66
73
  }
67
74
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
68
75
  throw new Error(`${path} must contain a JSON object.`);
@@ -82,7 +89,139 @@ export function updateConfigFile(path: string, patch: ConfigFile): ConfigFile {
82
89
  return next;
83
90
  }
84
91
 
85
- /** `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` */
86
225
  export function parseModelRoles(value: string): Record<string, string> {
87
226
  const roles: Record<string, string> = {};
88
227
  for (const pair of value.split(",")) {
@@ -90,9 +229,15 @@ export function parseModelRoles(value: string): Record<string, string> {
90
229
  if (trimmed === "") continue;
91
230
  const separator = trimmed.indexOf("=");
92
231
  if (separator <= 0) {
93
- 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.`);
94
233
  }
95
- 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;
96
241
  }
97
242
  return roles;
98
243
  }
@@ -106,7 +251,14 @@ export function environmentLayer(env: NodeJS.ProcessEnv): ConfigLayer {
106
251
  if (env[ENV.pluginPath]) values.pluginPath = env[ENV.pluginPath];
107
252
  if (env[ENV.cacheDir]) values.cacheDir = env[ENV.cacheDir];
108
253
  if (env[ENV.allowBootstrap]) values.allowBootstrap = !NEGATIVE.has((env[ENV.allowBootstrap] as string).trim().toLowerCase());
109
- 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
+ }
261
+ if (env[ENV.delegation]) values.delegation = (env[ENV.delegation] as string).trim().toLowerCase();
110
262
  return { label: "environment", values };
111
263
  }
112
264
 
@@ -128,7 +280,7 @@ export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
128
280
  merged.ref = undefined;
129
281
  }
130
282
  if (key === "models") {
131
- merged.models = { ...merged.models, ...(value as Record<string, string>) };
283
+ merged.models = { ...merged.models, ...validatedModels(value, layer.label) };
132
284
  } else {
133
285
  (merged as Record<string, unknown>)[key] = value;
134
286
  }
@@ -151,10 +303,30 @@ export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
151
303
  allowBootstrap: merged.allowBootstrap ?? false,
152
304
  cacheDir: merged.cacheDir,
153
305
  models: merged.models ?? {},
306
+ delegation: validateDelegation(merged.delegation, layers),
154
307
  sources,
155
308
  };
156
309
  }
157
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
+
158
330
  /**
159
331
  * The certified commit is the default, not a floor. Naming a ref or a repository without a
160
332
  * commit means the user asked for that ref, so the pin must get out of the way and trust
@@ -167,6 +339,15 @@ function resolvePin(merged: ConfigFile): string | undefined {
167
339
  return CERTIFIED.commit;
168
340
  }
169
341
 
342
+ function validateDelegation(value: string | undefined, layers: readonly ConfigLayer[]): DelegationProvider {
343
+ if (value === undefined) return DEFAULT_DELEGATION;
344
+ if (isDelegationProvider(value)) return value;
345
+ const owner = [...layers].reverse().find((layer) => layer.values.delegation === value);
346
+ throw new Error(
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}".`,
348
+ );
349
+ }
350
+
170
351
  function resolveLayerPath(value: string, layers: readonly ConfigLayer[]): string {
171
352
  const expanded = expandHome(value);
172
353
  if (isAbsolute(expanded)) return expanded;