@adeildo/pi-ask-permission 4.1.0 → 5.1.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.
Files changed (47) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +114 -193
  3. package/assets/preview.png +0 -0
  4. package/package.json +3 -2
  5. package/src/core/answer.ts +8 -0
  6. package/src/core/config/decode.ts +40 -6
  7. package/src/core/config/patterns.ts +0 -6
  8. package/src/core/config/schema.ts +13 -0
  9. package/src/core/config/settings.ts +318 -226
  10. package/src/core/config/store.ts +11 -4
  11. package/src/core/decide.ts +131 -19
  12. package/src/core/folders.ts +159 -0
  13. package/src/core/judge/backends/factory.ts +2 -2
  14. package/src/core/judge/backends/pi-model.ts +2 -1
  15. package/src/core/judge/compose.ts +0 -1
  16. package/src/core/judge/config.ts +34 -11
  17. package/src/core/judge/decode.ts +0 -3
  18. package/src/core/judge/gate.ts +9 -4
  19. package/src/core/judge/pipeline.ts +7 -4
  20. package/src/core/judge/policy.ts +1 -1
  21. package/src/core/judge/report.ts +5 -4
  22. package/src/core/judge/request.ts +19 -4
  23. package/src/core/judge/types.ts +11 -0
  24. package/src/core/mcp.ts +130 -0
  25. package/src/core/mode.ts +47 -25
  26. package/src/core/readonly-bash.ts +167 -4
  27. package/src/core/tools.ts +7 -3
  28. package/src/core/workspace.ts +107 -16
  29. package/src/index.ts +19 -3
  30. package/src/pi/commands.ts +13 -185
  31. package/src/pi/events.ts +83 -46
  32. package/src/pi/intent.ts +34 -0
  33. package/src/pi/mode.ts +63 -16
  34. package/src/pi/screen.ts +293 -0
  35. package/src/pi/session-entries.ts +46 -8
  36. package/src/pi/session.ts +52 -11
  37. package/src/ui/decision-options.ts +56 -12
  38. package/src/ui/judge-entry.ts +6 -16
  39. package/src/ui/questions.ts +194 -0
  40. package/src/ui/selector.ts +13 -5
  41. package/src/ui/clipboard.ts +0 -51
  42. package/src/ui/dialog.ts +0 -395
  43. package/src/ui/paste.ts +0 -27
  44. package/src/ui/picker.ts +0 -90
  45. package/src/ui/settings/judge.ts +0 -299
  46. package/src/ui/settings/screen.ts +0 -268
  47. package/src/ui/settings/status.ts +0 -53
@@ -1,24 +1,27 @@
1
- // The permission config lives in the shared settings file, one key per leaf, so the settings screen
2
- // comes from these declarations instead of a screen written by hand. The nested config the rest of
3
- // the package reads is assembled here, and writing maps back to the same keys.
1
+ // One setting per leaf of the config. `readConfig` builds the nested config back.
4
2
  import { existsSync, renameSync } from "node:fs";
5
3
 
6
4
  import {
7
5
  boolean,
6
+ type Control,
8
7
  type Decoder,
9
8
  duration,
9
+ isObject,
10
10
  literal,
11
11
  nullable,
12
+ readSettingsFile,
12
13
  type Setting,
13
14
  setting,
14
15
  type SettingsScope,
16
+ type SettingUi,
15
17
  string,
16
18
  stringList,
17
19
  trimmedString,
18
20
  unit,
21
+ writeSettingsFile,
19
22
  } from "@adeildo/pi-kit";
20
23
 
