pi-delegation-policy 0.4.1 → 0.6.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,28 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.6.0 - 2026-08-29
6
+
7
+ ### Added
8
+
9
+ - Allow Small, Medium, and Large to be explicitly disabled independently while requiring one enabled ordinary role for an active policy.
10
+ - Add schema 3 defaults and session state, with in-memory schema 2 migration and guarded session writes for safer downgrade behavior. Downgrading after saving schema 3 defaults or manually editing them requires `/delegate off` in each active branch and manual conversion of ordinary `null` values.
11
+
12
+ ### Changed
13
+
14
+ - Select only enabled ordinary roles that can satisfy the task, leaving work with the main agent when none can; Small/Medium preferences are inactive when either role is disabled.
15
+
16
+ ## 0.5.0 - 2026-08-28
17
+
18
+ ### Added
19
+
20
+ - Expanded the optional visual specialist to create assets and complete bounded presentation-layer changes when behavior and data contracts are already defined.
21
+
22
+ ### Changed
23
+
24
+ - Renamed the user-facing UI Design role to Visual Design while preserving the `uiDesign` configuration key and `ui-design` status token.
25
+ - Clarified routing boundaries for application behavior, accessibility, checks, integration, and final acceptance.
26
+
5
27
  ## 0.4.1 - 2026-08-28
6
28
 
7
29
  ### Fixed
package/README.md CHANGED
@@ -1,23 +1,27 @@
1
1
  # pi-delegation-policy
2
2
 
3
- A local Pi extension that helps the main agent decide **when delegation is worth it** and which exact models to use for the Small, Medium, Large, and optional UI Design roles. It provides guidance; it is not a subagent runner.
3
+ A local Pi extension that helps the main agent decide **when delegation is worth it** and which exact models to use for Small, Medium, Large, and optional Visual Design. It provides guidance; it is not a subagent runner.
4
4
 
5
- > **Status:** Version **0.4.1** is the latest published package. The package requires Pi `>=0.84.3`; Pi `0.84.3` is the explicitly checked baseline (newer versions are not claimed as tested).
5
+ > **Status:** Version **0.6.0** is the latest published package. The package requires Pi `>=0.84.3`; Pi `0.84.3` is the explicitly checked baseline.
6
6
  >
