@adeildo/pi-ask-permission 4.0.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 (53) hide show
  1. package/CONTRIBUTING.md +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +274 -0
  4. package/assets/preview.png +0 -0
  5. package/package.json +63 -0
  6. package/src/core/always-yes.ts +183 -0
  7. package/src/core/answer.ts +8 -0
  8. package/src/core/config/decode.ts +118 -0
  9. package/src/core/config/patterns.ts +51 -0
  10. package/src/core/config/schema.ts +72 -0
  11. package/src/core/config/settings.ts +374 -0
  12. package/src/core/config/store.ts +57 -0
  13. package/src/core/decide.ts +106 -0
  14. package/src/core/judge/backends/factory.ts +30 -0
  15. package/src/core/judge/backends/jev.ts +227 -0
  16. package/src/core/judge/backends/pi-model.ts +174 -0
  17. package/src/core/judge/compose.ts +91 -0
  18. package/src/core/judge/config.ts +60 -0
  19. package/src/core/judge/decode.ts +51 -0
  20. package/src/core/judge/gate.ts +70 -0
  21. package/src/core/judge/pipeline.ts +106 -0
  22. package/src/core/judge/policy.ts +114 -0
  23. package/src/core/judge/probe.ts +51 -0
  24. package/src/core/judge/report.ts +73 -0
  25. package/src/core/judge/request.ts +78 -0
  26. package/src/core/judge/types.ts +72 -0
  27. package/src/core/mode.ts +42 -0
  28. package/src/core/readonly-bash.ts +689 -0
  29. package/src/core/tools.ts +208 -0
  30. package/src/core/workspace.ts +66 -0
  31. package/src/identity.ts +5 -0
  32. package/src/index.ts +29 -0
  33. package/src/pi/api.ts +44 -0
  34. package/src/pi/bash-timer.ts +49 -0
  35. package/src/pi/commands.ts +193 -0
  36. package/src/pi/events.ts +260 -0
  37. package/src/pi/mode.ts +48 -0
  38. package/src/pi/preview.ts +82 -0
  39. package/src/pi/provider.ts +11 -0
  40. package/src/pi/session-entries.ts +74 -0
  41. package/src/pi/session.ts +129 -0
  42. package/src/ui/clipboard.ts +51 -0
  43. package/src/ui/decision-options.ts +23 -0
  44. package/src/ui/dialog.ts +395 -0
  45. package/src/ui/judge-entry.ts +130 -0
  46. package/src/ui/paste.ts +27 -0
  47. package/src/ui/picker.ts +90 -0
  48. package/src/ui/selector.ts +42 -0
  49. package/src/ui/settings/judge.ts +299 -0
  50. package/src/ui/settings/screen.ts +268 -0
  51. package/src/ui/settings/status.ts +53 -0
  52. package/src/ui/typing.ts +72 -0
  53. package/src/util/primitives.ts +11 -0
