@adeildo/pi-ask-permission 4.1.0 → 5.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.
- package/README.md +181 -67
- package/package.json +2 -2
- package/src/core/answer.ts +8 -0
- package/src/core/config/decode.ts +40 -6
- package/src/core/config/patterns.ts +0 -6
- package/src/core/config/schema.ts +13 -0
- package/src/core/config/settings.ts +318 -226
- package/src/core/config/store.ts +11 -4
- package/src/core/decide.ts +131 -19
- package/src/core/folders.ts +159 -0
- package/src/core/judge/backends/factory.ts +2 -2
- package/src/core/judge/backends/pi-model.ts +2 -1
- package/src/core/judge/compose.ts +0 -1
- package/src/core/judge/config.ts +34 -11
- package/src/core/judge/decode.ts +0 -3
- package/src/core/judge/gate.ts +9 -4
- package/src/core/judge/pipeline.ts +7 -4
- package/src/core/judge/policy.ts +1 -1
- package/src/core/judge/report.ts +5 -4
- package/src/core/judge/request.ts +19 -4
- package/src/core/judge/types.ts +11 -0
- package/src/core/mcp.ts +130 -0
- package/src/core/mode.ts +47 -25
- package/src/core/readonly-bash.ts +167 -4
- package/src/core/tools.ts +7 -3
- package/src/core/workspace.ts +107 -16
- package/src/index.ts +15 -2
- package/src/pi/commands.ts +13 -185
- package/src/pi/events.ts +87 -28
- package/src/pi/intent.ts +34 -0
- package/src/pi/mode.ts +63 -16
- package/src/pi/screen.ts +293 -0
- package/src/pi/session-entries.ts +46 -8
- package/src/pi/session.ts +52 -11
- package/src/ui/decision-options.ts +58 -12
- package/src/ui/dialog.ts +135 -23
- package/src/ui/judge-entry.ts +6 -16
- package/src/ui/questions.ts +170 -0
- package/src/ui/selector.ts +13 -5
- package/src/ui/picker.ts +0 -90
- package/src/ui/settings/judge.ts +0 -299
- package/src/ui/settings/screen.ts +0 -268
- package/src/ui/settings/status.ts +0 -53
|
@@ -1,24 +1,27 @@
|
|
|
1
|
-
//
|
|
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 {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"
|
|
82
|
-
|
|
83
|
-
),
|
|
84
|
-
|
|
85
|
-
"
|
|
86
|
-
DEFAULT_CONFIG.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
"
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
"
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
123
|
-
"judge.
|
|
124
|
-
DEFAULT_JUDGE.
|
|
125
|
-
|
|
126
|
-
"Judge
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
"
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
"
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
"
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
),
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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:
|
|
250
|
+
whenItFails: hiddenLeaf<JudgeFallback>(
|
|
203
251
|
"judge.whenItFails",
|
|
204
252
|
DEFAULT_JUDGE.whenItFails,
|
|
205
|
-
|
|
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
|
-
|
|
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
|
|
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: {
|
|
285
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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],
|
package/src/core/config/store.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
-
|
|
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: [] };
|