7
7
  > **Docs:** [Read the documentation site](https://yivas.github.io/pi-delegation-policy/).
8
8
 
9
9
  ## Value and boundary
10
10
 
11
- - Choose `off`, `normal`, or `aggressive` delegation intensity globally or for the current session branch.
12
- - Configure exact provider/model references for Small, Medium, Large, and optionally UI Design.
11
+ - Choose `off`, `normal`, or `aggressive` globally or for the current session branch.
12
+ - Configure an exact provider/model reference or explicitly disable each ordinary role.
13
13
  - Keep global defaults and session-branch overrides across reload, resume, and tree navigation.
14
14
  - Validate active configurations before injecting one policy block through Pi's public `before_agent_start` event.
15
15
 
16
16
  The extension guides the main agent. It never creates, launches, routes, supervises, or blocks subagents; changes Pi's main model or thinking; stores credentials; intercepts tools; or makes its own network requests. It has no model fallback, telemetry, project configuration, presets, or external skill loading.
17
17
 
18
- ## Install and start
18
+ A valid active policy requires an explicit decision for Small, Medium, and Large: an exact model reference or disabled. At least one ordinary role must remain enabled. A disabled role is not validated. An absent role, an invalid enabled reference, or no enabled ordinary role produces `D:ERR` and injects no policy. `off` always produces `D:OFF` without injection.
19
+
20
+ The policy considers only enabled ordinary roles, chooses the least costly role that can satisfy the task's acceptance criteria and evidence, and keeps work with the main agent when none can. It never invents a model or role. `efficient` and `intensive` are tie-breaks only when both Small and Medium are enabled; otherwise their bias is inactive.
19
21
 
20
- Install the published package in Pi's user settings, then reload Pi:
22
+ Visual Design is an independent optional specialist for direction, assets, bounded presentation-layer implementation, and visual review. Use it only when behavior and data contracts are already defined and unchanged, the affected surface is bounded, and visual quality or user experience is the primary acceptance criterion. It does not count as an ordinary role or replace one. Route business logic, data, APIs, routes, application architecture, tooling, interaction behavior, and cross-system integration to an enabled ordinary role by task fit. The main agent retains final integration and acceptance.
23
+
24
+ ## Install and start
21
25
 
22
26
  ```bash
23
27
  pi install npm:pi-delegation-policy
@@ -25,12 +29,14 @@ pi install npm:pi-delegation-policy
25
29
  ```
26
30
 
27
31
  1. Open `/delegate` (or press `Alt+G` in Pi's TUI).
28
- 2. Configure exact, authenticated Small, Medium, and Large provider/model references. UI Design is optional.
32
+ 2. For Small, Medium, and Large, select an exact authenticated provider/model or **Disable for this session**. Keep at least one enabled.
29
33
  3. Select `normal` or `aggressive`, then choose **Apply changes**.
30
- 4. Run `/delegate status` and inspect the exact role references and their sources. `D:ERR` means an active required role is invalid; no policy is injected.
31
- 5. The applied state affects the **next** agent run. An agent already running is not rewritten.
34
+ 4. Run `/delegate status`. `disabled`, `not configured`, and exact references remain distinct. `D:ERR` means no policy is injected.
35
+ 5. The applied state affects the **next** agent run.
32
36
 
33
- See the [end-to-end getting-started guide](https://yivas.github.io/pi-delegation-policy/getting-started/) and [configuration reference](https://yivas.github.io/pi-delegation-policy/configuration/) for details.
37
+ Global defaults are stored at `~/.pi/agent/delegation-policy.json` and use schema version 3. Schema 2 defaults and session entries are read and normalized in memory; they are not rewritten on read. Schema 3 stores `null` for an explicitly disabled ordinary role. Session changes write a schema 2 `off` guard before the schema 3 state so older versions restore off. Saving global defaults is global-only: before downgrading after that action or manually writing schema 3, run `/delegate off` in every active branch, restore schema 2 references manually, then install the older package.
38
+
39
+ See the [getting-started guide](https://yivas.github.io/pi-delegation-policy/getting-started/) and [configuration reference](https://yivas.github.io/pi-delegation-policy/configuration/).
34
40
 
35
41
  ## Essential commands
36
42
 
@@ -43,13 +49,7 @@ See the [end-to-end getting-started guide](https://yivas.github.io/pi-delegation
43
49
  /delegate reset Reset this branch to off and other fields to global defaults
44
50
  ```
45
51
 
46
- The editor is a bounded, keyboard-first panel. It shows model ID first and `[provider]` last, fuzzy-searches provider, model ID, and display name, and shows at most 10 model rows. It also shows a compact effective-policy preview, field explanations, and public model metadata when Pi supplies it. Changes are drafts until **Apply changes**; saving effective configuration as defaults updates the global file without applying the draft, and closing a modified draft requires explicit discard.
47
-
48
- ## Configuration and safety
49
-
50
- Global defaults are stored at `~/.pi/agent/delegation-policy.json` (schema version 2). The file stores optional intensity, preference, exact role references, and an optional UI Design reference; it never stores thinking. A valid active mode requires exact, available, in-scope, authenticated Small, Medium, and Large references. Before every delegated launch, the policy requires the selected role's exact `provider/model` base and a thinking level chosen for that task instead of relying on ambient defaults. With `pi-subagents`, the main agent passes both as `model: "provider/model:LEVEL"`; other launchers may expose a separate per-run thinking field. Invalid active configuration fails closed as `D:ERR` with no policy injection; `off` is always `D:OFF`.
51
-
52
- The extension stores only policy settings and provider/model identifiers in local defaults and session entries. Review local configuration before sharing diagnostics, and remove credentials, prompts, personal paths, session files, and unredacted logs from reports. See the [limits and privacy reference](https://yivas.github.io/pi-delegation-policy/limits-and-privacy/) and [security policy](https://github.com/Yivas/pi-delegation-policy/blob/main/SECURITY.md).
52
+ The editor is a bounded, keyboard-first panel. Every model selector pins **Use global default** and **Disable for this session** before searchable models. It shows model ID first and `[provider]` last, fuzzy-searches provider, model ID, and display name, and shows at most 10 model rows. It also shows a compact effective-policy preview, field explanations, and public model metadata when Pi supplies it. Changes are drafts until **Apply changes**; saving effective configuration as defaults updates only the global file without applying the draft, and closing a modified draft requires explicit discard.
53
53
 
54
54
  ## Development
55
55
 
@@ -63,8 +63,8 @@ npm run build
63
63
  npm run pack:check
64
64
  ```
65
65
 
66
- Tests use local mocks and do not make paid model calls or network requests. See [CONTRIBUTING.md](https://github.com/Yivas/pi-delegation-policy/blob/main/CONTRIBUTING.md) for contribution guidance.
66
+ Tests use local mocks and do not make paid model calls or network requests. See [CONTRIBUTING.md](https://github.com/Yivas/pi-delegation-policy/blob/main/CONTRIBUTING.md).
67
67
 
68
68
  ## License
69
69
 
70
- MIT. See [LICENSE](https://github.com/Yivas/pi-delegation-policy/blob/main/LICENSE).
70
+ MIT. See [LICENSE](LICENSE).
package/SECURITY.md CHANGED
@@ -14,4 +14,4 @@ Include the affected version or commit, operating system, Pi version, reproducti
14
14
 
15
15
  ## Supported versions
16
16
 
17
- Only the latest published version is supported. Version 0.4.1 is the current supported release.
17
+ Only the latest published version is supported. Version 0.6.0 is the current supported release.
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 2,
2
+ "schemaVersion": 3,
3
3
  "intensity": "normal",
4
4
  "preference": "standard",
5
5
  "small": {
@@ -10,10 +10,7 @@
10
10
  "provider": "example-provider",
11
11
  "model": "example-medium"
12
12
  },
13
- "large": {
14
- "provider": "example-provider",
15
- "model": "example-large"
16
- },
13
+ "large": null,
17
14
  "uiDesign": {
18
15
  "provider": "example-provider",
19
16
  "model": "example-ui-design"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-delegation-policy",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "A Pi extension for configurable delegation intensity and exact subagent role model references.",
6
6
  "type": "module",
@@ -5,12 +5,12 @@
5
5
  "type": "object",
6
6
  "required": ["schemaVersion"],
7
7
  "properties": {
8
- "schemaVersion": { "const": 2 },
8
+ "schemaVersion": { "const": 3 },
9
9
  "intensity": { "enum": ["off", "normal", "aggressive"] },
10
10
  "preference": { "enum": ["efficient", "standard", "intensive"] },
11
- "small": { "$ref": "#/$defs/modelRef" },
12
- "medium": { "$ref": "#/$defs/modelRef" },
13
- "large": { "$ref": "#/$defs/modelRef" },
11
+ "small": { "$ref": "#/$defs/ordinaryRole" },
12
+ "medium": { "$ref": "#/$defs/ordinaryRole" },
13
+ "large": { "$ref": "#/$defs/ordinaryRole" },
14
14
  "uiDesign": { "$ref": "#/$defs/modelRef" }
15
15
  },
16
16
  "$defs": {
@@ -22,6 +22,9 @@
22
22
  "model": { "type": "string", "minLength": 1 }
23
23
  },
24
24
  "additionalProperties": false
25
+ },
26
+ "ordinaryRole": {
27
+ "anyOf": [{ "$ref": "#/$defs/modelRef" }, { "type": "null" }]
25
28
  }
26
29
  },
27
30
  "additionalProperties": false
package/src/config.ts CHANGED
@@ -2,12 +2,15 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import {
5
+ CURRENT_SCHEMA_VERSION,
5
6
  emptyGlobalDefaults,
6
7
  emptySessionState,
7
8
  type EffectiveDelegateState,
8
9
  type GlobalDefaults,
9
10
  type Intensity,
10
11
  type ModelRef,
12
+ type ModelRole,
13
+ type OrdinaryRoleSetting,
11
14
  type Preference,
12
15
  type SessionDelegateState,
13
16
  type ValueSource,
@@ -19,11 +22,23 @@ export const SESSION_ENTRY_TYPE = "pi-delegation-policy:session";
19
22
  export const GLOBAL_CONFIG_NAME = "delegation-policy.json";
20
23
 
21
24
  const LEGACY_SCHEMA_MESSAGE =
22
- "Global defaults use schema version 1. Configure schema version 2 with /delegate before activating delegation.";
25
+ "Global defaults use schema version 1. Configure schema version 3 with /delegate before activating delegation.";
23
26
  const INVALID_CONFIG_MESSAGE = "Global defaults are invalid. Configure them again with /delegate.";
27
+ const INVALID_SESSION_MESSAGE =
28
+ "The latest delegation session state is invalid or unsupported. Delegation is off for safety.";
29
+ const CONFIG_KEYS = [
30
+ "schemaVersion",
31
+ "intensity",
32
+ "preference",
33
+ "small",
34
+ "medium",
35
+ "large",
36
+ "uiDesign",
37
+ ] as const;
24
38
 
25
39
  export type ConfigDiagnostic = {
26
40
  message: string;
41
+ reportWhenOff?: boolean;
27
42
  };
28
43
 
29
44
  export type LoadedDefaults = {
@@ -31,6 +46,26 @@ export type LoadedDefaults = {
31
46
  diagnostics: ConfigDiagnostic[];
32
47
  };
33
48
 
49
+ export type RestoredSessionState = {
50
+ session: SessionDelegateState;
51
+ diagnostics: ConfigDiagnostic[];
52
+ };
53
+
54
+ export type SessionEntryWriter = { appendEntry: (type: string, data?: unknown) => void };
55
+ export type GuardedAppendResult = "success" | "guard-failed" | "state-failed";
56
+
57
+ type Schema2GlobalDefaults = {
58
+ schemaVersion: 2;
59
+ intensity?: Intensity;
60
+ preference?: Preference;
61
+ small?: ModelRef;
62
+ medium?: ModelRef;
63
+ large?: ModelRef;
64
+ uiDesign?: ModelRef;
65
+ };
66
+
67
+ type Schema2SessionState = Omit<Schema2GlobalDefaults, "uiDesign"> & { uiDesign?: ModelRef | null };
68
+
34
69
  function isRecord(value: unknown): value is Record<string, unknown> {
35
70
  return typeof value === "object" && value !== null && !Array.isArray(value);
36
71
  }
@@ -60,33 +95,27 @@ function parseModelRef(value: unknown): ModelRef | undefined {
60
95
  return { provider: value.provider, model: value.model };
61
96
  }
62
97
 
63
- function copyModelRef(value: ModelRef | undefined): ModelRef | undefined {
64
- return value ? { ...value } : undefined;
98
+ function copyRoleSetting(value: OrdinaryRoleSetting | undefined): OrdinaryRoleSetting | undefined {
99
+ return value === null ? null : value ? { ...value } : undefined;
65
100
  }
66
101
 
67
- export function parseConfig(value: unknown): GlobalDefaults | undefined {
68
- if (
69
- !isRecord(value) ||
70
- !hasOnlyKeys(value, [
71
- "schemaVersion",
72
- "intensity",
73
- "preference",
74
- "small",
75
- "medium",
76
- "large",
77
- "uiDesign",
78
- ]) ||
79
- value.schemaVersion !== 2 ||
80
- (value.intensity !== undefined && !isIntensity(value.intensity)) ||
81
- (value.preference !== undefined && !isPreference(value.preference))
82
- )
83
- return undefined;
102
+ function hasValidEnvelope(value: unknown, schemaVersion: 2 | 3): value is Record<string, unknown> {
103
+ return (
104
+ isRecord(value) &&
105
+ hasOnlyKeys(value, CONFIG_KEYS) &&
106
+ value.schemaVersion === schemaVersion &&
107
+ (value.intensity === undefined || isIntensity(value.intensity)) &&
108
+ (value.preference === undefined || isPreference(value.preference))
109
+ );
110
+ }
84
111
 
112
+ function parseSchema2Roles(
113
+ value: Record<string, unknown>,
114
+ ): Pick<Schema2GlobalDefaults, "small" | "medium" | "large" | "uiDesign"> | undefined {
85
115
  const small = value.small === undefined ? undefined : parseModelRef(value.small);
86
116
  const medium = value.medium === undefined ? undefined : parseModelRef(value.medium);
87
117
  const large = value.large === undefined ? undefined : parseModelRef(value.large);
88
118
  const uiDesign = value.uiDesign === undefined ? undefined : parseModelRef(value.uiDesign);
89
-
90
119
  if (
91
120
  (value.small !== undefined && !small) ||
92
121
  (value.medium !== undefined && !medium) ||
@@ -94,11 +123,7 @@ export function parseConfig(value: unknown): GlobalDefaults | undefined {
94
123
  (value.uiDesign !== undefined && !uiDesign)
95
124
  )
96
125
  return undefined;
97
-
98
126
  return {
99
- schemaVersion: 2,
100
- ...(value.intensity ? { intensity: value.intensity } : {}),
101
- ...(value.preference ? { preference: value.preference } : {}),
102
127
  ...(small ? { small } : {}),
103
128
  ...(medium ? { medium } : {}),
104
129
  ...(large ? { large } : {}),
@@ -106,51 +131,139 @@ export function parseConfig(value: unknown): GlobalDefaults | undefined {
106
131
  };
107
132
  }
108
133
 
109
- export function parseSessionState(value: unknown): SessionDelegateState | undefined {
134
+ export function parseSchema2Config(value: unknown): Schema2GlobalDefaults | undefined {
135
+ if (!hasValidEnvelope(value, 2)) return undefined;
136
+ const roles = parseSchema2Roles(value);
137
+ if (!roles) return undefined;
138
+ return {
139
+ schemaVersion: 2,
140
+ ...(value.intensity ? { intensity: value.intensity as Intensity } : {}),
141
+ ...(value.preference ? { preference: value.preference as Preference } : {}),
142
+ ...roles,
143
+ };
144
+ }
145
+
146
+ export function parseSchema3Config(value: unknown): GlobalDefaults | undefined {
147
+ if (!hasValidEnvelope(value, CURRENT_SCHEMA_VERSION)) return undefined;
148
+ const parseOrdinary = (setting: unknown): OrdinaryRoleSetting | undefined =>
149
+ setting === null ? null : parseModelRef(setting);
150
+ const small = value.small === undefined ? undefined : parseOrdinary(value.small);
151
+ const medium = value.medium === undefined ? undefined : parseOrdinary(value.medium);
152
+ const large = value.large === undefined ? undefined : parseOrdinary(value.large);
153
+ const uiDesign = value.uiDesign === undefined ? undefined : parseModelRef(value.uiDesign);
110
154
  if (
111
- !isRecord(value) ||
112
- !hasOnlyKeys(value, [
113
- "schemaVersion",
114
- "intensity",
115
- "preference",
116
- "small",
117
- "medium",
118
- "large",
119
- "uiDesign",
120
- ]) ||
121
- value.schemaVersion !== 2 ||
122
- (value.intensity !== undefined && !isIntensity(value.intensity)) ||
123
- (value.preference !== undefined && !isPreference(value.preference))
155
+ (value.small !== undefined && small === undefined) ||
156
+ (value.medium !== undefined && medium === undefined) ||
157
+ (value.large !== undefined && large === undefined) ||
158
+ (value.uiDesign !== undefined && !uiDesign)
124
159
  )
125
160
  return undefined;
161
+ return {
162
+ schemaVersion: CURRENT_SCHEMA_VERSION,
163
+ ...(value.intensity ? { intensity: value.intensity as Intensity } : {}),
164
+ ...(value.preference ? { preference: value.preference as Preference } : {}),
165
+ ...(small !== undefined ? { small } : {}),
166
+ ...(medium !== undefined ? { medium } : {}),
167
+ ...(large !== undefined ? { large } : {}),
168
+ ...(uiDesign ? { uiDesign } : {}),
169
+ };
170
+ }
126
171
 
127
- const small = value.small === undefined ? undefined : parseModelRef(value.small);
128
- const medium = value.medium === undefined ? undefined : parseModelRef(value.medium);
129
- const large = value.large === undefined ? undefined : parseModelRef(value.large);
172
+ function migrateSchema2Config(value: Schema2GlobalDefaults): GlobalDefaults {
173
+ return {
174
+ schemaVersion: CURRENT_SCHEMA_VERSION,
175
+ ...(value.intensity ? { intensity: value.intensity as Intensity } : {}),
176
+ ...(value.preference ? { preference: value.preference as Preference } : {}),
177
+ ...(value.small ? { small: { ...value.small } } : {}),
178
+ ...(value.medium ? { medium: { ...value.medium } } : {}),
179
+ ...(value.large ? { large: { ...value.large } } : {}),
180
+ ...(value.uiDesign ? { uiDesign: { ...value.uiDesign } } : {}),
181
+ };
182
+ }
183
+
184
+ export function parseConfig(value: unknown): GlobalDefaults | undefined {
185
+ return (
186
+ parseSchema3Config(value) ??
187
+ (() => {
188
+ const schema2 = parseSchema2Config(value);
189
+ return schema2 ? migrateSchema2Config(schema2) : undefined;
190
+ })()
191
+ );
192
+ }
193
+
194
+ function parseSchema2SessionState(value: unknown): Schema2SessionState | undefined {
195
+ if (!hasValidEnvelope(value, 2)) return undefined;
130
196
  const uiDesign =
131
197
  value.uiDesign === undefined || value.uiDesign === null
132
198
  ? value.uiDesign
133
199
  : parseModelRef(value.uiDesign);
200
+ const roles = parseSchema2Roles({ ...value, uiDesign: undefined });
201
+ if (!roles || (value.uiDesign !== undefined && value.uiDesign !== null && !uiDesign))
202
+ return undefined;
203
+ return {
204
+ schemaVersion: 2,
205
+ ...(value.intensity ? { intensity: value.intensity as Intensity } : {}),
206
+ ...(value.preference ? { preference: value.preference as Preference } : {}),
207
+ ...roles,
208
+ ...(uiDesign === null ? { uiDesign: null } : uiDesign ? { uiDesign } : {}),
209
+ };
210
+ }
134
211
 
212
+ function parseSchema3SessionState(value: unknown): SessionDelegateState | undefined {
213
+ if (!hasValidEnvelope(value, CURRENT_SCHEMA_VERSION)) return undefined;
214
+ const parseOrdinary = (setting: unknown): OrdinaryRoleSetting | undefined =>
215
+ setting === null ? null : parseModelRef(setting);
216
+ const small = value.small === undefined ? undefined : parseOrdinary(value.small);
217
+ const medium = value.medium === undefined ? undefined : parseOrdinary(value.medium);
218
+ const large = value.large === undefined ? undefined : parseOrdinary(value.large);
219
+ const uiDesign =
220
+ value.uiDesign === undefined || value.uiDesign === null
221
+ ? value.uiDesign
222
+ : parseModelRef(value.uiDesign);
135
223
  if (
136
- (value.small !== undefined && !small) ||
137
- (value.medium !== undefined && !medium) ||
138
- (value.large !== undefined && !large) ||
224
+ (value.small !== undefined && small === undefined) ||
225
+ (value.medium !== undefined && medium === undefined) ||
226
+ (value.large !== undefined && large === undefined) ||
139
227
  (value.uiDesign !== undefined && value.uiDesign !== null && !uiDesign)
140
228
  )
141
229
  return undefined;
142
-
143
230
  return {
144
- schemaVersion: 2,
145
- ...(value.intensity ? { intensity: value.intensity } : {}),
146
- ...(value.preference ? { preference: value.preference } : {}),
147
- ...(small ? { small } : {}),
148
- ...(medium ? { medium } : {}),
149
- ...(large ? { large } : {}),
231
+ schemaVersion: CURRENT_SCHEMA_VERSION,
232
+ ...(value.intensity ? { intensity: value.intensity as Intensity } : {}),
233
+ ...(value.preference ? { preference: value.preference as Preference } : {}),
234
+ ...(small !== undefined ? { small } : {}),
235
+ ...(medium !== undefined ? { medium } : {}),
236
+ ...(large !== undefined ? { large } : {}),
150
237
  ...(uiDesign === null ? { uiDesign: null } : uiDesign ? { uiDesign } : {}),
151
238
  };
152
239
  }
153
240
 
241
+ function migrateSchema2SessionState(value: Schema2SessionState): SessionDelegateState {
242
+ return {
243
+ schemaVersion: CURRENT_SCHEMA_VERSION,
244
+ ...(value.intensity ? { intensity: value.intensity as Intensity } : {}),
245
+ ...(value.preference ? { preference: value.preference as Preference } : {}),
246
+ ...(value.small ? { small: { ...value.small } } : {}),
247
+ ...(value.medium ? { medium: { ...value.medium } } : {}),
248
+ ...(value.large ? { large: { ...value.large } } : {}),
249
+ ...(value.uiDesign === null
250
+ ? { uiDesign: null }
251
+ : value.uiDesign
252
+ ? { uiDesign: { ...value.uiDesign } }
253
+ : {}),
254
+ };
255
+ }
256
+
257
+ export function parseSessionState(value: unknown): SessionDelegateState | undefined {
258
+ return (
259
+ parseSchema3SessionState(value) ??
260
+ (() => {
261
+ const schema2 = parseSchema2SessionState(value);
262
+ return schema2 ? migrateSchema2SessionState(schema2) : undefined;
263
+ })()
264
+ );
265
+ }
266
+
154
267
  function isLegacyConfig(value: unknown): boolean {
155
268
  return isRecord(value) && value.schemaVersion === 1;
156
269
  }
@@ -195,64 +308,111 @@ async function atomicWrite(path: string, value: unknown): Promise<void> {
195
308
  }
196
309
 
197
310
  export async function writeConfig(path: string, defaults: GlobalDefaults): Promise<void> {
198
- const parsed = parseConfig(defaults);
311
+ const parsed = parseSchema3Config(defaults);
199
312
  if (!parsed) throw new Error("Refusing to write invalid delegation policy defaults.");
200
313
  await atomicWrite(path, parsed);
201
314
  }
202
315
 
203
- export function restoreSessionState(entries: unknown[]): SessionDelegateState {
316
+ export function restoreSessionStateWithDiagnostics(entries: unknown[]): RestoredSessionState {
204
317
  for (let index = entries.length - 1; index >= 0; index -= 1) {
205
318
  const entry = entries[index] as Record<string, unknown> | undefined;
206
319
  if (entry?.type !== "custom" || entry.customType !== SESSION_ENTRY_TYPE) continue;
207
- const state = parseSessionState(entry.data);
208
- if (state) return state;
320
+ const session = parseSessionState(entry.data);
321
+ if (session) return { session, diagnostics: [] };
322
+ return {
323
+ session: { schemaVersion: CURRENT_SCHEMA_VERSION, intensity: "off" },
324
+ diagnostics: [{ message: INVALID_SESSION_MESSAGE, reportWhenOff: true }],
325
+ };
209
326
  }
210
- return emptySessionState();
327
+ return { session: emptySessionState(), diagnostics: [] };
211
328
  }
212
329
 
213
- function sourceFor<T>(sessionValue: T | undefined, globalValue: T | undefined): ValueSource {
214
- if (sessionValue !== undefined) return "session";
330
+ export function restoreSessionState(entries: unknown[]): SessionDelegateState {
331
+ return restoreSessionStateWithDiagnostics(entries).session;
332
+ }
333
+
334
+ function sourceFor<T>(
335
+ session: Record<string, unknown>,
336
+ globalValue: T | undefined,
337
+ key: string,
338
+ ): ValueSource {
339
+ if (Object.hasOwn(session, key)) return "session";
215
340
  if (globalValue !== undefined) return "global";
216
341
  return "default";
217
342
  }
218
343
 
344
+ function resolveRole(
345
+ globalValue: OrdinaryRoleSetting | undefined,
346
+ session: SessionDelegateState,
347
+ role: ModelRole,
348
+ ): OrdinaryRoleSetting | undefined {
349
+ return copyRoleSetting(Object.hasOwn(session, role) ? session[role] : globalValue);
350
+ }
351
+
219
352
  export function resolveDelegateState(
220
353
  defaults: GlobalDefaults,
221
354
  session: SessionDelegateState,
222
355
  ): EffectiveDelegateState {
223
356
  const uiDesign =
224
357
  session.uiDesign === undefined
225
- ? copyModelRef(defaults.uiDesign)
358
+ ? defaults.uiDesign
359
+ ? { ...defaults.uiDesign }
360
+ : undefined
226
361
  : session.uiDesign === null
227
362
  ? undefined
228
- : copyModelRef(session.uiDesign);
363
+ : { ...session.uiDesign };
364
+ const small = resolveRole(defaults.small, session, "small");
365
+ const medium = resolveRole(defaults.medium, session, "medium");
366
+ const large = resolveRole(defaults.large, session, "large");
229
367
 
230
368
  return {
231
369
  intensity: session.intensity ?? defaults.intensity ?? "off",
232
370
  preference: session.preference ?? defaults.preference ?? "standard",
233
- small: copyModelRef(session.small ?? defaults.small),
234
- medium: copyModelRef(session.medium ?? defaults.medium),
235
- large: copyModelRef(session.large ?? defaults.large),
371
+ ...(small !== undefined ? { small } : {}),
372
+ ...(medium !== undefined ? { medium } : {}),
373
+ ...(large !== undefined ? { large } : {}),
236
374
  ...(uiDesign ? { uiDesign } : {}),
237
375
  source: {
238
- intensity: sourceFor(session.intensity, defaults.intensity),
239
- preference: sourceFor(session.preference, defaults.preference),
240
- small: sourceFor(session.small, defaults.small),
241
- medium: sourceFor(session.medium, defaults.medium),
242
- large: sourceFor(session.large, defaults.large),
243
- uiDesign: sourceFor(session.uiDesign, defaults.uiDesign),
376
+ intensity: sourceFor(session, defaults.intensity, "intensity"),
377
+ preference: sourceFor(session, defaults.preference, "preference"),
378
+ small: sourceFor(session, defaults.small, "small"),
379
+ medium: sourceFor(session, defaults.medium, "medium"),
380
+ large: sourceFor(session, defaults.large, "large"),
381
+ uiDesign: sourceFor(session, defaults.uiDesign, "uiDesign"),
244
382
  },
245
383
  };
246
384
  }
247
385
 
248
386
  export function defaultsFromEffectiveState(state: EffectiveDelegateState): GlobalDefaults {
387
+ const role = (setting: OrdinaryRoleSetting | undefined) =>
388
+ setting === undefined ? {} : setting === null ? { value: null } : { value: { ...setting } };
389
+ const small = role(state.small);
390
+ const medium = role(state.medium);
391
+ const large = role(state.large);
249
392
  return {
250
- schemaVersion: 2,
393
+ schemaVersion: CURRENT_SCHEMA_VERSION,
251
394
  intensity: state.intensity,
252
395
  preference: state.preference,
253
- ...(state.small ? { small: { ...state.small } } : {}),
254
- ...(state.medium ? { medium: { ...state.medium } } : {}),
255
- ...(state.large ? { large: { ...state.large } } : {}),
396
+ ...("value" in small ? { small: small.value } : {}),
397
+ ...("value" in medium ? { medium: medium.value } : {}),
398
+ ...("value" in large ? { large: large.value } : {}),
256
399
  ...(state.uiDesign ? { uiDesign: { ...state.uiDesign } } : {}),
257
400
  };
258
401
  }
402
+
403
+ export function appendGuardedSessionState(
404
+ pi: SessionEntryWriter,
405
+ session: SessionDelegateState,
406
+ ): GuardedAppendResult {
407
+ try {
408
+ pi.appendEntry(SESSION_ENTRY_TYPE, { schemaVersion: 2, intensity: "off" });
409
+ } catch {
410
+ return "guard-failed";
411
+ }
412
+ try {
413
+ pi.appendEntry(SESSION_ENTRY_TYPE, session);
414
+ return "success";
415
+ } catch {
416
+ return "state-failed";
417
+ }
418
+ }
@@ -16,6 +16,7 @@ import { buildPolicyPreview } from "./prompt.ts";
16
16
  import {
17
17
  INTENSITIES,
18
18
  PREFERENCES,
19
+ CURRENT_SCHEMA_VERSION,
19
20
  type GlobalDefaults,
20
21
  type Intensity,
21
22
  type ModelConfigKey,
@@ -73,7 +74,7 @@ const FIELD_LABELS: Record<DelegateField, string> = {
73
74
  small: "Small model",
74
75
  medium: "Medium model",
75
76
  large: "Large model",
76
- uiDesign: "UI Design",
77
+ uiDesign: "Visual Design",
77
78
  };
78
79
 
79
80
  const FIELD_DESCRIPTIONS: Record<DelegateField, string> = {
@@ -82,7 +83,7 @@ const FIELD_DESCRIPTIONS: Record<DelegateField, string> = {
82
83
  small: "Bounded, planned, and verifiable execution.",
83
84
  medium: "Planning, ambiguity, synthesis, and coordination.",
84
85
  large: "Exceptional unblocker for persistent hard problems.",
85
- uiDesign: "Visual direction and review only; never implementation.",
86
+ uiDesign: "Design, assets, and bounded presentation work; no app behavior.",
86
87
  };
87
88
 
88
89
  const ACTION_LABELS: Record<PanelAction, string> = {
@@ -260,7 +261,7 @@ export class DelegatePanel implements Component, Focusable {
260
261
  const safeWidth = Math.max(1, width);
261
262
  this.renderWidth = safeWidth;
262
263
  const rows = Math.max(1, this.tui.terminal.rows);
263
- if (safeWidth < 24 || rows < 9) return this.renderCompact(safeWidth, rows);
264
+ if (safeWidth < 26 || rows < 9) return this.renderCompact(safeWidth, rows);
264
265
 
265
266
  const title = this.renderTitle(safeWidth);
266
267
  const footer = this.renderFooter(safeWidth);
@@ -281,7 +282,7 @@ export class DelegatePanel implements Component, Focusable {
281
282
  }
282
283
 
283
284
  private isCompact(): boolean {
284
- return this.renderWidth < 24 || this.tui.terminal.rows < 9;
285
+ return this.renderWidth < 26 || this.tui.terminal.rows < 9;
285
286
  }
286
287
 
287
288
  private handleCompactInput(data: string): void {
@@ -400,7 +401,7 @@ export class DelegatePanel implements Component, Focusable {
400
401
  ? effective.uiDesign
401
402
  ? modelText(effective.uiDesign)
402
403
  : "disabled"
403
- : modelText(effective[field]);
404
+ : rawModelText(effective[field], "not configured");
404
405
  const details = this.sourceDetails(field);
405
406
  if (width < 48) {
406
407
  return [
@@ -473,7 +474,7 @@ export class DelegatePanel implements Component, Focusable {
473
474
  ];
474
475
  }
475
476
  return [
476
- "built-in —",
477
+ "built-in not configured",
477
478
  `global ${rawModelText(this.global[field], "—")}`,
478
479
  `session ${rawModelText(this.draft[field], "inherit")}`,
479
480
  ];
@@ -522,7 +523,7 @@ export class DelegatePanel implements Component, Focusable {
522
523
  const [input = ""] = this.searchInput.render(inputWidth);
523
524
  const inputText = input.startsWith("> ") ? input.slice(2) : input;
524
525
  const choices = this.modelChoices(mode.field, mode.query);
525
- const pinnedCount = mode.field === "uiDesign" ? 2 : 1;
526
+ const pinnedCount = 2;
526
527
  const pinned = choices.slice(0, pinnedCount);
527
528
  const models = choices.slice(pinnedCount);
528
529
  const pinnedLines = pinned.map((choice, index) =>
@@ -544,8 +545,9 @@ export class DelegatePanel implements Component, Focusable {
544
545
  ),
545
546
  )
546
547
  : [];
547
- const fixedRows = 2 + pinnedLines.length + dividerRows;
548
- const availableDetailRows = Math.max(0, budget - fixedRows - 1);
548
+ const fixedRows = 1 + pinnedLines.length + dividerRows;
549
+ const reservedModelRows = models.length > 0 && budget > fixedRows ? 1 : 0;
550
+ const availableDetailRows = Math.max(0, budget - fixedRows - reservedModelRows - 1);
549
551
  const details = metadata.slice(0, availableDetailRows);
550
552
  const detailRows = details.length > 0 ? details.length + 1 : 0;
551
553
  const availableListRows = Math.max(0, budget - fixedRows - detailRows);
@@ -577,7 +579,6 @@ export class DelegatePanel implements Component, Focusable {
577
579
  }
578
580
  return [
579
581
  truncateToWidth(`Search: ${inputText}`, width, ""),
580
- "",
581
582
  ...pinnedLines,
582
583
  ...(dividerRows ? [this.theme.fg("borderMuted", "─".repeat(width))] : []),
583
584
  ...modelLines,
@@ -656,7 +657,8 @@ export class DelegatePanel implements Component, Focusable {
656
657
  }
657
658
  if (item === "apply") void this.applyDraft();
658
659
  else if (item === "save-defaults") void this.saveDefaults();
659
- else if (item === "reset") this.draft = { schemaVersion: 2, intensity: "off" };
660
+ else if (item === "reset")
661
+ this.draft = { schemaVersion: CURRENT_SCHEMA_VERSION, intensity: "off" };
660
662
  else this.requestClose();
661
663
  }
662
664
 
@@ -730,24 +732,28 @@ export class DelegatePanel implements Component, Focusable {
730
732
  if (!choice || this.mode.kind !== "model") return;
731
733
  const field = this.mode.field;
732
734
  if (choice.kind === "global") delete this.draft[field];
733
- else if (choice.kind === "disabled" && field === "uiDesign") this.draft.uiDesign = null;
735
+ else if (choice.kind === "disabled") this.draft[field] = null;
734
736
  else if (choice.kind === "model") this.draft[field] = { ...choice.reference };
735
737
  this.mode = { kind: "settings" };
736
738
  }
737
739
 
738
740
  private modelChoices(field: ModelConfigKey, query: string): ModelChoice[] {
739
741
  const global = this.global[field];
742
+ const globalDescription =
743
+ field === "uiDesign"
744
+ ? global
745
+ ? modelText(global)
746
+ : "disabled"
747
+ : rawModelText(global, "not configured");
740
748
  const pinned: ModelChoice[] = [
741
749
  {
742
750
  kind: "global",
743
751
  key: "global",
744
752
  label: USE_GLOBAL_DEFAULT,
745
- ...(global ? { description: modelText(global) } : {}),
753
+ description: globalDescription,
746
754
  },
755
+ { kind: "disabled", key: "disabled", label: DISABLE_FOR_SESSION },
747
756
  ];
748
- if (field === "uiDesign") {
749
- pinned.push({ kind: "disabled", key: "disabled", label: DISABLE_FOR_SESSION });
750
- }
751
757
  const filtered = query
752
758
  ? fuzzyFilter(this.candidates, query, (model) =>
753
759
  `${model.provider} ${model.provider}/${model.id} ${model.provider} ${model.id} ${model.name ?? ""}`.trim(),
package/src/index.ts CHANGED
@@ -5,16 +5,16 @@ import type {
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
7
7
  import { buildDelegationPolicy } from "./prompt.ts";
8
+ import { appendGuardedSessionState, type GuardedAppendResult } from "./config.ts";
8
9
  import {
9
10
  formatModelRef,
10
11
  hasRuntimeError,
11
12
  loadRuntime,
12
- sessionEntry,
13
13
  statusLabel,
14
14
  type RuntimeState,
15
15
  } from "./runtime.ts";
16
16
  import { openDelegateEditor } from "./ui.ts";
17
- import { INTENSITIES, type Intensity } from "./types.ts";
17
+ import { CURRENT_SCHEMA_VERSION, INTENSITIES, type Intensity } from "./types.ts";
18
18
 
19
19
  const STATUS_KEY = "pi-delegation-policy";
20
20
 
@@ -42,17 +42,13 @@ export function getArgumentCompletions(prefix: string): AutocompleteItem[] | nul
42
42
  return matches.length ? matches.map((value) => ({ value, label: value })) : null;
43
43
  }
44
44
 
45
- async function refresh(ctx: ExtensionContext): Promise<RuntimeState> {
46
- return loadRuntime(ctx);
47
- }
48
-
49
45
  function updateStatus(ctx: ExtensionContext, state: RuntimeState): void {
50
46
  ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("dim", statusLabel(state)));
51
47
  }
52
48
 
53
49
  async function openEditor(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
54
50
  await openDelegateEditor(ctx, pi);
55
- updateStatus(ctx, await refresh(ctx));
51
+ updateStatus(ctx, await loadRuntime(ctx));
56
52
  }
57
53
 
58
54
  export function statusText(state: RuntimeState): string {
@@ -63,34 +59,56 @@ export function statusText(state: RuntimeState): string {
63
59
  `small=${formatModelRef(effective.small)} (${effective.source.small})`,
64
60
  `medium=${formatModelRef(effective.medium)} (${effective.source.medium})`,
65
61
  `large=${formatModelRef(effective.large)} (${effective.source.large})`,
66
- `ui-design=${formatModelRef(effective.uiDesign)} (${effective.source.uiDesign})`,
62
+ `ui-design=${effective.uiDesign ? formatModelRef(effective.uiDesign) : "disabled"} (${effective.source.uiDesign})`,
67
63
  ];
68
64
 
69
- if (effective.intensity !== "off" && state.runtimeErrors.length > 0) {
70
- details.push(`details=${state.runtimeErrors.join("; ")}`);
71
- }
65
+ const diagnosticMessages = state.diagnostics.map(({ message }) => message);
66
+ const errorDetails =
67
+ effective.intensity === "off"
68
+ ? state.diagnostics.filter(({ reportWhenOff }) => reportWhenOff).map(({ message }) => message)
69
+ : [
70
+ ...diagnosticMessages,
71
+ ...state.runtimeErrors.filter((message) => !diagnosticMessages.includes(message)),
72
+ ];
73
+ if (errorDetails.length > 0) details.push(`details=${errorDetails.join("; ")}`);
72
74
  return details.join(" | ");
73
75
  }
74
76
 
77
+ function notifyAppendFailure(ctx: ExtensionCommandContext, result: GuardedAppendResult): void {
78
+ ctx.ui.notify(
79
+ result === "guard-failed"
80
+ ? "Could not save session settings. No change was applied."
81
+ : "Could not save session settings. Delegation is off for safety.",
82
+ "error",
83
+ );
84
+ }
85
+
75
86
  async function setSessionIntensity(
76
87
  pi: ExtensionAPI,
77
88
  ctx: ExtensionCommandContext,
78
89
  intensity: Intensity,
79
90
  ): Promise<void> {
80
- const state = await refresh(ctx);
81
- state.session = { ...state.session, schemaVersion: 2, intensity };
82
- sessionEntry(pi, state);
83
- const updated = await refresh(ctx);
91
+ const state = await loadRuntime(ctx);
92
+ const session = { ...state.session, schemaVersion: CURRENT_SCHEMA_VERSION, intensity };
93
+ const result = appendGuardedSessionState(pi, session);
94
+ const updated = await loadRuntime(ctx);
84
95
  updateStatus(ctx, updated);
96
+ if (result !== "success") {
97
+ notifyAppendFailure(ctx, result);
98
+ return;
99
+ }
85
100
  ctx.ui.notify(`Session delegation intensity: ${intensity}.`, "info");
86
101
  }
87
102
 
88
103
  async function resetSession(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
89
- const state = await refresh(ctx);
90
- state.session = { schemaVersion: 2, intensity: "off" };
91
- sessionEntry(pi, state);
92
- const updated = await refresh(ctx);
104
+ const session = { schemaVersion: CURRENT_SCHEMA_VERSION, intensity: "off" as const };
105
+ const result = appendGuardedSessionState(pi, session);
106
+ const updated = await loadRuntime(ctx);
93
107
  updateStatus(ctx, updated);
108
+ if (result !== "success") {
109
+ notifyAppendFailure(ctx, result);
110
+ return;
111
+ }
94
112
  ctx.ui.notify("Session delegation settings reset to off.", "info");
95
113
  }
96
114
 
@@ -105,7 +123,7 @@ export default function piDelegationPolicy(pi: ExtensionAPI): void {
105
123
  return;
106
124
  }
107
125
  if (action.kind === "status") {
108
- const state = await refresh(ctx);
126
+ const state = await loadRuntime(ctx);
109
127
  updateStatus(ctx, state);
110
128
  ctx.ui.notify(statusText(state), hasRuntimeError(state) ? "error" : "info");
111
129
  return;
@@ -128,13 +146,13 @@ export default function piDelegationPolicy(pi: ExtensionAPI): void {
128
146
  });
129
147
 
130
148
  pi.on("session_start", async (_event, ctx) => {
131
- updateStatus(ctx, await refresh(ctx));
149
+ updateStatus(ctx, await loadRuntime(ctx));
132
150
  });
133
151
  pi.on("session_tree", async (_event, ctx) => {
134
- updateStatus(ctx, await refresh(ctx));
152
+ updateStatus(ctx, await loadRuntime(ctx));
135
153
  });
136
154
  pi.on("before_agent_start", async (event, ctx) => {
137
- const state = await refresh(ctx);
155
+ const state = await loadRuntime(ctx);
138
156
  updateStatus(ctx, state);
139
157
  const policy = buildDelegationPolicy(state);
140
158
  return policy ? { systemPrompt: `${event.systemPrompt}\n\n${policy}` } : undefined;
package/src/prompt.ts CHANGED
@@ -1,5 +1,10 @@
1
- import { hasRuntimeError, type RuntimeState } from "./runtime.ts";
2
- import type { EffectiveDelegateState, ModelRef, Preference } from "./types.ts";
1
+ import {
2
+ enabledOrdinaryRoles,
3
+ hasRuntimeError,
4
+ isRoleDisabled,
5
+ type RuntimeState,
6
+ } from "./runtime.ts";
7
+ import type { EffectiveDelegateState, ModelRef, ModelRole, Preference } from "./types.ts";
3
8
 
4
9
  const NORMAL_POLICY =
5
10
  "Delegate substantial, separable work only when the expected benefit clearly outweighs briefing, supervision, review, and integration cost. Count parallelism as a benefit only when valuable work can advance now or elapsed time matters. A merely possible fresh perspective is not enough by itself. Keep borderline work with the main agent.";
@@ -11,19 +16,36 @@ const ROLE_SELECTION_POLICY = `Choose the role by task fit before considering mo
11
16
  - difficulty: clarity, ambiguity, dependencies, competing hypotheses, and risk;
12
17
  - quantity: files, modules, systems, sources, and context volume;
13
18
  - error and review cost: what can go wrong, how costly it is to detect, and what evidence is needed.
14
- No single factor decides the role. Select the smallest role that can satisfy the acceptance criteria and evidence requirements.
19
+ No single factor decides the role. First remove disabled roles, then discard enabled roles that cannot satisfy the acceptance criteria and evidence requirements. Select the least costly remaining role that can satisfy them. Keep the work with the main agent if no enabled role can satisfy them.
15
20
 
16
21
  Use Small for bounded, planned, and verifiable execution: concrete searches, scoped exploration, defined implementation, focused documentation, tests, reviews, mechanical changes, evident bugs, and bounded UI implementation whose design and stack are decided. Difficult but well-defined execution can remain Small with higher thinking.
17
22
 
18
23
  Use Medium directly when the combined task fit materially requires planning, reducing meaningful ambiguity, broad synthesis, tracing several modules, comparing sources or options, coordinating substantial context, or making difficult decisions. Small does not need to fail first.
19
24
 
20
- Use Large only to unblock genuinely stuck work: persistent failures, severe framework conflicts, contradictory hypotheses, or reliable prior evidence that ordinary roles have not produced a trustworthy answer. Do not require ceremonial failed attempts. Large remains exceptional.
25
+ When Small and Medium are enabled alternatives, use Large only to unblock genuinely stuck work: persistent failures, severe framework conflicts, contradictory hypotheses, or reliable prior evidence that ordinary roles have not produced a trustworthy answer. Do not require ceremonial failed attempts. Large remains exceptional in a complete ordinary-role configuration.
21
26
 
22
- Large quantities of repetitive, independent work favor multiple Small delegations; volume alone does not justify Medium or Large. Agent type does not determine the model role. Apply preference only when Small and Medium are comparably credible fits.
27
+ A more capable enabled role may cover work normally suited to a disabled role only when it can satisfy the same acceptance and evidence. Never choose a less capable role merely because it is the only enabled role. Large quantities of repetitive, independent work favor multiple Small delegations; volume alone does not justify Medium or Large. Agent type does not determine the model role. Apply preference only when Small and Medium are comparably credible fits. That tie-break applies only when both are enabled.
23
28
 
24
29
  In every intensity, keep global strategy, coordination, integration, final review, and work whose essential context is too costly or risky to transfer with the main agent.`;
25
30
 
26
- function preferenceGuidance(preference: Preference): string {
31
+ const VISUAL_DESIGN_POLICY = `Visual Design is an optional specialist role. Use it only when all four conditions hold:
32
+ 1. the primary acceptance criterion is a visual or user-experience result;
33
+ 2. product behavior and data contracts are already defined and remain unchanged;
34
+ 3. the patch is bounded to an identifiable surface, component, or set of assets;
35
+ 4. it requires no business logic, data flow, APIs, routes, application architecture, tooling, or cross-system coordination.
36
+
37
+ When eligible, Visual Design may design, create, implement, and review scoped presentation code and visual assets, including layout, styles, responsive presentation, typography, images, icons, logos, SVGs, diagrams, and documentation visuals. It may address visual accessibility such as contrast and focus visibility. It must run and report the relevant existing checks for its patch.
38
+
39
+ Route interaction behavior, state, validation, semantic HTML changes, keyboard mechanics, ARIA behavior, authentication, permissions, persistence, test infrastructure, and behavior-test ownership to an enabled ordinary role that fits, or keep it with the main agent. If any eligibility condition fails, use an enabled ordinary role or split the visual portion from the broader task. The main agent retains cross-domain integration and final acceptance.`;
40
+
41
+ function hasSmallMedium(enabled: readonly ModelRole[]): boolean {
42
+ return enabled.includes("small") && enabled.includes("medium");
43
+ }
44
+
45
+ function preferenceGuidance(preference: Preference, enabled: readonly ModelRole[]): string {
46
+ if (!hasSmallMedium(enabled)) {
47
+ return `${preference} is inactive because Small or Medium is disabled.`;
48
+ }
27
49
  if (preference === "efficient") {
28
50
  return "Use efficient only as a Small tie-break when Small and Medium are comparably credible. Do not choose Small when Medium is a materially better task fit.";
29
51
  }
@@ -33,7 +55,8 @@ function preferenceGuidance(preference: Preference): string {
33
55
  return "Standard adds no Small or Medium bias; follow task fit.";
34
56
  }
35
57
 
36
- function preferencePreview(preference: Preference): string {
58
+ function preferencePreview(preference: Preference, enabled: readonly ModelRole[]): string {
59
+ if (!hasSmallMedium(enabled)) return `${preference} inactive (Small or Medium disabled)`;
37
60
  if (preference === "efficient") return "efficient breaks comparable fits toward Small";
38
61
  if (preference === "intensive") return "intensive breaks comparable fits toward Medium";
39
62
  return "standard has no extra bias";
@@ -58,18 +81,43 @@ function formatThinkingLaunchModel(reference: ModelRef): string {
58
81
  return promptString(`${reference.provider}/${reference.model}:LEVEL`);
59
82
  }
60
83
 
84
+ function roleName(role: ModelRole): string {
85
+ return role[0]!.toUpperCase() + role.slice(1);
86
+ }
87
+
88
+ function rolesByState(effective: EffectiveDelegateState): {
89
+ enabled: ModelRole[];
90
+ disabled: ModelRole[];
91
+ unconfigured: ModelRole[];
92
+ } {
93
+ return {
94
+ enabled: enabledOrdinaryRoles(effective),
95
+ disabled: (["small", "medium", "large"] as const).filter((role) =>
96
+ isRoleDisabled(effective[role]),
97
+ ),
98
+ unconfigured: (["small", "medium", "large"] as const).filter(
99
+ (role) => effective[role] === undefined,
100
+ ),
101
+ };
102
+ }
103
+
61
104
  export function buildPolicyPreview(effective: EffectiveDelegateState): string[] {
62
105
  if (effective.intensity === "off") return ["off · no policy injected"];
63
106
 
64
- const { small, medium, large } = effective;
65
- if (!small) return ["active · Small not configured · no policy can be injected"];
66
- if (!medium) return ["active · Medium not configured · no policy can be injected"];
67
- if (!large) return ["active · Large not configured · no policy can be injected"];
107
+ const { enabled, disabled, unconfigured } = rolesByState(effective);
108
+ if (unconfigured.length > 0) {
109
+ return [`active · ${roleName(unconfigured[0]!)} not configured · no policy can be injected`];
110
+ }
111
+ if (enabled.length === 0)
112
+ return ["active · no ordinary role enabled · no policy can be injected"];
68
113
 
114
+ const references = enabled
115
+ .map((role) => `${roleName(role)} ${formatLaunchModel(effective[role] as ModelRef)}`)
116
+ .join(" · ");
69
117
  return [
70
- `${effective.intensity} · task fit first · ${preferencePreview(effective.preference)}`,
71
- `Small ${formatLaunchModel(small)} · Medium ${formatLaunchModel(medium)} · Large ${formatLaunchModel(large)}`,
72
- "Every launch must include the exact model plus per-task thinking; neither uses an ambient default.",
118
+ `${effective.intensity} · task fit first · ${preferencePreview(effective.preference, enabled)}`,
119
+ `Enabled: ${enabled.map(roleName).join(", ")}${disabled.length ? ` · Disabled: ${disabled.map(roleName).join(", ")}` : ""}`,
120
+ `${references} · exact model plus per-task thinking required; neither uses an ambient default.`,
73
121
  ];
74
122
  }
75
123
 
@@ -77,12 +125,19 @@ export function buildDelegationPolicy(state: RuntimeState): string | undefined {
77
125
  if (state.effective.intensity === "off" || hasRuntimeError(state)) return undefined;
78
126
 
79
127
  const { effective } = state;
80
- if (!effective.small || !effective.medium || !effective.large) return undefined;
128
+ const { enabled, disabled } = rolesByState(effective);
129
+ if (enabled.length === 0) return undefined;
81
130
 
82
131
  const intensityPolicy = effective.intensity === "normal" ? NORMAL_POLICY : AGGRESSIVE_POLICY;
83
132
  const uiDesign = effective.uiDesign
84
- ? `\n- UI Design: ${formatReference(effective.uiDesign)}; exact model base: ${formatLaunchModel(effective.uiDesign)}; pi-subagents form: ${formatThinkingLaunchModel(effective.uiDesign)}. Use this role only for visual design direction, exploration, or review. Never use it to implement an interface, write code, or run tests.`
133
+ ? `\n- Visual Design: ${formatReference(effective.uiDesign)}; exact model base: ${formatLaunchModel(effective.uiDesign)}; pi-subagents form: ${formatThinkingLaunchModel(effective.uiDesign)}`
85
134
  : "";
135
+ const roleLines = enabled
136
+ .map((role) => {
137
+ const reference = effective[role] as ModelRef;
138
+ return `- ${roleName(role)}: ${formatReference(reference)}; exact model base: ${formatLaunchModel(reference)}; pi-subagents form: ${formatThinkingLaunchModel(reference)}`;
139
+ })
140
+ .join("\n");
86
141
 
87
142
  return `<delegation_policy>
88
143
  Intensity: ${effective.intensity}.
@@ -90,15 +145,15 @@ ${intensityPolicy}
90
145
 
91
146
  ${ROLE_SELECTION_POLICY}
92
147
 
93
- Model preference: ${effective.preference}. ${preferenceGuidance(effective.preference)}
148
+ Enabled ordinary roles: ${enabled.map(roleName).join(", ")}.${disabled.length ? `\nDisabled ordinary roles: ${disabled.map(roleName).join(", ")}.` : ""}
94
149
 
95
- Before every delegated launch, name the selected role and take its exact combined provider/model base below. Choose thinking dynamically for that run from task demand, difficulty, quantity, risk, review cost, and the selected model's capabilities. Then transmit both through the launcher's per-run mechanism without changing the provider/model base. When the launcher encodes thinking as a model suffix, replace LEVEL in the shown pi-subagents form and pass model: "provider/model:LEVEL". Do not omit the model or thinking choice, inherit an ambient launcher default for either, substitute another model, persist the thinking level, or invent a fallback model or role or an unsupported thinking level.
150
+ Model preference: ${effective.preference}. ${preferenceGuidance(effective.preference, enabled)}
96
151
 
97
- Roles:
98
- - Small: ${formatReference(effective.small)}; exact model base: ${formatLaunchModel(effective.small)}; pi-subagents form: ${formatThinkingLaunchModel(effective.small)}
99
- - Medium: ${formatReference(effective.medium)}; exact model base: ${formatLaunchModel(effective.medium)}; pi-subagents form: ${formatThinkingLaunchModel(effective.medium)}
100
- - Large: ${formatReference(effective.large)}; exact model base: ${formatLaunchModel(effective.large)}; pi-subagents form: ${formatThinkingLaunchModel(effective.large)}${uiDesign}
152
+ Before every delegated launch, name the selected role and take its exact combined provider/model base below. Choose thinking dynamically for that run from task demand, difficulty, quantity, risk, review cost, and the selected model's capabilities. Then transmit both through the launcher's per-run mechanism without changing the provider/model base. When the launcher encodes thinking as a model suffix, replace LEVEL in the shown pi-subagents form and pass model: "provider/model:LEVEL". Do not omit the model or thinking choice, inherit an ambient launcher default for either, substitute an unlisted model, persist the thinking level, launch a disabled or unconfigured role, invent a role, or use an unsupported thinking level.
101
153
 
154
+ Roles:
155
+ ${roleLines}${uiDesign}
156
+ ${effective.uiDesign ? `\n${VISUAL_DESIGN_POLICY}\n` : ""}
102
157
  This is guidance for the main agent. It does not create, execute, route, supervise, or enforce delegated work.
103
158
  </delegation_policy>`;
104
159
  }
package/src/runtime.ts CHANGED
@@ -3,9 +3,8 @@ import type { Api, Model } from "@earendil-works/pi-ai";
3
3
  import {
4
4
  getGlobalConfigPath,
5
5
  readConfig,
6
- SESSION_ENTRY_TYPE,
7
6
  resolveDelegateState,
8
- restoreSessionState,
7
+ restoreSessionStateWithDiagnostics,
9
8
  type ConfigDiagnostic,
10
9
  } from "./config.ts";
11
10
  import {
@@ -17,6 +16,7 @@ import {
17
16
  type ModelRef,
18
17
  type ModelRole,
19
18
  type ModelStatus,
19
+ type OrdinaryRoleSetting,
20
20
  type SessionDelegateState,
21
21
  } from "./types.ts";
22
22
 
@@ -29,6 +29,18 @@ export type RuntimeState = {
29
29
  runtimeErrors: string[];
30
30
  };
31
31
 
32
+ export function isRoleEnabled(setting: OrdinaryRoleSetting | undefined): setting is ModelRef {
33
+ return setting !== undefined && setting !== null;
34
+ }
35
+
36
+ export function isRoleDisabled(setting: OrdinaryRoleSetting | undefined): setting is null {
37
+ return setting === null;
38
+ }
39
+
40
+ export function enabledOrdinaryRoles(effective: EffectiveDelegateState): ModelRole[] {
41
+ return MODEL_ROLES.filter((role) => isRoleEnabled(effective[role]));
42
+ }
43
+
32
44
  function matchesReference(model: Model<Api>, reference: ModelRef): boolean {
33
45
  return model.provider === reference.provider && model.id === reference.model;
34
46
  }
@@ -88,17 +100,12 @@ function statusDetail(status: ModelStatus): string {
88
100
  }
89
101
  }
90
102
 
91
- function referenceFor(state: RuntimeState, role: ModelRole): ModelRef | undefined {
92
- return state.effective[role];
93
- }
94
-
95
- function validateRole(ctx: ExtensionContext, state: RuntimeState, role: ModelConfigKey): void {
96
- const reference = role === "uiDesign" ? state.effective.uiDesign : referenceFor(state, role);
97
- if (!reference) {
98
- state.runtimeErrors.push(`${ROLE_LABELS[role]} model is not configured.`);
99
- return;
100
- }
101
-
103
+ function validateEnabledRole(
104
+ ctx: ExtensionContext,
105
+ state: RuntimeState,
106
+ role: ModelConfigKey,
107
+ reference: ModelRef,
108
+ ): void {
102
109
  const status = validateModelReference(ctx, reference);
103
110
  state.modelStatuses.set(role, status);
104
111
  if (status.kind !== "available") {
@@ -108,12 +115,12 @@ function validateRole(ctx: ExtensionContext, state: RuntimeState, role: ModelCon
108
115
 
109
116
  export async function loadRuntime(ctx: ExtensionContext): Promise<RuntimeState> {
110
117
  const loaded = await readConfig(getGlobalConfigPath());
111
- const session = restoreSessionState(ctx.sessionManager.getBranch());
118
+ const restored = restoreSessionStateWithDiagnostics(ctx.sessionManager.getBranch());
112
119
  const state: RuntimeState = {
113
- effective: resolveDelegateState(loaded.defaults, session),
120
+ effective: resolveDelegateState(loaded.defaults, restored.session),
114
121
  global: loaded.defaults,
115
- session,
116
- diagnostics: loaded.diagnostics,
122
+ session: restored.session,
123
+ diagnostics: [...loaded.diagnostics, ...restored.diagnostics],
117
124
  modelStatuses: new Map(),
118
125
  runtimeErrors: [],
119
126
  };
@@ -129,8 +136,21 @@ export function validateRuntime(ctx: ExtensionContext, state: RuntimeState): voi
129
136
  if (state.effective.intensity === "off") return;
130
137
 
131
138
  for (const diagnostic of state.diagnostics) state.runtimeErrors.push(diagnostic.message);
132
- for (const role of MODEL_ROLES) validateRole(ctx, state, role);
133
- if (state.effective.uiDesign) validateRole(ctx, state, "uiDesign");
139
+ for (const role of MODEL_ROLES) {
140
+ const setting = state.effective[role];
141
+ if (setting === undefined) {
142
+ state.runtimeErrors.push(
143
+ `${ROLE_LABELS[role]} model is not configured; configure it or explicitly disable it.`,
144
+ );
145
+ } else if (isRoleEnabled(setting)) {
146
+ validateEnabledRole(ctx, state, role, setting);
147
+ }
148
+ }
149
+ if (enabledOrdinaryRoles(state.effective).length === 0) {
150
+ state.runtimeErrors.push("At least one ordinary role must be enabled.");
151
+ }
152
+ if (state.effective.uiDesign)
153
+ validateEnabledRole(ctx, state, "uiDesign", state.effective.uiDesign);
134
154
  }
135
155
 
136
156
  export function hasRuntimeError(state: RuntimeState): boolean {
@@ -143,13 +163,7 @@ export function statusLabel(state: RuntimeState): string {
143
163
  return state.effective.intensity === "normal" ? "D:NORM" : "D:AGG";
144
164
  }
145
165
 
146
- export function formatModelRef(reference: ModelRef | undefined): string {
166
+ export function formatModelRef(reference: ModelRef | null | undefined): string {
167
+ if (reference === null) return "disabled";
147
168
  return reference ? `${reference.provider}/${reference.model}` : "not configured";
148
169
  }
149
-
150
- export function sessionEntry(
151
- pi: { appendEntry: (type: string, data?: unknown) => void },
152
- state: RuntimeState,
153
- ): void {
154
- pi.appendEntry(SESSION_ENTRY_TYPE, state.session);
155
- }
package/src/types.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
 
3
+ export const CURRENT_SCHEMA_VERSION = 3 as const;
4
+
3
5
  export const INTENSITIES = ["off", "normal", "aggressive"] as const;
4
6
  export type Intensity = (typeof INTENSITIES)[number];
5
7
 
@@ -14,7 +16,7 @@ export const ROLE_LABELS: Record<ModelConfigKey, string> = {
14
16
  small: "Small",
15
17
  medium: "Medium",
16
18
  large: "Large",
17
- uiDesign: "UI Design",
19
+ uiDesign: "Visual Design",
18
20
  };
19
21
 
20
22
  export type ModelRef = {
@@ -22,24 +24,27 @@ export type ModelRef = {
22
24
  model: string;
23
25
  };
24
26
 
27
+ // Absent session properties inherit. Null explicitly disables an ordinary role.
28
+ export type OrdinaryRoleSetting = ModelRef | null;
29
+
25
30
  export type GlobalDefaults = {
26
- schemaVersion: 2;
31
+ schemaVersion: typeof CURRENT_SCHEMA_VERSION;
27
32
  intensity?: Intensity;
28
33
  preference?: Preference;
29
- small?: ModelRef;
30
- medium?: ModelRef;
31
- large?: ModelRef;
34
+ small?: OrdinaryRoleSetting;
35
+ medium?: OrdinaryRoleSetting;
36
+ large?: OrdinaryRoleSetting;
32
37
  uiDesign?: ModelRef;
33
38
  };
34
39
 
35
40
  export type SessionDelegateState = {
36
- schemaVersion: 2;
41
+ schemaVersion: typeof CURRENT_SCHEMA_VERSION;
37
42
  intensity?: Intensity;
38
43
  preference?: Preference;
39
- small?: ModelRef;
40
- medium?: ModelRef;
41
- large?: ModelRef;
42
- // Null explicitly disables a global UI Design role for this session branch.
44
+ small?: OrdinaryRoleSetting;
45
+ medium?: OrdinaryRoleSetting;
46
+ large?: OrdinaryRoleSetting;
47
+ // Null explicitly disables a global Visual Design role for this session branch.
43
48
  uiDesign?: ModelRef | null;
44
49
  };
45
50
 
@@ -48,9 +53,9 @@ export type ValueSource = "default" | "global" | "session";
48
53
  export type EffectiveDelegateState = {
49
54
  intensity: Intensity;
50
55
  preference: Preference;
51
- small?: ModelRef;
52
- medium?: ModelRef;
53
- large?: ModelRef;
56
+ small?: OrdinaryRoleSetting;
57
+ medium?: OrdinaryRoleSetting;
58
+ large?: OrdinaryRoleSetting;
54
59
  uiDesign?: ModelRef;
55
60
  source: {
56
61
  intensity: ValueSource;
@@ -70,9 +75,9 @@ export type ModelStatus =
70
75
  | { kind: "no-credentials" };
71
76
 
72
77
  export function emptyGlobalDefaults(): GlobalDefaults {
73
- return { schemaVersion: 2 };
78
+ return { schemaVersion: CURRENT_SCHEMA_VERSION };
74
79
  }
75
80
 
76
81
  export function emptySessionState(): SessionDelegateState {
77
- return { schemaVersion: 2 };
82
+ return { schemaVersion: CURRENT_SCHEMA_VERSION };
78
83
  }
package/src/ui.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
+ appendGuardedSessionState,
3
4
  defaultsFromEffectiveState,
4
5
  getGlobalConfigPath,
5
6
  resolveDelegateState,
6
7
  writeConfig,
7
8
  } from "./config.ts";
8
9
  import { DelegatePanel, type DelegatePanelResult } from "./delegate-panel.ts";
9
- import { hasRuntimeError, loadRuntime, modelCandidates, sessionEntry } from "./runtime.ts";
10
+ import { hasRuntimeError, loadRuntime, modelCandidates } from "./runtime.ts";
10
11
 
11
12
  export async function openDelegateEditor(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
12
13
  if (!ctx.hasUI) return;
@@ -29,13 +30,23 @@ export async function openDelegateEditor(ctx: ExtensionContext, pi: ExtensionAPI
29
30
  global: state.global,
30
31
  session: state.session,
31
32
  candidates,
32
- diagnostics: state.runtimeErrors,
33
+ diagnostics:
34
+ state.effective.intensity === "off"
35
+ ? state.diagnostics
36
+ .filter(({ reportWhenOff }) => reportWhenOff)
37
+ .map(({ message }) => message)
38
+ : [...state.runtimeErrors],
33
39
  hasRuntimeError: hasRuntimeError(state),
34
40
  onApply: async (draft) => {
35
- const session = structuredClone(draft);
36
- sessionEntry(pi, { ...state, session });
37
- state.session = session;
38
- return true;
41
+ const result = appendGuardedSessionState(pi, structuredClone(draft));
42
+ const refreshed = await loadRuntime(ctx);
43
+ state.global = refreshed.global;
44
+ state.session = refreshed.session;
45
+ state.diagnostics = refreshed.diagnostics;
46
+ state.effective = refreshed.effective;
47
+ state.modelStatuses = refreshed.modelStatuses;
48
+ state.runtimeErrors = refreshed.runtimeErrors;
49
+ return result === "success";
39
50
  },
40
51
  onSaveDefaults: async (draft) => {
41
52
  try {