@@ -0,0 +1,118 @@
1
+ import {
2
+ boolean,
3
+ type Decoder,
4
+ duration,
5
+ fail,
6
+ fieldPath,
7
+ formatProblems,
8
+ isObject,
9
+ literal,
10
+ nullable,
11
+ object,
12
+ pass,
13
+ problem,
14
+ type Problem,
15
+ stringList,
16
+ withDefault,
17
+ withDefaultOf,
18
+ } from "@adeildo/pi-kit";
19
+
20
+ import {
21
+ DEFAULT_CONFIG,
22
+ DEFAULT_TYPING,
23
+ DEFAULT_WORKSPACE,
24
+ defaultConfig,
25
+ type NoUIMode,
26
+ type PermissionConfig,
27
+ type TypingConfig,
28
+ type WorkspaceConfig,
29
+ } from "#core/config/schema.ts";
30
+ import { defaultJudge } from "#core/judge/config.ts";
31
+ import { judgeConfig } from "#core/judge/decode.ts";
32
+ import { DEFAULT_MODE } from "#core/mode.ts";
33
+
34
+ export type NoUIConfig = NoUIMode | Record<string, NoUIMode>;
35
+
36
+ export const noUI: Decoder<NoUIConfig> = {
37
+ decode(input, path) {
38
+ if (input === "allow" || input === "deny") return pass(input);
39
+ if (!isObject(input)) return fail(problem(path, 'expected "allow", "deny", or a per-tool map'));
40
+
41
+ const problems: Problem[] = [];
42
+ const value: Record<string, NoUIMode> = {};
43
+ for (const [tool, entry] of Object.entries(input)) {
44
+ if (entry === "allow" || entry === "deny") value[tool] = entry;
45
+ else problems.push(problem(fieldPath(path, tool), 'expected "allow" or "deny"'));
46
+ }
47
+ return pass(value, problems);
48
+ },
49
+ };
50
+
51
+ const typing: Decoder<TypingConfig> = object({
52
+ pause: withDefault(duration, DEFAULT_TYPING.pause),
53
+ maxWait: withDefault(nullable(duration), DEFAULT_TYPING.maxWait),
54
+ });
55
+
56
+ const workspace: Decoder<WorkspaceConfig> = object({
57
+ roots: withDefaultOf(stringList("paths"), () => [...DEFAULT_WORKSPACE.roots]),
58
+ outside: withDefault(literal("ask", "deny", "allow"), DEFAULT_WORKSPACE.outside),
59
+ });
60
+
61
+ const config: Decoder<PermissionConfig> = object({
62
+ allow: withDefaultOf(stringList("tool names"), () => [...DEFAULT_CONFIG.allow]),
63
+ noUI: withDefaultOf(noUI, () => DEFAULT_CONFIG.noUI),
64
+ notes: withDefault(literal("result", "message"), DEFAULT_CONFIG.notes),
65
+ mode: withDefault(literal("manual", "accept-edits", "auto"), DEFAULT_MODE),
66
+ readOnlyBash: withDefault(boolean, DEFAULT_CONFIG.readOnlyBash),
67
+ workspace: withDefaultOf(workspace, () => ({
68
+ ...DEFAULT_WORKSPACE,
69
+ roots: [...DEFAULT_WORKSPACE.roots],
70
+ })),
71
+ typing: withDefaultOf(typing, () => ({ ...DEFAULT_TYPING })),
72
+ judge: withDefaultOf(judgeConfig, defaultJudge),
73
+ });
74
+
75
+ export function decodeConfig(input: unknown, warnings: string[] = []): PermissionConfig {
76
+ const result = config.decode(migrate(input, warnings), "");
77
+ warnings.push(...formatProblems(result.problems));
78
+
79
+ return result.ok ? result.value : defaultConfig();
80
+ }
81
+
82
+ const RENAMED: [section: "judge" | undefined, from: string, to: string][] = [
83
+ [undefined, "followup", "notes"],
84
+ [undefined, "headless", "noUI"],
85
+ ["judge", "backend", "provider"],
86
+ ["judge", "autoDeny", "canDeny"],
87
+ ["judge", "onUncertain", "whenUnsure"],
88
+ ["judge", "onError", "whenItFails"],
89
+ ["judge", "never", "alwaysAsk"],
90
+ ["judge", "headless", "noUI"],
91
+ ["judge", "grant", "rememberApprovals"],
92
+ ];
93
+
94
+ // A rename keeps the value and says nothing. The migration writes the new names.
95
+ function migrate(input: unknown, warnings: string[]): unknown {
96
+ if (!isObject(input)) return input;
97
+
98
+ const next: Record<string, unknown> = { ...input };
99
+ if (isObject(next.judge)) next.judge = { ...next.judge };
100
+
101
+ for (const [section, from, to] of RENAMED) {
102
+ const target = section === undefined ? next : next[section];
103
+ if (!isObject(target) || !(from in target)) continue;
104
+
105
+ if (!(to in target)) target[to] = target[from];
106
+ delete target[from];
107
+ }
108
+
109
+ if (next.mode === "yolo") {
110
+ next.mode = "auto";
111
+ warnings.push('mode "yolo" is now "auto", with workspace.outside "allow" for the same reach');
112
+ }
113
+ if ("yolo" in next) {
114
+ delete next.yolo;
115
+ warnings.push("yolo is gone, the mode is picked per session");
116
+ }
117
+ return next;
118
+ }
@@ -0,0 +1,51 @@
1
+ import type { NoUIMode, PermissionConfig } from "#core/config/schema.ts";
2
+
3
+ const patternCache = new Map<string, RegExp>();
4
+
5
+ export function matchesPattern(pattern: string, value: string): boolean {
6
+ if (pattern === "*") return true;
7
+
8
+ let compiled = patternCache.get(pattern);
9
+ if (!compiled) {
10
+ const escaped = pattern
11
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
12
+ .replace(/\*/g, ".*")
13
+ .replace(/\?/g, ".");
14
+ compiled = new RegExp(`^${escaped}$`);
15
+ patternCache.set(pattern, compiled);
16
+ }
17
+ return compiled.test(value);
18
+ }
19
+
20
+ export function isAllowed(config: PermissionConfig, toolName: string): boolean {
21
+ return config.allow.some((pattern) => matchesPattern(pattern, toolName));
22
+ }
23
+
24
+ export function isJudged(config: PermissionConfig, toolName: string): boolean {
25
+ return (
26
+ config.judge.enabled && config.judge.tools.some((pattern) => matchesPattern(pattern, toolName))
27
+ );
28
+ }
29
+
30
+ export function noUIMode(config: PermissionConfig, toolName: string): NoUIMode {
31
+ if (typeof config.noUI === "string") return config.noUI;
32
+
33
+ let bestScore = 0;
34
+ let best: NoUIMode | undefined;
35
+ for (const [pattern, value] of Object.entries(config.noUI)) {
36
+ if (!matchesPattern(pattern, toolName)) continue;
37
+
38
+ const score = specificity(pattern);
39
+ if (score >= bestScore) {
40
+ bestScore = score;
41
+ best = value;
42
+ }
43
+ }
44
+ return best ?? "deny";
45
+ }
46
+
47
+ function specificity(pattern: string): number {
48
+ if (pattern === "*") return 1;
49
+ if (pattern.includes("*") || pattern.includes("?")) return 2;
50
+ return 3;
51
+ }
@@ -0,0 +1,72 @@
1
+ import { DEFAULT_JUDGE, defaultJudge, type JudgeConfig } from "#core/judge/config.ts";
2
+ import { DEFAULT_MODE, type PermissionMode } from "#core/mode.ts";
3
+
4
+ export type NoUIMode = "allow" | "deny";
5
+
6
+ export type NoteDelivery = "result" | "message";
7
+
8
+ export type OutsideScope = "ask" | "deny" | "allow";
9
+
10
+ export interface WorkspaceConfig {
11
+ roots: string[];
12
+ outside: OutsideScope;
13
+ }
14
+
15
+ export interface TypingConfig {
16
+ pause: number;
17
+ maxWait: number | null;
18
+ }
19
+
20
+ export interface PermissionConfig {
21
+ allow: string[];
22
+ noUI: NoUIMode | Record<string, NoUIMode>;
23
+ notes: NoteDelivery;
24
+ mode: PermissionMode;
25
+ readOnlyBash: boolean;
26
+ workspace: WorkspaceConfig;
27
+ typing: TypingConfig;
28
+ judge: JudgeConfig;
29
+ }
30
+
31
+ export const DEFAULT_TYPING: TypingConfig = {
32
+ pause: 1000,
33
+ maxWait: null,
34
+ };
35
+
36
+ export const DEFAULT_WORKSPACE: WorkspaceConfig = {
37
+ roots: ["."],
38
+ outside: "ask",
39
+ };
40
+
41
+ export const DEFAULT_CONFIG: PermissionConfig = {
42
+ allow: ["read", "grep", "find", "ls"],
43
+ noUI: "deny",
44
+ notes: "result",
45
+ mode: DEFAULT_MODE,
46
+ readOnlyBash: true,
47
+ workspace: DEFAULT_WORKSPACE,
48
+ typing: DEFAULT_TYPING,
49
+ judge: DEFAULT_JUDGE,
50
+ };
51
+
52
+ export function defaultConfig(): PermissionConfig {
53
+ return {
54
+ ...DEFAULT_CONFIG,
55
+ allow: [...DEFAULT_CONFIG.allow],
56
+ workspace: { ...DEFAULT_WORKSPACE, roots: [...DEFAULT_WORKSPACE.roots] },
57
+ typing: { ...DEFAULT_CONFIG.typing },
58
+ judge: defaultJudge(),
59
+ };
60
+ }
61
+
62
+ export function isNoUIMode(value: unknown): value is NoUIMode {
63
+ return value === "allow" || value === "deny";
64
+ }
65
+
66
+ export function isNoteDelivery(value: unknown): value is NoteDelivery {
67
+ return value === "result" || value === "message";
68
+ }
69
+
70
+ export function isOutsideScope(value: unknown): value is OutsideScope {
71
+ return value === "ask" || value === "deny" || value === "allow";
72
+ }
@@ -0,0 +1,374 @@
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.
4
+ import { existsSync, renameSync } from "node:fs";
5
+
6
+ import {
7
+ boolean,
8
+ type Decoder,
9
+ duration,
10
+ literal,
11
+ nullable,
12
+ type Setting,
13
+ setting,
14
+ type SettingsScope,
15
+ string,
16
+ stringList,
17
+ trimmedString,
18
+ unit,
19
+ } from "@adeildo/pi-kit";
20
+
21
+ import { type NoUIConfig, noUI } from "#core/config/decode.ts";
22
+ import {
23
+ DEFAULT_CONFIG,
24
+ DEFAULT_TYPING,
25
+ DEFAULT_WORKSPACE,
26
+ type NoteDelivery,
27
+ type OutsideScope,
28
+ type PermissionConfig,
29
+ type TypingConfig,
30
+ type WorkspaceConfig,
31
+ } from "#core/config/schema.ts";
32
+ 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
+ });
53
+ }
54
+
55
+ 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
+ ),
119
+ };
120
+
121
+ 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>(
187
+ "judge.whenUnsure",
188
+ 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,
201
+ ),
202
+ whenItFails: leaf<JudgeFallback>(
203
+ "judge.whenItFails",
204
+ 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,
225
+ ),
226
+ rememberApprovals: leaf(
227
+ "judge.rememberApprovals",
228
+ DEFAULT_JUDGE.rememberApprovals,
229
+ 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
+ ),
258
+ };
259
+
260
+ export const PERMISSION_SETTINGS: readonly Setting<unknown>[] = [
261
+ ...Object.values(PERMISSION_LEAVES),
262
+ ...Object.values(JUDGE_LEAVES),
263
+ ];
264
+
265
+ /** A fresh object every read, so a caller that mutates one cannot change the defaults. */
266
+ export function readConfig(scope: SettingsScope): PermissionConfig {
267
+ const leaves = PERMISSION_LEAVES;
268
+ const judge = JUDGE_LEAVES;
269
+
270
+ return {
271
+ allow: [...leaves.allow.get(scope)],
272
+ noUI: leaves.noUI.get(scope),
273
+ notes: leaves.notes.get(scope),
274
+ mode: leaves.mode.get(scope),
275
+ readOnlyBash: leaves.readOnlyBash.get(scope),
276
+ workspace: workspaceOf(scope),
277
+ typing: typingOf(scope),
278
+ judge: {
279
+ enabled: judge.enabled.get(scope),
280
+ provider: judge.provider.get(scope),
281
+ model: judge.model.get(scope),
282
+ tools: [...judge.tools.get(scope)],
283
+ alwaysAsk: [...judge.alwaysAsk.get(scope)],
284
+ thresholds: { allow: judge.allowThreshold.get(scope), deny: judge.denyThreshold.get(scope) },
285
+ riskCeiling: judge.riskCeiling.get(scope),
286
+ whenUnsure: judge.whenUnsure.get(scope),
287
+ canDeny: judge.canDeny.get(scope),
288
+ whenItFails: judge.whenItFails.get(scope),
289
+ noUI: judge.noUI.get(scope),
290
+ dryRun: judge.dryRun.get(scope),
291
+ rememberApprovals: judge.rememberApprovals.get(scope),
292
+ timeoutMs: judge.timeoutMs.get(scope),
293
+ cache: judge.cache.get(scope),
294
+ policy: judge.policy.get(scope),
295
+ },
296
+ };
297
+ }
298
+
299
+ export function writeConfig(scope: SettingsScope, config: PermissionConfig): string | undefined {
300
+ return scope.settings.setAll(toEntries(config));
301
+ }
302
+
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
+ */
307
+ export function migrateConfig(scope: SettingsScope): string[] {
308
+ const path = configPath();
309
+ if (!existsSync(path)) return [];
310
+
311
+ const loaded = readLegacyConfig();
312
+ const failure = writeConfig(scope, loaded.config);
313
+ if (failure !== undefined)
314
+ return [...loaded.warnings, `could not move ${path} to the shared settings: ${failure}`];
315
+
316
+ const backup = `${path}.bak`;
317
+ try {
318
+ renameSync(path, backup);
319
+ } catch (error) {
320
+ return [...loaded.warnings, `could not keep a copy of ${path}: ${describeError(error)}`];
321
+ }
322
+ return [...loaded.warnings, `moved the config to the shared settings, keeping ${backup}`];
323
+ }
324
+
325
+ function workspaceOf(scope: SettingsScope): WorkspaceConfig {
326
+ return {
327
+ roots: [...PERMISSION_LEAVES.roots.get(scope)],
328
+ outside: PERMISSION_LEAVES.outside.get(scope),
329
+ };
330
+ }
331
+
332
+ function typingOf(scope: SettingsScope): TypingConfig {
333
+ return {
334
+ pause: PERMISSION_LEAVES.pause.get(scope),
335
+ maxWait: PERMISSION_LEAVES.maxWait.get(scope),
336
+ };
337
+ }
338
+
339
+ function toEntries(config: PermissionConfig): (readonly [Setting<unknown>, unknown])[] {
340
+ const leaves = PERMISSION_LEAVES;
341
+ const judge = JUDGE_LEAVES;
342
+ return [
343
+ [leaves.allow, config.allow],
344
+ [leaves.noUI, config.noUI],
345
+ [leaves.notes, config.notes],
346
+ [leaves.mode, config.mode],
347
+ [leaves.readOnlyBash, config.readOnlyBash],
348
+ [leaves.roots, config.workspace.roots],
349
+ [leaves.outside, config.workspace.outside],
350
+ [leaves.pause, config.typing.pause],
351
+ [leaves.maxWait, config.typing.maxWait],
352
+ [judge.enabled, config.judge.enabled],
353
+ [judge.provider, config.judge.provider],
354
+ [judge.model, config.judge.model],
355
+ [judge.tools, config.judge.tools],
356
+ [judge.alwaysAsk, config.judge.alwaysAsk],
357
+ [judge.allowThreshold, config.judge.thresholds.allow],
358
+ [judge.denyThreshold, config.judge.thresholds.deny],
359
+ [judge.riskCeiling, config.judge.riskCeiling],
360
+ [judge.whenUnsure, config.judge.whenUnsure],
361
+ [judge.canDeny, config.judge.canDeny],
362
+ [judge.whenItFails, config.judge.whenItFails],
363
+ [judge.noUI, config.judge.noUI],
364
+ [judge.dryRun, config.judge.dryRun],
365
+ [judge.rememberApprovals, config.judge.rememberApprovals],
366
+ [judge.timeoutMs, config.judge.timeoutMs],
367
+ [judge.cache, config.judge.cache],
368
+ [judge.policy, config.judge.policy],
369
+ ];
370
+ }
371
+
372
+ function describeError(error: unknown): string {
373
+ return error instanceof Error ? error.message : String(error);
374
+ }
@@ -0,0 +1,57 @@
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.
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
8
+
9
+ import { ALWAYS_YES_FILE } from "#core/always-yes.ts";
10
+ import { decodeConfig } from "#core/config/decode.ts";
11
+ import { defaultConfig, type PermissionConfig } from "#core/config/schema.ts";
12
+ import { CONFIG_DIR } from "#identity";
13
+ import { describe, isRecord } from "#util/primitives.ts";
14
+
15
+ export interface LegacyConfig {
16
+ config: PermissionConfig;
17
+ path: string;
18
+ warnings: string[];
19
+ }
20
+
21
+ export function configPath(): string {
22
+ return join(getAgentDir(), "extensions", CONFIG_DIR, "config.json");
23
+ }
24
+
25
+ export function globalAlwaysYesPath(): string {
26
+ return join(getAgentDir(), "extensions", CONFIG_DIR, ALWAYS_YES_FILE);
27
+ }
28
+
29
+ export function projectAlwaysYesPath(cwd: string): string {
30
+ return join(cwd, CONFIG_DIR_NAME, "extensions", CONFIG_DIR, ALWAYS_YES_FILE);
31
+ }
32
+
33
+ /** Reads the config.json of 3.x. Only the migration reads it, and only while the file exists. */
34
+ export function readLegacyConfig(): LegacyConfig {
35
+ const path = configPath();
36
+ if (!existsSync(path)) return { config: defaultConfig(), path, warnings: [] };
37
+
38
+ try {
39
+ const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
40
+ if (!isRecord(raw)) {
41
+ return {
42
+ config: defaultConfig(),
43
+ path,
44
+ warnings: [`${path} must contain a JSON object; using the defaults`],
45
+ };
46
+ }
47
+
48
+ const warnings: string[] = [];
49
+ return { config: decodeConfig(raw, warnings), path, warnings };
50
+ } catch (error) {
51
+ return {
52
+ config: defaultConfig(),
53
+ path,
54
+ warnings: [`could not parse ${path}: ${describe(error)}; using the defaults`],
55
+ };
56
+ }
57
+ }