@adeildo/pi-ask-permission 4.0.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/LICENSE +1 -1
- package/README.md +187 -72
- package/package.json +6 -3
- 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
package/src/pi/screen.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import type { Control, FeatureScope, Json, ScreenEntry } from "@adeildo/pi-kit";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import { type Scope, SCOPE_LABEL } from "#core/always-yes.ts";
|
|
5
|
+
import { isMcpPolicy, isOutsideScope } from "#core/config/schema.ts";
|
|
6
|
+
import {
|
|
7
|
+
MCP_POLICY_CONTROL,
|
|
8
|
+
MODE_CONTROL,
|
|
9
|
+
OUTSIDE_CONTROL,
|
|
10
|
+
readDryRun,
|
|
11
|
+
readRigor,
|
|
12
|
+
setMcpPolicy,
|
|
13
|
+
writeDryRun,
|
|
14
|
+
writeRigor,
|
|
15
|
+
} from "#core/config/settings.ts";
|
|
16
|
+
import { globalAlwaysYesPath, projectAlwaysYesPath } from "#core/config/store.ts";
|
|
17
|
+
import { TYPESAFE_PROVIDER } from "#core/judge/backends/jev.ts";
|
|
18
|
+
import {
|
|
19
|
+
describeRigor,
|
|
20
|
+
JUDGE_RIGORS,
|
|
21
|
+
judgeBackendOf,
|
|
22
|
+
type JudgeRigor,
|
|
23
|
+
} from "#core/judge/config.ts";
|
|
24
|
+
import { probeJudge } from "#core/judge/probe.ts";
|
|
25
|
+
import { judgeLogText } from "#core/judge/report.ts";
|
|
26
|
+
import { MCP_POLICY_TEXT, type McpServer, mcpServers, policyFor, sameServer } from "#core/mcp.ts";
|
|
27
|
+
import { parseMode } from "#core/mode.ts";
|
|
28
|
+
import { shortenHome } from "#core/tools.ts";
|
|
29
|
+
import { renderStatus, setSessionMode, setSessionOutside } from "#pi/mode.ts";
|
|
30
|
+
import { closeFolders, forgetAlwaysYes, resetJudgeHealth, type SessionState } from "#pi/session.ts";
|
|
31
|
+
|
|
32
|
+
export function registerScreen(scope: FeatureScope, state: SessionState): void {
|
|
33
|
+
scope.screen.rows(() => mcpRows(scope, state));
|
|
34
|
+
|
|
35
|
+
scope.screen.value({
|
|
36
|
+
id: "session.mode",
|
|
37
|
+
section: "This session",
|
|
38
|
+
label: "Mode",
|
|
39
|
+
description: "Which calls run without asking, until the session ends. Alt+M cycles it.",
|
|
40
|
+
control: MODE_CONTROL,
|
|
41
|
+
get: () => state.mode,
|
|
42
|
+
set: (value, ctx) => {
|
|
43
|
+
const mode = typeof value === "string" ? parseMode(value) : undefined;
|
|
44
|
+
if (mode === undefined) return "not a mode";
|
|
45
|
+
setSessionMode(scope, state, mode, ctx, false);
|
|
46
|
+
return undefined;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
scope.screen.value({
|
|
51
|
+
id: "session.outside",
|
|
52
|
+
section: "This session",
|
|
53
|
+
label: "Outside the workspace",
|
|
54
|
+
description: "What a call outside the workspace does, until the session ends. Alt+W flips it.",
|
|
55
|
+
control: OUTSIDE_CONTROL,
|
|
56
|
+
get: () => state.outside,
|
|
57
|
+
set: (value, ctx) => {
|
|
58
|
+
if (!isOutsideScope(value)) return "not an outside policy";
|
|
59
|
+
setSessionOutside(scope, state, value, ctx, false);
|
|
60
|
+
return undefined;
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
scope.screen.value({
|
|
65
|
+
id: "judge.rigor",
|
|
66
|
+
section: "Judge",
|
|
67
|
+
label: "Rigor",
|
|
68
|
+
description:
|
|
69
|
+
"How sure the judge must be before a call runs without you. Below that, it asks you.",
|
|
70
|
+
meta: "saved for every project",
|
|
71
|
+
control: () => rigorControl(readRigor(scope)),
|
|
72
|
+
get: () => readRigor(scope),
|
|
73
|
+
set: (value) => {
|
|
74
|
+
if (value === "custom")
|
|
75
|
+
return "custom is what judge.thresholds and judge.riskCeiling set by hand";
|
|
76
|
+
if (!isRigor(value)) return "not a rigor";
|
|
77
|
+
return writeRigor(scope, value);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
scope.screen.value({
|
|
82
|
+
id: "judge.dryRun",
|
|
83
|
+
section: "Judge",
|
|
84
|
+
label: "Dry run",
|
|
85
|
+
description:
|
|
86
|
+
"The judge shows its verdict, and you still decide. Try a policy or a rigor this way first.",
|
|
87
|
+
meta: "saved for every project",
|
|
88
|
+
control: { type: "toggle" },
|
|
89
|
+
get: () => readDryRun(scope),
|
|
90
|
+
set: (value) => (typeof value === "boolean" ? writeDryRun(scope, value) : "expected on or off"),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
scope.screen.action({
|
|
94
|
+
id: "judge.test",
|
|
95
|
+
section: "Judge",
|
|
96
|
+
label: "Test the judge",
|
|
97
|
+
description:
|
|
98
|
+
"Sends one real request with the settings above, and reports the model and the time.",
|
|
99
|
+
run: (ctx) => testJudge(state, ctx),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
scope.screen.action({
|
|
103
|
+
id: "judge.log",
|
|
104
|
+
section: "Judge",
|
|
105
|
+
label: "This session's verdicts",
|
|
106
|
+
description: "The last calls the judge decided in this session.",
|
|
107
|
+
text: () => (state.judgeLog.length === 0 ? "none yet" : `${state.judgeLog.length}`),
|
|
108
|
+
run: () => judgeLogText(state.judgeLog),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
for (const where of ["session", "project", "global"] as const) {
|
|
112
|
+
scope.screen.action({
|
|
113
|
+
id: `always-yes.${where}`,
|
|
114
|
+
section: "Always yes",
|
|
115
|
+
label: capitalize(SCOPE_LABEL[where]),
|
|
116
|
+
description: alwaysYesDescription(where),
|
|
117
|
+
text: () => count(state.alwaysYes.size(where)),
|
|
118
|
+
confirm: `Forget every always yes for ${SCOPE_LABEL[where]}?`,
|
|
119
|
+
run: (ctx) => forget(scope, state, ctx, where),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const where of ["session", "project", "global"] as const) {
|
|
124
|
+
scope.screen.action({
|
|
125
|
+
id: `folders.${where}`,
|
|
126
|
+
section: "Folders",
|
|
127
|
+
label: capitalize(SCOPE_LABEL[where]),
|
|
128
|
+
description: `Folders outside the workspace you opened from the dialog, ${SCOPE_LABEL[where]}. A call that reaches only these counts as inside. Enter closes them.`,
|
|
129
|
+
text: () => foldersText(state, where),
|
|
130
|
+
confirm: `Close every folder open for ${SCOPE_LABEL[where]}?`,
|
|
131
|
+
run: (ctx) => close(scope, state, ctx, where),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// One row per server pi is connected to, plus any server the settings still name. The rows are
|
|
137
|
+
// built when the screen opens, because a server connects after the session starts.
|
|
138
|
+
function mcpRows(scope: FeatureScope, state: SessionState): ScreenEntry[] {
|
|
139
|
+
const connected = mcpServers(scope.getAllTools());
|
|
140
|
+
const gone = Object.keys(state.config.mcp.servers).filter(
|
|
141
|
+
(name) => !connected.some((entry) => sameServer(entry.server, name)),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (connected.length === 0 && gone.length === 0) {
|
|
145
|
+
return [
|
|
146
|
+
{
|
|
147
|
+
kind: "info",
|
|
148
|
+
id: "mcp.none",
|
|
149
|
+
section: "MCP servers",
|
|
150
|
+
label: "Servers",
|
|
151
|
+
description:
|
|
152
|
+
"The MCP servers pi connects to. Each one follows the policy set here, whatever the mode.",
|
|
153
|
+
text: () => "none connected",
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return [
|
|
159
|
+
...connected.map((entry) => mcpRow(scope, state, entry.server, entry)),
|
|
160
|
+
...gone.map((server) => mcpRow(scope, state, server)),
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function mcpRow(
|
|
165
|
+
scope: FeatureScope,
|
|
166
|
+
state: SessionState,
|
|
167
|
+
server: string,
|
|
168
|
+
connected?: McpServer,
|
|
169
|
+
): ScreenEntry {
|
|
170
|
+
const policy = policyFor(state.config.mcp.servers, server);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
kind: "value",
|
|
174
|
+
id: `mcp.${server}`,
|
|
175
|
+
section: "MCP servers",
|
|
176
|
+
label: server,
|
|
177
|
+
description: `${describeServer(connected)}. ${capitalize(MCP_POLICY_TEXT[policy].description)}.`,
|
|
178
|
+
meta: "saved for every project",
|
|
179
|
+
control: MCP_POLICY_CONTROL,
|
|
180
|
+
get: () => policy,
|
|
181
|
+
set: (value: Json) => {
|
|
182
|
+
if (!isMcpPolicy(value)) return "not an MCP policy";
|
|
183
|
+
return setMcpPolicy(scope, server, value);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function describeServer(server: McpServer | undefined): string {
|
|
189
|
+
if (server === undefined) return "not connected now, so the policy waits";
|
|
190
|
+
const tools = server.tools === 1 ? "1 tool" : `${server.tools} tools`;
|
|
191
|
+
return `${tools}, ${server.reads} that read, ${server.destructive} destructive`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isRigor(value: Json): value is JudgeRigor {
|
|
195
|
+
return JUDGE_RIGORS.some((rigor) => rigor === value);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// `custom` is listed only while it is the value, so the row can show it.
|
|
199
|
+
function rigorControl(current: JudgeRigor | "custom"): Control {
|
|
200
|
+
const options: { value: string; description: string }[] = JUDGE_RIGORS.map((value) => ({
|
|
201
|
+
value,
|
|
202
|
+
description: describeRigor(value),
|
|
203
|
+
}));
|
|
204
|
+
if (current === "custom")
|
|
205
|
+
options.push({
|
|
206
|
+
value: "custom",
|
|
207
|
+
description:
|
|
208
|
+
"the settings file sets judge.thresholds or judge.riskCeiling. Pick a rigor to drop them",
|
|
209
|
+
});
|
|
210
|
+
return { type: "choice", options };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// With one folder open, the row names it.
|
|
214
|
+
function foldersText(state: SessionState, where: Scope): string {
|
|
215
|
+
const [first, ...rest] = state.folders.list(where);
|
|
216
|
+
if (first === undefined) return "none";
|
|
217
|
+
if (rest.length > 0) return `${rest.length + 1} folders`;
|
|
218
|
+
return first.access === "read" ? `${shortenHome(first.path)}, reads` : shortenHome(first.path);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function close(
|
|
222
|
+
scope: FeatureScope,
|
|
223
|
+
state: SessionState,
|
|
224
|
+
ctx: ExtensionContext,
|
|
225
|
+
where: Scope,
|
|
226
|
+
): string | undefined {
|
|
227
|
+
const removed = closeFolders(scope, state, ctx, where);
|
|
228
|
+
if (removed === 0) throw new Error(`no folder open for ${SCOPE_LABEL[where]}`);
|
|
229
|
+
renderStatus(ctx, state);
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function testJudge(state: SessionState, ctx: ExtensionContext): Promise<string> {
|
|
234
|
+
const judge = state.config.judge;
|
|
235
|
+
const probe = await probeJudge(
|
|
236
|
+
judge,
|
|
237
|
+
{
|
|
238
|
+
resolveApiKey: () => ctx.modelRegistry.getApiKeyForProvider(TYPESAFE_PROVIDER),
|
|
239
|
+
modelRegistry: ctx.modelRegistry,
|
|
240
|
+
},
|
|
241
|
+
ctx.signal,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
if (!probe.ok) {
|
|
245
|
+
const auth = ctx.modelRegistry.getProviderAuthStatus(TYPESAFE_PROVIDER);
|
|
246
|
+
const key = auth.configured
|
|
247
|
+
? `key from ${auth.label ?? auth.source}`
|
|
248
|
+
: "no key, run /login typesafe";
|
|
249
|
+
throw new Error(
|
|
250
|
+
`the judge failed: ${probe.detail}${judgeBackendOf(judge.model) === "jev" ? ` (${key})` : ""}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
resetJudgeHealth(state);
|
|
255
|
+
const lines = [`The judge answered in ${probe.elapsedMs}ms, with ${probe.model ?? judge.model}.`];
|
|
256
|
+
if (probe.elapsedMs > judge.timeoutMs) {
|
|
257
|
+
lines.push(
|
|
258
|
+
"",
|
|
259
|
+
`That is slower than the ${judge.timeoutMs}ms timeout, so real calls would come to you. Raise the timeout.`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return lines.join("\n");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function forget(
|
|
266
|
+
scope: FeatureScope,
|
|
267
|
+
state: SessionState,
|
|
268
|
+
ctx: ExtensionContext,
|
|
269
|
+
where: Scope,
|
|
270
|
+
): string | undefined {
|
|
271
|
+
const removed = forgetAlwaysYes(scope, state, ctx, where);
|
|
272
|
+
if (removed === 0) throw new Error(`nothing to forget for ${SCOPE_LABEL[where]}`);
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function alwaysYesDescription(where: Scope): string {
|
|
277
|
+
switch (where) {
|
|
278
|
+
case "session":
|
|
279
|
+
return "Rules for this session. Enter forgets them.";
|
|
280
|
+
case "project":
|
|
281
|
+
return `Rules for this project, in ${projectAlwaysYesPath(".")}. Enter forgets them.`;
|
|
282
|
+
case "global":
|
|
283
|
+
return `Rules for every project, in ${shortenHome(globalAlwaysYesPath())}. Enter forgets them.`;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function count(size: number): string {
|
|
288
|
+
return size === 1 ? "1 rule" : `${size} rules`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function capitalize(text: string): string {
|
|
292
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
293
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
// In the session file, so a resume or a fork comes back with its mode, outside and always yes.
|
|
1
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
+
import { isOutsideScope, type OutsideScope } from "#core/config/schema.ts";
|
|
5
|
+
import type { Access, OpenFolder } from "#core/folders.ts";
|
|
6
|
+
import { parseMode, type PermissionMode } from "#core/mode.ts";
|
|
4
7
|
import { NAME } from "#identity";
|
|
5
8
|
import type { SessionState } from "#pi/session.ts";
|
|
6
9
|
import { isRecord } from "#util/primitives.ts";
|
|
@@ -9,20 +12,28 @@ export const SESSION_ENTRY = `${NAME}:session`;
|
|
|
9
12
|
|
|
10
13
|
export type SessionEntry =
|
|
11
14
|
| { kind: "mode"; mode: PermissionMode }
|
|
15
|
+
| { kind: "outside"; outside: OutsideScope }
|
|
12
16
|
| { kind: "always-yes"; toolName: string; level: string }
|
|
13
|
-
| { kind: "forget-always-yes" }
|
|
17
|
+
| { kind: "forget-always-yes" }
|
|
18
|
+
| { kind: "folder"; path: string; access: Access }
|
|
19
|
+
| { kind: "close-folders" };
|
|
14
20
|
|
|
15
|
-
interface SessionSnapshot {
|
|
21
|
+
export interface SessionSnapshot {
|
|
16
22
|
mode: PermissionMode;
|
|
23
|
+
outside: OutsideScope;
|
|
17
24
|
alwaysYes: { toolName: string; level: string }[];
|
|
25
|
+
folders: OpenFolder[];
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
export function record(pi: ExtensionAPI, entry: SessionEntry): void {
|
|
21
29
|
pi.appendEntry(SESSION_ENTRY, entry);
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
export function replay(
|
|
25
|
-
|
|
32
|
+
export function replay(
|
|
33
|
+
branch: readonly unknown[],
|
|
34
|
+
start: Pick<SessionSnapshot, "mode" | "outside">,
|
|
35
|
+
): SessionSnapshot {
|
|
36
|
+
const snapshot: SessionSnapshot = { ...start, alwaysYes: [], folders: [] };
|
|
26
37
|
|
|
27
38
|
for (const item of branch) {
|
|
28
39
|
const entry = sessionEntry(item);
|
|
@@ -30,25 +41,40 @@ export function replay(branch: readonly unknown[], startMode: PermissionMode): S
|
|
|
30
41
|
case "mode":
|
|
31
42
|
snapshot.mode = entry.mode;
|
|
32
43
|
break;
|
|
44
|
+
case "outside":
|
|
45
|
+
snapshot.outside = entry.outside;
|
|
46
|
+
break;
|
|
33
47
|
case "always-yes":
|
|
34
48
|
snapshot.alwaysYes.push({ toolName: entry.toolName, level: entry.level });
|
|
35
49
|
break;
|
|
36
50
|
case "forget-always-yes":
|
|
37
51
|
snapshot.alwaysYes = [];
|
|
38
52
|
break;
|
|
53
|
+
case "folder":
|
|
54
|
+
snapshot.folders.push({ path: entry.path, access: entry.access });
|
|
55
|
+
break;
|
|
56
|
+
case "close-folders":
|
|
57
|
+
snapshot.folders = [];
|
|
58
|
+
break;
|
|
39
59
|
}
|
|
40
60
|
}
|
|
41
61
|
return snapshot;
|
|
42
62
|
}
|
|
43
63
|
|
|
44
64
|
export function restoreSession(state: SessionState, ctx: ExtensionContext): void {
|
|
45
|
-
const snapshot = replay(ctx.sessionManager.getBranch(),
|
|
65
|
+
const snapshot = replay(ctx.sessionManager.getBranch(), {
|
|
66
|
+
mode: state.config.mode,
|
|
67
|
+
outside: state.config.workspace.outside,
|
|
68
|
+
});
|
|
46
69
|
|
|
47
70
|
state.mode = snapshot.mode;
|
|
71
|
+
state.outside = snapshot.outside;
|
|
48
72
|
state.alwaysYes.forget("session");
|
|
49
73
|
for (const { toolName, level } of snapshot.alwaysYes) {
|
|
50
74
|
state.alwaysYes.add("session", toolName, level);
|
|
51
75
|
}
|
|
76
|
+
state.folders.forget("session");
|
|
77
|
+
for (const { path, access } of snapshot.folders) state.folders.add("session", path, access);
|
|
52
78
|
}
|
|
53
79
|
|
|
54
80
|
function sessionEntry(item: unknown): SessionEntry | undefined {
|
|
@@ -59,13 +85,25 @@ function sessionEntry(item: unknown): SessionEntry | undefined {
|
|
|
59
85
|
const data = item.data;
|
|
60
86
|
if (!isRecord(data)) return undefined;
|
|
61
87
|
|
|
62
|
-
if (data.kind === "mode"
|
|
63
|
-
|
|
88
|
+
if (data.kind === "mode") {
|
|
89
|
+
const mode = typeof data.mode === "string" ? parseMode(data.mode) : undefined;
|
|
90
|
+
return mode === undefined ? undefined : { kind: "mode", mode };
|
|
91
|
+
}
|
|
92
|
+
if (data.kind === "outside" && isOutsideScope(data.outside)) {
|
|
93
|
+
return { kind: "outside", outside: data.outside };
|
|
64
94
|
}
|
|
65
95
|
if (data.kind === "always-yes" && isText(data.toolName) && isText(data.level)) {
|
|
66
96
|
return { kind: "always-yes", toolName: data.toolName, level: data.level };
|
|
67
97
|
}
|
|
68
98
|
if (data.kind === "forget-always-yes") return { kind: "forget-always-yes" };
|
|
99
|
+
if (
|
|
100
|
+
data.kind === "folder" &&
|
|
101
|
+
isText(data.path) &&
|
|
102
|
+
(data.access === "read" || data.access === "write")
|
|
103
|
+
) {
|
|
104
|
+
return { kind: "folder", path: data.path, access: data.access };
|
|
105
|
+
}
|
|
106
|
+
if (data.kind === "close-folders") return { kind: "close-folders" };
|
|
69
107
|
return undefined;
|
|
70
108
|
}
|
|
71
109
|
|
package/src/pi/session.ts
CHANGED
|
@@ -2,9 +2,15 @@ import { type FeatureScope } from "@adeildo/pi-kit";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
|
|
4
4
|
import { AlwaysYes, savedFileExists, type Scope } from "#core/always-yes.ts";
|
|
5
|
-
import { defaultConfig, type PermissionConfig } from "#core/config/schema.ts";
|
|
6
|
-
import { migrateConfig, readConfig
|
|
7
|
-
import {
|
|
5
|
+
import { defaultConfig, type OutsideScope, type PermissionConfig } from "#core/config/schema.ts";
|
|
6
|
+
import { dropRetiredKeys, migrateConfig, readConfig } from "#core/config/settings.ts";
|
|
7
|
+
import {
|
|
8
|
+
globalAlwaysYesPath,
|
|
9
|
+
globalFoldersPath,
|
|
10
|
+
projectAlwaysYesPath,
|
|
11
|
+
projectFoldersPath,
|
|
12
|
+
} from "#core/config/store.ts";
|
|
13
|
+
import { type Access, OpenFolders } from "#core/folders.ts";
|
|
8
14
|
import type { JudgeOutcome, JudgeRecord } from "#core/judge/types.ts";
|
|
9
15
|
import type { PermissionMode } from "#core/mode.ts";
|
|
10
16
|
import type { ToolAdapter } from "#core/tools.ts";
|
|
@@ -24,7 +30,9 @@ export interface JudgeHealth {
|
|
|
24
30
|
export interface SessionState {
|
|
25
31
|
config: PermissionConfig;
|
|
26
32
|
mode: PermissionMode;
|
|
33
|
+
outside: OutsideScope;
|
|
27
34
|
alwaysYes: AlwaysYes;
|
|
35
|
+
folders: OpenFolders;
|
|
28
36
|
pendingNotes: Map<string, string>;
|
|
29
37
|
judgeCache: Map<string, JudgeOutcome>;
|
|
30
38
|
judgeLog: JudgeRecord[];
|
|
@@ -41,7 +49,9 @@ export function createSession(): SessionState {
|
|
|
41
49
|
return {
|
|
42
50
|
config,
|
|
43
51
|
mode: config.mode,
|
|
52
|
+
outside: config.workspace.outside,
|
|
44
53
|
alwaysYes: new AlwaysYes(),
|
|
54
|
+
folders: new OpenFolders(),
|
|
45
55
|
pendingNotes: new Map(),
|
|
46
56
|
judgeCache: new Map(),
|
|
47
57
|
judgeLog: [],
|
|
@@ -65,7 +75,7 @@ export function noteJudgeFailure(state: SessionState, ctx: ExtensionContext): vo
|
|
|
65
75
|
state.judgeHealth.failures = 0;
|
|
66
76
|
state.judgeHealth.retryAt = Date.now() + JUDGE_RETRY_MS;
|
|
67
77
|
ctx.ui.notify(
|
|
68
|
-
`${NAME}: judge paused for ${JUDGE_RETRY_MS / 1000}s after repeated failures;
|
|
78
|
+
`${NAME}: judge paused for ${JUDGE_RETRY_MS / 1000}s after repeated failures; try Test the judge on Alt+S`,
|
|
69
79
|
"warning",
|
|
70
80
|
);
|
|
71
81
|
}
|
|
@@ -77,17 +87,13 @@ export function loadSessionConfig(
|
|
|
77
87
|
ctx: ExtensionContext,
|
|
78
88
|
): void {
|
|
79
89
|
for (const warning of migrateConfig(scope)) ctx.ui.notify(`${NAME}: ${warning}`, "warning");
|
|
90
|
+
for (const line of dropRetiredKeys(scope)) ctx.ui.notify(`${NAME}: ${line}`, "info");
|
|
80
91
|
|
|
81
92
|
state.config = readConfig(scope);
|
|
82
93
|
state.typing.stop();
|
|
83
94
|
state.typing = new TypingMonitor(state.config.typing.pause, state.config.typing.maxWait);
|
|
84
95
|
}
|
|
85
96
|
|
|
86
|
-
export function saveConfig(scope: FeatureScope, state: SessionState, ctx: ExtensionContext): void {
|
|
87
|
-
const error = writeConfig(scope, state.config);
|
|
88
|
-
if (error) ctx.ui.notify(`${NAME}: could not save the settings: ${error}`, "error");
|
|
89
|
-
}
|
|
90
|
-
|
|
91
97
|
export function openAlwaysYes(state: SessionState, ctx: ExtensionContext): void {
|
|
92
98
|
const project = projectAlwaysYesPath(ctx.cwd);
|
|
93
99
|
const trusted = ctx.isProjectTrusted();
|
|
@@ -96,12 +102,47 @@ export function openAlwaysYes(state: SessionState, ctx: ExtensionContext): void
|
|
|
96
102
|
project: trusted ? project : undefined,
|
|
97
103
|
});
|
|
98
104
|
|
|
105
|
+
const folders = projectFoldersPath(ctx.cwd);
|
|
106
|
+
warnings.push(
|
|
107
|
+
...state.folders.open({
|
|
108
|
+
global: globalFoldersPath(),
|
|
109
|
+
project: trusted ? folders : undefined,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
99
113
|
for (const warning of warnings) ctx.ui.notify(`${NAME}: ${warning}`, "warning");
|
|
100
|
-
|
|
101
|
-
|
|
114
|
+
for (const file of [project, folders]) {
|
|
115
|
+
if (trusted || !savedFileExists(file)) continue;
|
|
116
|
+
ctx.ui.notify(`${NAME}: ${file} skipped, this project is not trusted`, "warning");
|
|
102
117
|
}
|
|
103
118
|
}
|
|
104
119
|
|
|
120
|
+
export function openFolder(
|
|
121
|
+
pi: ExtensionAPI,
|
|
122
|
+
state: SessionState,
|
|
123
|
+
ctx: ExtensionContext,
|
|
124
|
+
scope: Scope,
|
|
125
|
+
path: string,
|
|
126
|
+
access: Access,
|
|
127
|
+
): void {
|
|
128
|
+
if (scope === "session") record(pi, { kind: "folder", path, access });
|
|
129
|
+
const problem = state.folders.add(scope, path, access);
|
|
130
|
+
if (problem) ctx.ui.notify(`${NAME}: could not save the folder: ${problem}`, "warning");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function closeFolders(
|
|
134
|
+
pi: ExtensionAPI,
|
|
135
|
+
state: SessionState,
|
|
136
|
+
ctx: ExtensionContext,
|
|
137
|
+
scope: Scope,
|
|
138
|
+
): number {
|
|
139
|
+
if (scope === "session") record(pi, { kind: "close-folders" });
|
|
140
|
+
const { removed, errors } = state.folders.forget(scope);
|
|
141
|
+
for (const error of errors)
|
|
142
|
+
ctx.ui.notify(`${NAME}: could not close the folders: ${error}`, "error");
|
|
143
|
+
return removed;
|
|
144
|
+
}
|
|
145
|
+
|
|
105
146
|
export function rememberAlwaysYes(
|
|
106
147
|
pi: ExtensionAPI,
|
|
107
148
|
state: SessionState,
|
|
@@ -1,23 +1,69 @@
|
|
|
1
|
+
import type { FolderOffer } from "#core/answer.ts";
|
|
2
|
+
import { shortenHome } from "#core/tools.ts";
|
|
3
|
+
|
|
1
4
|
export interface Choice {
|
|
2
5
|
key: string;
|
|
3
6
|
decision: "allow" | "deny";
|
|
4
7
|
always: boolean;
|
|
8
|
+
/** Opens the offered folder. */
|
|
9
|
+
open?: boolean;
|
|
5
10
|
label: string;
|
|
6
11
|
tone: "success" | "warning" | "error";
|
|
7
12
|
}
|
|
8
13
|
|
|
14
|
+
const YES: Omit<Choice, "key"> = {
|
|
15
|
+
decision: "allow",
|
|
16
|
+
always: false,
|
|
17
|
+
label: "yes",
|
|
18
|
+
tone: "success",
|
|
19
|
+
};
|
|
20
|
+
const ALWAYS: Omit<Choice, "key"> = {
|
|
21
|
+
decision: "allow",
|
|
22
|
+
always: true,
|
|
23
|
+
label: "always yes",
|
|
24
|
+
tone: "warning",
|
|
25
|
+
};
|
|
26
|
+
const DENY: Omit<Choice, "key"> = { decision: "deny", always: false, label: "deny", tone: "error" };
|
|
27
|
+
|
|
9
28
|
export const CHOICES: Choice[] = [
|
|
10
|
-
{ key: "1"
|
|
11
|
-
{ key: "2"
|
|
12
|
-
{ key: "3"
|
|
29
|
+
{ ...YES, key: "1" },
|
|
30
|
+
{ ...ALWAYS, key: "2" },
|
|
31
|
+
{ ...DENY, key: "3" },
|
|
13
32
|
];
|
|
14
33
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
34
|
+
// Second, right under the plain yes: the answer the call most likely wants.
|
|
35
|
+
export function choicesFor(offer?: FolderOffer): Choice[] {
|
|
36
|
+
if (offer === undefined) return CHOICES;
|
|
37
|
+
const tone = offer.access === "read" ? "success" : "warning";
|
|
38
|
+
return [
|
|
39
|
+
{ ...YES, key: "1" },
|
|
40
|
+
{ key: "2", decision: "allow", always: false, open: true, label: "", tone },
|
|
41
|
+
{ ...ALWAYS, key: "3" },
|
|
42
|
+
{ ...DENY, key: "4" },
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function openLabel(offer: FolderOffer, folder: string): string {
|
|
47
|
+
const shown = shortenHome(folder);
|
|
48
|
+
return offer.access === "read"
|
|
49
|
+
? `yes, and allow reads in ${shown}`
|
|
50
|
+
: `yes, and add ${shown} to the workspace`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function fallbackChoices(offer?: FolderOffer): (Choice & { note: boolean })[] {
|
|
54
|
+
const folder = offer?.folders[offer.suggested];
|
|
55
|
+
return choicesFor(offer).flatMap((option, index) => {
|
|
56
|
+
const label = option.open && offer && folder ? openLabel(offer, folder) : option.label;
|
|
57
|
+
return [
|
|
58
|
+
{ ...option, label, key: String(index * 2 + 1), note: false },
|
|
59
|
+
{
|
|
60
|
+
...option,
|
|
61
|
+
key: String(index * 2 + 2),
|
|
62
|
+
note: true,
|
|
63
|
+
label: `${label}, ${option.decision === "allow" ? "with a note" : "with a reason"}`,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const FALLBACK_CHOICES = fallbackChoices();
|