21
- import { type NoUIConfig, noUI } from "#core/config/decode.ts";
24
+ import { mode, type NoUIConfig, noUI, mcpPolicies } from "#core/config/decode.ts";
22
25
  import {
23
26
  DEFAULT_CONFIG,
24
27
  DEFAULT_TYPING,
@@ -30,242 +33,276 @@ import {
30
33
  type WorkspaceConfig,
31
34
  } from "#core/config/schema.ts";
32
35
  import { configPath, readLegacyConfig } from "#core/config/store.ts";
33
- import { DEFAULT_JUDGE, type JudgeBackendId, type JudgeFallback } from "#core/judge/config.ts";
34
- import { DEFAULT_MODE, PERMISSION_MODES, type PermissionMode } from "#core/mode.ts";
35
-
36
- const PERMISSION = "Permission";
37
- const JUDGE = "Permission judge";
38
-
39
- function leaf<T>(
40
- id: string,
41
- fallback: T,
42
- decoder: Decoder<T>,
43
- label: string,
44
- description: string,
45
- ui = PERMISSION,
46
- ): Setting<T> {
47
- return setting({
48
- id: `permission.${id}`,
49
- default: fallback,
50
- decoder,
51
- ui: { group: ui, label, description },
52
- });
36
+ import {
37
+ DEFAULT_JUDGE,
38
+ DEFAULT_RIGOR,
39
+ JEV_MODELS,
40
+ JUDGE_RIGORS,
41
+ type JudgeFallback,
42
+ type JudgeRigor,
43
+ RIGOR,
44
+ } from "#core/judge/config.ts";
45
+ import { POLICY_PRESETS } from "#core/judge/policy.ts";
46
+ import { MCP_POLICIES, MCP_POLICY_TEXT, type McpPolicy, sameServer } from "#core/mcp.ts";
47
+ import { DEFAULT_MODE, MODES, PERMISSION_MODES, type PermissionMode } from "#core/mode.ts";
48
+ import { OUTSIDE_DESCRIPTION, OUTSIDE_SCOPES } from "#core/workspace.ts";
49
+
50
+ export const SECTIONS = [
51
+ "This session",
52
+ "New sessions",
53
+ "Workspace",
54
+ "MCP servers",
55
+ "Reads",
56
+ "Dialog",
57
+ "Judge",
58
+ "Always yes",
59
+ ] as const;
60
+
61
+ export type Section = (typeof SECTIONS)[number];
62
+
63
+ interface Leaf<T> extends Omit<SettingUi, "section"> {
64
+ id: string;
65
+ fallback: T;
66
+ decoder: Decoder<T>;
67
+ section: Section;
68
+ }
69
+
70
+ function leaf<T>(entry: Leaf<T>): Setting<T> {
71
+ const { id, fallback, decoder, ...ui } = entry;
72
+ return setting({ id: `permission.${id}`, default: fallback, decoder, ui });
73
+ }
74
+
75
+ /** A setting with no row of its own: the screen builds its row, or only the file sets it. */
76
+ function hiddenLeaf<T>(id: string, fallback: T, decoder: Decoder<T>): Setting<T> {
77
+ return setting({ id: `permission.${id}`, default: fallback, decoder });
53
78
  }
54
79
 
80
+ export const MCP_POLICY_CONTROL: Control = {
81
+ type: "choice",
82
+ options: MCP_POLICIES.map((value) => ({
83
+ value,
84
+ label: MCP_POLICY_TEXT[value].label,
85
+ description: MCP_POLICY_TEXT[value].description,
86
+ })),
87
+ };
88
+
89
+ export const MODE_CONTROL: Control = {
90
+ type: "choice",
91
+ options: PERMISSION_MODES.map((value) => ({ value, description: MODES[value].description })),
92
+ };
93
+
94
+ export const OUTSIDE_CONTROL: Control = {
95
+ type: "choice",
96
+ options: OUTSIDE_SCOPES.map((value) => ({ value, description: OUTSIDE_DESCRIPTION[value] })),
97
+ };
98
+
99
+ const judgeFallback = literal("ask", "allow", "deny");
100
+
55
101
  const PERMISSION_LEAVES = {
56
- allow: leaf(
57
- "allow",
58
- DEFAULT_CONFIG.allow,
59
- stringList("tool names"),
60
- "Allowed tools",
61
- "Tools that run without asking.",
62
- ),
63
- noUI: leaf<NoUIConfig>(
64
- "noUI",
65
- DEFAULT_CONFIG.noUI,
66
- noUI,
67
- "Without a dialog",
68
- 'What to do when there is no dialog to ask in: "allow", "deny", or a map per tool.',
69
- ),
70
- notes: leaf<NoteDelivery>(
71
- "notes",
72
- DEFAULT_CONFIG.notes,
73
- literal("result", "message"),
74
- "Note delivery",
75
- '"result" attaches your note to the tool result, "message" sends it as a message.',
76
- ),
77
- mode: leaf<PermissionMode>(
78
- "mode",
79
- DEFAULT_MODE,
80
- literal(...PERMISSION_MODES),
81
- "Default mode",
82
- "The mode a new session starts in.",
83
- ),
84
- readOnlyBash: leaf(
85
- "readOnlyBash",
86
- DEFAULT_CONFIG.readOnlyBash,
87
- boolean,
88
- "Read-only bash",
89
- "Let commands that only read run without asking.",
90
- ),
91
- roots: leaf(
92
- "workspace.roots",
93
- DEFAULT_WORKSPACE.roots,
94
- stringList("paths"),
95
- "Workspace roots",
96
- "Paths the project is made of.",
97
- ),
98
- outside: leaf<OutsideScope>(
99
- "workspace.outside",
100
- DEFAULT_WORKSPACE.outside,
101
- literal("ask", "deny", "allow"),
102
- "Outside the workspace",
103
- "What to do with a path outside the roots.",
104
- ),
105
- pause: leaf(
106
- "typing.pause",
107
- DEFAULT_TYPING.pause,
108
- duration,
109
- "Typing pause",
110
- "How long after you stop typing the dialog opens.",
111
- ),
112
- maxWait: leaf<number | null>(
113
- "typing.maxWait",
114
- DEFAULT_TYPING.maxWait,
115
- nullable(duration),
116
- "Typing wait cap",
117
- "Longest the dialog waits while you type. Empty means no cap.",
118
- ),
102
+ mode: leaf<PermissionMode>({
103
+ id: "mode",
104
+ fallback: DEFAULT_MODE,
105
+ decoder: mode,
106
+ section: "New sessions",
107
+ label: "Mode",
108
+ description: "The mode a new session starts in. Alt+M changes it for one session.",
109
+ control: MODE_CONTROL,
110
+ }),
111
+ outside: leaf<OutsideScope>({
112
+ id: "workspace.outside",
113
+ fallback: DEFAULT_WORKSPACE.outside,
114
+ decoder: literal("ask", "deny", "allow"),
115
+ section: "New sessions",
116
+ label: "Outside the workspace",
117
+ description:
118
+ "What a new session does with a call outside the roots. Alt+W changes it for one session.",
119
+ control: OUTSIDE_CONTROL,
120
+ }),
121
+ roots: leaf({
122
+ id: "workspace.roots",
123
+ fallback: DEFAULT_WORKSPACE.roots,
124
+ decoder: stringList("paths"),
125
+ section: "Workspace",
126
+ label: "Roots",
127
+ description: "The folders the project is made of. Relative, absolute and ~ work.",
128
+ }),
129
+ servers: hiddenLeaf<Record<string, McpPolicy>>("mcp.servers", {}, mcpPolicies),
130
+ allow: leaf({
131
+ id: "allow",
132
+ fallback: DEFAULT_CONFIG.allow,
133
+ decoder: stringList("tool names"),
134
+ section: "Reads",
135
+ label: "Allowed tools",
136
+ description: "Tools that run without asking. `mcp_*` matches a family.",
137
+ }),
138
+ readOnlyBash: leaf({
139
+ id: "readOnlyBash",
140
+ fallback: DEFAULT_CONFIG.readOnlyBash,
141
+ decoder: boolean,
142
+ section: "Reads",
143
+ label: "Read-only bash",
144
+ description: "Commands that only read run without asking. In full everything runs anyway.",
145
+ }),
146
+ notes: leaf<NoteDelivery>({
147
+ id: "notes",
148
+ fallback: DEFAULT_CONFIG.notes,
149
+ decoder: literal("result", "message"),
150
+ section: "Dialog",
151
+ label: "Notes",
152
+ description: "Where the note you write in the dialog goes.",
153
+ control: {
154
+ type: "choice",
155
+ options: [
156
+ { value: "result", description: "added to the tool result" },
157
+ { value: "message", description: "sent as a message of its own" },
158
+ ],
159
+ },
160
+ }),
161
+ noUI: leaf<NoUIConfig>({
162
+ id: "noUI",
163
+ fallback: DEFAULT_CONFIG.noUI,
164
+ decoder: noUI,
165
+ section: "Dialog",
166
+ label: "With no dialog",
167
+ description:
168
+ "What happens when nobody can answer, as in print mode or a subagent. The file also takes a map per tool.",
169
+ control: { type: "choice", options: [{ value: "deny" }, { value: "allow" }] },
170
+ }),
171
+ pause: leaf({
172
+ id: "typing.pause",
173
+ fallback: DEFAULT_TYPING.pause,
174
+ decoder: duration,
175
+ section: "Dialog",
176
+ label: "Typing pause",
177
+ description: "How long after you stop typing in the editor the dialog opens.",
178
+ }),
179
+ maxWait: leaf<number | null>({
180
+ id: "typing.maxWait",
181
+ fallback: DEFAULT_TYPING.maxWait,
182
+ decoder: nullable(duration),
183
+ section: "Dialog",
184
+ label: "Typing wait cap",
185
+ description:
186
+ "The longest the dialog waits while you type. Empty waits for as long as you type.",
187
+ }),
119
188
  };
120
189
 
121
190
  const JUDGE_LEAVES = {
122
- enabled: leaf(
123
- "judge.enabled",
124
- DEFAULT_JUDGE.enabled,
125
- boolean,
126
- "Judge enabled",
127
- "Let a model decide the routine calls.",
128
- JUDGE,
129
- ),
130
- provider: leaf<JudgeBackendId>(
131
- "judge.provider",
132
- DEFAULT_JUDGE.provider,
133
- literal("jev", "pi"),
134
- "Judge backend",
135
- '"jev" asks TypeSafe, "pi" asks the model you are already using.',
136
- JUDGE,
137
- ),
138
- model: leaf(
139
- "judge.model",
140
- DEFAULT_JUDGE.model,
141
- trimmedString,
142
- "Judge model",
143
- "A Jev alias, or provider/modelId for the pi backend.",
144
- JUDGE,
145
- ),
146
- tools: leaf(
147
- "judge.tools",
148
- DEFAULT_JUDGE.tools,
149
- stringList("patterns"),
150
- "Judged tools",
151
- "Tool patterns the judge may decide. Empty means it never runs.",
152
- JUDGE,
153
- ),
154
- alwaysAsk: leaf(
155
- "judge.alwaysAsk",
156
- DEFAULT_JUDGE.alwaysAsk,
157
- stringList("patterns"),
158
- "Always ask me",
159
- "Tool patterns that skip the judge and come to you.",
160
- JUDGE,
161
- ),
162
- allowThreshold: leaf(
163
- "judge.thresholds.allow",
164
- DEFAULT_JUDGE.thresholds.allow,
165
- unit,
166
- "Judge confidence to allow",
167
- "How sure the judge has to be before a call runs.",
168
- JUDGE,
169
- ),
170
- denyThreshold: leaf(
171
- "judge.thresholds.deny",
172
- DEFAULT_JUDGE.thresholds.deny,
173
- unit,
174
- "Judge confidence to deny",
175
- "How sure the judge has to be before it blocks a call on its own.",
176
- JUDGE,
177
- ),
178
- riskCeiling: leaf(
179
- "judge.riskCeiling",
180
- DEFAULT_JUDGE.riskCeiling,
181
- unit,
182
- "Risk ceiling",
183
- "Above this risk the call comes to you, however sure the judge is.",
184
- JUDGE,
185
- ),
186
- whenUnsure: leaf<JudgeFallback>(
191
+ model: leaf({
192
+ id: "judge.model",
193
+ fallback: DEFAULT_JUDGE.model,
194
+ decoder: trimmedString,
195
+ section: "Judge",
196
+ label: "Model",
197
+ description: "Who judges. Jev answers fast, after /login typesafe. Any pi model works too.",
198
+ control: (ctx) => ({
199
+ type: "choice",
200
+ custom: true,
201
+ options: [
202
+ ...JEV_MODELS.map((value) => ({ value, description: "Jev, by TypeSafe" })),
203
+ ...ctx.modelRegistry
204
+ .getAvailable()
205
+ .map((model) => `${model.provider}/${model.id}`)
206
+ .toSorted((left, right) => left.localeCompare(right))
207
+ .map((value) => ({ value, description: "pi model" })),
208
+ ],
209
+ }),
210
+ }),
211
+ policy: leaf({
212
+ id: "judge.policy",
213
+ fallback: DEFAULT_JUDGE.policy,
214
+ decoder: string,
215
+ section: "Judge",
216
+ label: "Policy",
217
+ description: "The rules the judge follows, in plain English. Start from a preset and edit it.",
218
+ control: {
219
+ type: "text",
220
+ multiline: true,
221
+ presets: POLICY_PRESETS.filter((preset) => preset.policy !== "").map((preset) => ({
222
+ value: preset.policy,
223
+ label: preset.label,
224
+ description: preset.description,
225
+ })),
226
+ },
227
+ }),
228
+ alwaysAsk: leaf({
229
+ id: "judge.alwaysAsk",
230
+ fallback: DEFAULT_JUDGE.alwaysAsk,
231
+ decoder: stringList("patterns"),
232
+ section: "Judge",
233
+ label: "Always ask me",
234
+ description: 'Patterns the judge never approves, like "git push*".',
235
+ }),
236
+ // The screen builds these two rows itself. That keeps them after the policy, and lets picking a
237
+ // rigor drop the numbers set by hand.
238
+ rigor: hiddenLeaf<JudgeRigor>("judge.rigor", DEFAULT_RIGOR, literal(...JUDGE_RIGORS)),
239
+ dryRun: hiddenLeaf("judge.dryRun", DEFAULT_JUDGE.dryRun, boolean),
240
+ // File only. A threshold or a ceiling set here overrides the rigor.
241
+ allowThreshold: hiddenLeaf("judge.thresholds.allow", DEFAULT_JUDGE.thresholds.allow, unit),
242
+ denyThreshold: hiddenLeaf("judge.thresholds.deny", DEFAULT_JUDGE.thresholds.deny, unit),
243
+ riskCeiling: hiddenLeaf("judge.riskCeiling", DEFAULT_JUDGE.riskCeiling, unit),
244
+ canDeny: hiddenLeaf("judge.canDeny", DEFAULT_JUDGE.canDeny, boolean),
245
+ whenUnsure: hiddenLeaf<JudgeFallback>(
187
246
  "judge.whenUnsure",
188
247
  DEFAULT_JUDGE.whenUnsure,
189
- literal("ask", "allow", "deny"),
190
- "When the judge is unsure",
191
- "What to do near the thresholds.",
192
- JUDGE,
193
- ),
194
- canDeny: leaf(
195
- "judge.canDeny",
196
- DEFAULT_JUDGE.canDeny,
197
- boolean,
198
- "Judge may deny",
199
- "Let the judge block a call instead of asking you.",
200
- JUDGE,
248
+ judgeFallback,
201
249
  ),
202
- whenItFails: leaf<JudgeFallback>(
250
+ whenItFails: hiddenLeaf<JudgeFallback>(
203
251
  "judge.whenItFails",
204
252
  DEFAULT_JUDGE.whenItFails,
205
- literal("ask", "allow", "deny"),
206
- "When the judge fails",
207
- "What to do when the judge errors or times out.",
208
- JUDGE,
209
- ),
210
- noUI: leaf(
211
- "judge.noUI",
212
- DEFAULT_JUDGE.noUI,
213
- boolean,
214
- "Judge without a dialog",
215
- "Run the judge even where there is nobody to ask.",
216
- JUDGE,
217
- ),
218
- dryRun: leaf(
219
- "judge.dryRun",
220
- DEFAULT_JUDGE.dryRun,
221
- boolean,
222
- "Judge dry run",
223
- "Ask the judge and log the verdict, deciding nothing.",
224
- JUDGE,
253
+ judgeFallback,
225
254
  ),
226
- rememberApprovals: leaf(
255
+ noUI: hiddenLeaf("judge.noUI", DEFAULT_JUDGE.noUI, boolean),
256
+ rememberApprovals: hiddenLeaf(
227
257
  "judge.rememberApprovals",
228
258
  DEFAULT_JUDGE.rememberApprovals,
229
259
  boolean,
230
- "Remember judge approvals",
231
- "Treat an always-yes as a rule for the judge later.",
232
- JUDGE,
233
- ),
234
- timeoutMs: leaf(
235
- "judge.timeoutMs",
236
- DEFAULT_JUDGE.timeoutMs,
237
- duration,
238
- "Judge timeout",
239
- "How long to wait before the call comes to you.",
240
- JUDGE,
241
- ),
242
- cache: leaf(
243
- "judge.cache",
244
- DEFAULT_JUDGE.cache,
245
- boolean,
246
- "Judge cache",
247
- "Reuse a verdict for the same call in one session.",
248
- JUDGE,
249
- ),
250
- policy: leaf(
251
- "judge.policy",
252
- DEFAULT_JUDGE.policy,
253
- string,
254
- "Judge policy",
255
- "The rulebook the judge reads. Plain English.",
256
- JUDGE,
257
260
  ),
261
+ timeoutMs: hiddenLeaf("judge.timeoutMs", DEFAULT_JUDGE.timeoutMs, duration),
262
+ cache: hiddenLeaf("judge.cache", DEFAULT_JUDGE.cache, boolean),
258
263
  };
259
264
 
265
+ /** The numbers a rigor stands for, when the settings file sets none of them itself. */
266
+ const RIGOR_NUMBERS = [
267
+ JUDGE_LEAVES.allowThreshold,
268
+ JUDGE_LEAVES.denyThreshold,
269
+ JUDGE_LEAVES.riskCeiling,
270
+ ] as const;
271
+
272
+ /** The rigor in force, or `custom` when the settings file sets the numbers by hand. */
273
+ export function readRigor(scope: SettingsScope): JudgeRigor | "custom" {
274
+ if (RIGOR_NUMBERS.some((entry) => isSetByHand(scope, entry))) return "custom";
275
+ return JUDGE_LEAVES.rigor.get(scope);
276
+ }
277
+
278
+ /** Picks a rigor and drops the numbers set by hand, which would otherwise win over it. */
279
+ export function writeRigor(scope: SettingsScope, rigor: JudgeRigor): string | undefined {
280
+ for (const entry of RIGOR_NUMBERS) {
281
+ if (!isSetByHand(scope, entry)) continue;
282
+ const failure = scope.settings.unset(entry, "global");
283
+ if (failure !== undefined) return failure;
284
+ }
285
+ return scope.settings.set(JUDGE_LEAVES.rigor, rigor);
286
+ }
287
+
288
+ export function readDryRun(scope: SettingsScope): boolean {
289
+ return JUDGE_LEAVES.dryRun.get(scope);
290
+ }
291
+
292
+ export function writeDryRun(scope: SettingsScope, dryRun: boolean): string | undefined {
293
+ return scope.settings.set(JUDGE_LEAVES.dryRun, dryRun);
294
+ }
295
+
260
296
  export const PERMISSION_SETTINGS: readonly Setting<unknown>[] = [
261
297
  ...Object.values(PERMISSION_LEAVES),
262
298
  ...Object.values(JUDGE_LEAVES),
263
299
  ];
264
300
 
265
- /** A fresh object every read, so a caller that mutates one cannot change the defaults. */
301
+ /** A fresh object every read, so a caller can mutate it. */
266
302
  export function readConfig(scope: SettingsScope): PermissionConfig {
267
303
  const leaves = PERMISSION_LEAVES;
268
304
  const judge = JUDGE_LEAVES;
305
+ const rigor = RIGOR[judge.rigor.get(scope)];
269
306
 
270
307
  return {
271
308
  allow: [...leaves.allow.get(scope)],
@@ -275,14 +312,15 @@ export function readConfig(scope: SettingsScope): PermissionConfig {
275
312
  readOnlyBash: leaves.readOnlyBash.get(scope),
276
313
  workspace: workspaceOf(scope),
277
314
  typing: typingOf(scope),
315
+ mcp: { servers: { ...leaves.servers.get(scope) } },
278
316
  judge: {
279
- enabled: judge.enabled.get(scope),
280
- provider: judge.provider.get(scope),
281
317
  model: judge.model.get(scope),
282
- tools: [...judge.tools.get(scope)],
283
318
  alwaysAsk: [...judge.alwaysAsk.get(scope)],
284
- thresholds: { allow: judge.allowThreshold.get(scope), deny: judge.denyThreshold.get(scope) },
285
- riskCeiling: judge.riskCeiling.get(scope),
319
+ thresholds: {
320
+ allow: byHand(scope, judge.allowThreshold) ?? rigor.thresholds.allow,
321
+ deny: byHand(scope, judge.denyThreshold) ?? rigor.thresholds.deny,
322
+ },
323
+ riskCeiling: byHand(scope, judge.riskCeiling) ?? rigor.riskCeiling,
286
324
  whenUnsure: judge.whenUnsure.get(scope),
287
325
  canDeny: judge.canDeny.get(scope),
288
326
  whenItFails: judge.whenItFails.get(scope),
@@ -300,10 +338,23 @@ export function writeConfig(scope: SettingsScope, config: PermissionConfig): str
300
338
  return scope.settings.setAll(toEntries(config));
301
339
  }
302
340
 
303
- /**
304
- * Moves the pre-4.0 config.json into the shared settings and keeps it as a `.bak`. A value that
305
- * already reads the same is not written, so migrating a default config leaves no keys behind.
306
- */
341
+ /** The policy one MCP server follows, keeping the others. */
342
+ export function setMcpPolicy(
343
+ scope: SettingsScope,
344
+ server: string,
345
+ policy: McpPolicy,
346
+ ): string | undefined {
347
+ const entry = PERMISSION_LEAVES.servers;
348
+ const servers: Record<string, McpPolicy> = {};
349
+ // A name that pi sanitized one way and a hand edit wrote the other way are one server.
350
+ for (const [name, value] of Object.entries(entry.get(scope))) {
351
+ if (!sameServer(name, server)) servers[name] = value;
352
+ }
353
+ servers[server] = policy;
354
+ return scope.settings.set(entry, servers);
355
+ }
356
+
357
+ /** Moves the 3.x config.json into the shared settings and keeps it as a `.bak`. */
307
358
  export function migrateConfig(scope: SettingsScope): string[] {
308
359
  const path = configPath();
309
360
  if (!existsSync(path)) return [];
@@ -322,6 +373,49 @@ export function migrateConfig(scope: SettingsScope): string[] {
322
373
  return [...loaded.warnings, `moved the config to the shared settings, keeping ${backup}`];
323
374
  }
324
375
 
376
+ // Keys an older version read and this one ignores. Left in the file, they look like they work.
377
+ const RETIRED_JUDGE_KEYS: Record<string, string> = {
378
+ enabled: "pick the judge mode with Alt+M, or under New sessions",
379
+ tools: "the judge mode judges every call that is not a read or an edit",
380
+ provider: "the model name picks the provider",
381
+ };
382
+
383
+ /** Removes the retired judge keys from the global file, and says what each one became. */
384
+ export function dropRetiredKeys(scope: SettingsScope): string[] {
385
+ const path = scope.settings.globalPath;
386
+ const { data } = readSettingsFile(path);
387
+ const permission = isObject(data.permission) ? data.permission : undefined;
388
+ const judge = isObject(permission?.judge) ? permission.judge : undefined;
389
+ if (permission === undefined || judge === undefined) return [];
390
+
391
+ const retired = Object.keys(RETIRED_JUDGE_KEYS).filter((key) => key in judge);
392
+ if (retired.length === 0) return [];
393
+
394
+ // In 3.x a judge switched on with no mode meant the judge mode, so new sessions keep starting in it.
395
+ const switchedOn = judge.enabled === true && permission.mode === undefined;
396
+ if (switchedOn) permission.mode = "judge";
397
+ for (const key of retired) delete judge[key];
398
+
399
+ try {
400
+ writeSettingsFile(path, data);
401
+ } catch (error) {
402
+ return [`could not remove the retired judge keys from ${path}: ${describeError(error)}`];
403
+ }
404
+ scope.settings.load(scope.settings.projectPath);
405
+
406
+ const lines = retired.map((key) => `removed judge.${key}, ${RETIRED_JUDGE_KEYS[key]}`);
407
+ if (switchedOn) lines.push("new sessions start in the judge mode, as judge.enabled said");
408
+ return lines;
409
+ }
410
+
411
+ function isSetByHand(scope: SettingsScope, entry: Setting<unknown>): boolean {
412
+ return scope.settings.layer(entry) !== "default";
413
+ }
414
+
415
+ function byHand<T>(scope: SettingsScope, entry: Setting<T>): T | undefined {
416
+ return isSetByHand(scope, entry) ? entry.get(scope) : undefined;
417
+ }
418
+
325
419
  function workspaceOf(scope: SettingsScope): WorkspaceConfig {
326
420
  return {
327
421
  roots: [...PERMISSION_LEAVES.roots.get(scope)],
@@ -347,12 +441,10 @@ function toEntries(config: PermissionConfig): (readonly [Setting<unknown>, unkno
347
441
  [leaves.readOnlyBash, config.readOnlyBash],
348
442
  [leaves.roots, config.workspace.roots],
349
443
  [leaves.outside, config.workspace.outside],
444
+ [leaves.servers, config.mcp.servers],
350
445
  [leaves.pause, config.typing.pause],
351
446
  [leaves.maxWait, config.typing.maxWait],
352
- [judge.enabled, config.judge.enabled],
353
- [judge.provider, config.judge.provider],
354
447
  [judge.model, config.judge.model],
355
- [judge.tools, config.judge.tools],
356
448
  [judge.alwaysAsk, config.judge.alwaysAsk],
357
449
  [judge.allowThreshold, config.judge.thresholds.allow],
358
450
  [judge.denyThreshold, config.judge.thresholds.deny],
@@ -1,6 +1,5 @@
1
- // Where 3.x kept its files. The config is read once by the migration and then retired to a `.bak`.
2
- // Grants stay here, because they are state and not settings, and a project grant has to live in the
3
- // project it belongs to.
1
+ // The 3.x config.json, which only the migration reads. Always yes files stay here: they are state,
2
+ // and a project's has to live in that project.
4
3
  import { existsSync, readFileSync } from "node:fs";
5
4
  import { join } from "node:path";
6
5
 
@@ -9,6 +8,7 @@ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
9
8
  import { ALWAYS_YES_FILE } from "#core/always-yes.ts";
10
9
  import { decodeConfig } from "#core/config/decode.ts";
11
10
  import { defaultConfig, type PermissionConfig } from "#core/config/schema.ts";
11
+ import { FOLDERS_FILE } from "#core/folders.ts";
12
12
  import { CONFIG_DIR } from "#identity";
13
13
  import { describe, isRecord } from "#util/primitives.ts";
14
14
 
@@ -30,7 +30,14 @@ export function projectAlwaysYesPath(cwd: string): string {
30
30
  return join(cwd, CONFIG_DIR_NAME, "extensions", CONFIG_DIR, ALWAYS_YES_FILE);
31
31
  }
32
32
 
33
- /** Reads the config.json of 3.x. Only the migration reads it, and only while the file exists. */
33
+ export function globalFoldersPath(): string {
34
+ return join(getAgentDir(), "extensions", CONFIG_DIR, FOLDERS_FILE);
35
+ }
36
+
37
+ export function projectFoldersPath(cwd: string): string {
38
+ return join(cwd, CONFIG_DIR_NAME, "extensions", CONFIG_DIR, FOLDERS_FILE);
39
+ }
40
+
34
41
  export function readLegacyConfig(): LegacyConfig {
35
42
  const path = configPath();
36
43
  if (!existsSync(path)) return { config: defaultConfig(), path, warnings: [] };