@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.
- package/CONTRIBUTING.md +43 -0
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/assets/preview.png +0 -0
- package/package.json +63 -0
- package/src/core/always-yes.ts +183 -0
- package/src/core/answer.ts +8 -0
- package/src/core/config/decode.ts +118 -0
- package/src/core/config/patterns.ts +51 -0
- package/src/core/config/schema.ts +72 -0
- package/src/core/config/settings.ts +374 -0
- package/src/core/config/store.ts +57 -0
- package/src/core/decide.ts +106 -0
- package/src/core/judge/backends/factory.ts +30 -0
- package/src/core/judge/backends/jev.ts +227 -0
- package/src/core/judge/backends/pi-model.ts +174 -0
- package/src/core/judge/compose.ts +91 -0
- package/src/core/judge/config.ts +60 -0
- package/src/core/judge/decode.ts +51 -0
- package/src/core/judge/gate.ts +70 -0
- package/src/core/judge/pipeline.ts +106 -0
- package/src/core/judge/policy.ts +114 -0
- package/src/core/judge/probe.ts +51 -0
- package/src/core/judge/report.ts +73 -0
- package/src/core/judge/request.ts +78 -0
- package/src/core/judge/types.ts +72 -0
- package/src/core/mode.ts +42 -0
- package/src/core/readonly-bash.ts +689 -0
- package/src/core/tools.ts +208 -0
- package/src/core/workspace.ts +66 -0
- package/src/identity.ts +5 -0
- package/src/index.ts +29 -0
- package/src/pi/api.ts +44 -0
- package/src/pi/bash-timer.ts +49 -0
- package/src/pi/commands.ts +193 -0
- package/src/pi/events.ts +260 -0
- package/src/pi/mode.ts +48 -0
- package/src/pi/preview.ts +82 -0
- package/src/pi/provider.ts +11 -0
- package/src/pi/session-entries.ts +74 -0
- package/src/pi/session.ts +129 -0
- package/src/ui/clipboard.ts +51 -0
- package/src/ui/decision-options.ts +23 -0
- package/src/ui/dialog.ts +395 -0
- package/src/ui/judge-entry.ts +130 -0
- package/src/ui/paste.ts +27 -0
- package/src/ui/picker.ts +90 -0
- package/src/ui/selector.ts +42 -0
- package/src/ui/settings/judge.ts +299 -0
- package/src/ui/settings/screen.ts +268 -0
- package/src/ui/settings/status.ts +53 -0
- package/src/ui/typing.ts +72 -0
- package/src/util/primitives.ts +11 -0
package/src/pi/events.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { FeatureScope } from "@adeildo/pi-kit";
|
|
2
|
+
import {
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
isToolCallEventType,
|
|
6
|
+
type ToolCallEvent,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
import { SCOPE_LABEL } from "#core/always-yes.ts";
|
|
10
|
+
import type { DialogAnswer } from "#core/answer.ts";
|
|
11
|
+
import { noUIMode } from "#core/config/patterns.ts";
|
|
12
|
+
import {
|
|
13
|
+
type Call,
|
|
14
|
+
decide,
|
|
15
|
+
describeCall,
|
|
16
|
+
gateLayers,
|
|
17
|
+
type Layer,
|
|
18
|
+
type Verdict,
|
|
19
|
+
} from "#core/decide.ts";
|
|
20
|
+
import { judgeGate } from "#core/judge/gate.ts";
|
|
21
|
+
import { judgeVerdictText, remember, warnOnce } from "#core/judge/report.ts";
|
|
22
|
+
import type { CallDescriptor } from "#core/tools.ts";
|
|
23
|
+
import { NAME } from "#identity";
|
|
24
|
+
import { announce, type Decided } from "#pi/api.ts";
|
|
25
|
+
import { clearModeStatus, renderModeStatus } from "#pi/mode.ts";
|
|
26
|
+
import { type FileChange, type PendingWrites, previewEdit, previewWrite } from "#pi/preview.ts";
|
|
27
|
+
import { restoreSession } from "#pi/session-entries.ts";
|
|
28
|
+
import {
|
|
29
|
+
loadSessionConfig,
|
|
30
|
+
openAlwaysYes,
|
|
31
|
+
noteJudgeFailure,
|
|
32
|
+
rememberAlwaysYes,
|
|
33
|
+
resetJudgeHealth,
|
|
34
|
+
type SessionState,
|
|
35
|
+
} from "#pi/session.ts";
|
|
36
|
+
import { AskDialog } from "#ui/dialog.ts";
|
|
37
|
+
import { appendJudgeEntry } from "#ui/judge-entry.ts";
|
|
38
|
+
import { askViaSelector } from "#ui/selector.ts";
|
|
39
|
+
import { notifyJudgePolicyWarning } from "#ui/settings/status.ts";
|
|
40
|
+
|
|
41
|
+
const JUDGE_STATUS = `${NAME}:judge`;
|
|
42
|
+
const TYPING_STATUS = "waiting for you to finish typing";
|
|
43
|
+
|
|
44
|
+
export function registerEvents(scope: FeatureScope, state: SessionState): void {
|
|
45
|
+
scope.on("session_start", (_event, ctx) => {
|
|
46
|
+
loadSessionConfig(scope, state, ctx);
|
|
47
|
+
openAlwaysYes(state, ctx);
|
|
48
|
+
restoreSession(state, ctx);
|
|
49
|
+
notifyJudgePolicyWarning(state.config, ctx);
|
|
50
|
+
renderModeStatus(ctx, state.mode, state.config.workspace.outside);
|
|
51
|
+
state.typing.start(ctx);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
scope.on("session_tree", (_event, ctx) => {
|
|
55
|
+
restoreSession(state, ctx);
|
|
56
|
+
renderModeStatus(ctx, state.mode, state.config.workspace.outside);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
scope.on("session_shutdown", (_event, ctx) => {
|
|
60
|
+
clearModeStatus(ctx);
|
|
61
|
+
state.typing.stop();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
scope.on("tool_call", async (event, ctx) => {
|
|
65
|
+
const call = describeCall(
|
|
66
|
+
event.toolName,
|
|
67
|
+
event.input,
|
|
68
|
+
ctx.cwd,
|
|
69
|
+
state.config,
|
|
70
|
+
state.customTools,
|
|
71
|
+
);
|
|
72
|
+
const outcome = await gate(scope, state, ctx, call, event);
|
|
73
|
+
|
|
74
|
+
announce(scope, {
|
|
75
|
+
toolCallId: event.toolCallId,
|
|
76
|
+
toolName: call.toolName,
|
|
77
|
+
summary: call.target.summary,
|
|
78
|
+
...outcome,
|
|
79
|
+
});
|
|
80
|
+
if (outcome.action === "block") return { block: true, reason: outcome.reason };
|
|
81
|
+
|
|
82
|
+
if (outcome.note) {
|
|
83
|
+
if (state.config.notes === "message") sendNote(scope, outcome.note, call.toolName);
|
|
84
|
+
else state.pendingNotes.set(event.toolCallId, outcome.note);
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
scope.on("tool_result", (event) => {
|
|
90
|
+
for (const [path, write] of state.pendingWrites) {
|
|
91
|
+
if (write.toolCallId === event.toolCallId) state.pendingWrites.delete(path);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const note = state.pendingNotes.get(event.toolCallId);
|
|
95
|
+
if (!note) return undefined;
|
|
96
|
+
|
|
97
|
+
state.pendingNotes.delete(event.toolCallId);
|
|
98
|
+
return { content: [...event.content, { type: "text", text: noteBlock(note) }] };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type Outcome = Pick<Decided, "action" | "by" | "reason" | "note">;
|
|
103
|
+
|
|
104
|
+
async function gate(
|
|
105
|
+
pi: ExtensionAPI,
|
|
106
|
+
state: SessionState,
|
|
107
|
+
ctx: ExtensionContext,
|
|
108
|
+
call: Call,
|
|
109
|
+
event: ToolCallEvent,
|
|
110
|
+
): Promise<Outcome> {
|
|
111
|
+
const judge: Layer = { name: "judge", decide: (next) => runJudge(state, pi, ctx, next) };
|
|
112
|
+
const decision = await decide(call, [...gateLayers(state), judge]);
|
|
113
|
+
|
|
114
|
+
if (decision.action === "allow") return { action: "allow", by: decision.by };
|
|
115
|
+
if (decision.action === "block")
|
|
116
|
+
return { action: "block", by: decision.by, reason: decision.reason };
|
|
117
|
+
|
|
118
|
+
if (!ctx.hasUI) {
|
|
119
|
+
if (noUIMode(state.config, call.toolName) === "allow") return { action: "allow", by: "no UI" };
|
|
120
|
+
return { action: "block", by: "no UI", reason: `${NAME}: no UI to approve "${call.toolName}"` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const change = await previewChange(ctx, event, state.pendingWrites);
|
|
124
|
+
if (change && "error" in change)
|
|
125
|
+
return { action: "block", by: "edit check", reason: change.error };
|
|
126
|
+
|
|
127
|
+
await state.typing.waitUntilQuiet(ctx.signal, (waiting) => {
|
|
128
|
+
ctx.ui.setStatus(NAME, waiting ? TYPING_STATUS : undefined);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
state.typing.pause();
|
|
132
|
+
const answer = await ask(ctx, call.toolName, call.target, change?.diff).finally(() =>
|
|
133
|
+
state.typing.resume(),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (answer.decision === "deny") {
|
|
137
|
+
return { action: "block", by: "you", reason: denyReason(answer.note), note: answer.note };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (answer.remember) {
|
|
141
|
+
const scope = answer.scope ?? "session";
|
|
142
|
+
rememberAlwaysYes(pi, state, ctx, scope, call.toolName, answer.remember);
|
|
143
|
+
ctx.ui.notify(
|
|
144
|
+
`${NAME}: always yes for ${call.toolName} \u00b7 ${answer.remember} (${SCOPE_LABEL[scope]})`,
|
|
145
|
+
"info",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (change)
|
|
150
|
+
state.pendingWrites.set(change.path, { toolCallId: event.toolCallId, after: change.after });
|
|
151
|
+
return { action: "allow", by: "you", note: answer.note };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function previewChange(
|
|
155
|
+
ctx: ExtensionContext,
|
|
156
|
+
event: ToolCallEvent,
|
|
157
|
+
pending: PendingWrites,
|
|
158
|
+
): Promise<FileChange | undefined> {
|
|
159
|
+
if (isToolCallEventType("edit", event)) return previewEdit(ctx, event.input, pending);
|
|
160
|
+
if (isToolCallEventType("write", event)) return previewWrite(ctx, event.input, pending);
|
|
161
|
+
return Promise.resolve(undefined);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function runJudge(
|
|
165
|
+
state: SessionState,
|
|
166
|
+
pi: ExtensionAPI,
|
|
167
|
+
ctx: ExtensionContext,
|
|
168
|
+
call: Call,
|
|
169
|
+
): Promise<Verdict | undefined> {
|
|
170
|
+
if (Date.now() < state.judgeHealth.retryAt) return undefined;
|
|
171
|
+
|
|
172
|
+
const outcome = await judgeGate({
|
|
173
|
+
config: state.config,
|
|
174
|
+
ctx,
|
|
175
|
+
toolName: call.toolName,
|
|
176
|
+
target: call.target,
|
|
177
|
+
rawInput: call.input,
|
|
178
|
+
cache: state.judgeCache,
|
|
179
|
+
onStatus: (status) => ctx.ui.setStatus(JUDGE_STATUS, status),
|
|
180
|
+
});
|
|
181
|
+
if (!outcome) return undefined;
|
|
182
|
+
|
|
183
|
+
const record = outcome.record;
|
|
184
|
+
remember(record, state.judgeLog);
|
|
185
|
+
appendJudgeEntry(pi, record);
|
|
186
|
+
|
|
187
|
+
if (record.error) {
|
|
188
|
+
warnOnce(ctx, state.judgeWarned, record);
|
|
189
|
+
noteJudgeFailure(state, ctx);
|
|
190
|
+
} else {
|
|
191
|
+
resetJudgeHealth(state);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (outcome.action === "deny") {
|
|
195
|
+
return { action: "block", reason: `${NAME}: ${outcome.reason}` };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (outcome.action === "allow") {
|
|
199
|
+
if (state.config.judge.rememberApprovals) {
|
|
200
|
+
const level = call.target.levels.at(-1);
|
|
201
|
+
if (level !== undefined) rememberAlwaysYes(pi, state, ctx, "session", call.toolName, level);
|
|
202
|
+
}
|
|
203
|
+
return { action: "allow" };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (state.config.judge.dryRun && !record.error) {
|
|
207
|
+
ctx.ui.notify(`${NAME}: judge (dry run) ${judgeVerdictText(record)}`, "info");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function ask(
|
|
214
|
+
ctx: ExtensionContext,
|
|
215
|
+
toolName: string,
|
|
216
|
+
target: CallDescriptor,
|
|
217
|
+
diff: string | undefined,
|
|
218
|
+
): Promise<DialogAnswer> {
|
|
219
|
+
if (ctx.mode === "tui") {
|
|
220
|
+
try {
|
|
221
|
+
const answer = await ctx.ui.custom<DialogAnswer>(
|
|
222
|
+
(tui, theme, keybindings, done) =>
|
|
223
|
+
new AskDialog({
|
|
224
|
+
theme,
|
|
225
|
+
toolName,
|
|
226
|
+
target,
|
|
227
|
+
diff,
|
|
228
|
+
keybindings,
|
|
229
|
+
requestRender: () => tui.requestRender(),
|
|
230
|
+
complete: done,
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
if (answer) return answer;
|
|
234
|
+
} catch {
|
|
235
|
+
// The plain selector, not an open gate.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return askViaSelector(ctx, toolName, target);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function sendNote(pi: ExtensionAPI, note: string, toolName: string): void {
|
|
243
|
+
void pi.sendMessage(
|
|
244
|
+
{
|
|
245
|
+
customType: NAME,
|
|
246
|
+
content: noteBlock(note),
|
|
247
|
+
display: true,
|
|
248
|
+
details: { toolName },
|
|
249
|
+
},
|
|
250
|
+
{ deliverAs: "steer" },
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function noteBlock(note: string): string {
|
|
255
|
+
return `[${NAME}] the user approved this call and added:\n${note}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function denyReason(note?: string): string {
|
|
259
|
+
return note ? `${NAME}: denied by the user.\n${note}` : `${NAME}: denied by the user.`;
|
|
260
|
+
}
|
package/src/pi/mode.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { OutsideScope } from "#core/config/schema.ts";
|
|
4
|
+
import { MODE_DESCRIPTION, MODE_LABEL, type PermissionMode } from "#core/mode.ts";
|
|
5
|
+
import { NAME } from "#identity";
|
|
6
|
+
import { record } from "#pi/session-entries.ts";
|
|
7
|
+
import type { SessionState } from "#pi/session.ts";
|
|
8
|
+
|
|
9
|
+
export const MODE_STATUS = `${NAME}:mode`;
|
|
10
|
+
|
|
11
|
+
export function setSessionMode(
|
|
12
|
+
pi: ExtensionAPI,
|
|
13
|
+
state: SessionState,
|
|
14
|
+
mode: PermissionMode,
|
|
15
|
+
ctx: ExtensionContext,
|
|
16
|
+
announce = true,
|
|
17
|
+
): void {
|
|
18
|
+
if (mode !== state.mode) record(pi, { kind: "mode", mode });
|
|
19
|
+
state.mode = mode;
|
|
20
|
+
renderModeStatus(ctx, mode, state.config.workspace.outside);
|
|
21
|
+
|
|
22
|
+
if (announce) {
|
|
23
|
+
ctx.ui.notify(`${NAME}: ${MODE_LABEL[mode]} \u00b7 ${MODE_DESCRIPTION[mode]}`, "info");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function clearModeStatus(ctx: ExtensionContext): void {
|
|
28
|
+
if (!ctx.hasUI) return;
|
|
29
|
+
ctx.ui.setStatus(MODE_STATUS, undefined);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function renderModeStatus(
|
|
33
|
+
ctx: ExtensionContext,
|
|
34
|
+
mode: PermissionMode,
|
|
35
|
+
outside: OutsideScope,
|
|
36
|
+
): void {
|
|
37
|
+
if (mode === "manual") {
|
|
38
|
+
clearModeStatus(ctx);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (!ctx.hasUI) return;
|
|
42
|
+
|
|
43
|
+
const anywhere = outside === "allow";
|
|
44
|
+
const arrow = mode === "auto" ? "\u23f5\u23f5" : "\u23f5";
|
|
45
|
+
const color = anywhere ? "error" : "warning";
|
|
46
|
+
const label = anywhere ? `${MODE_LABEL[mode]} \u00b7 anywhere` : MODE_LABEL[mode];
|
|
47
|
+
ctx.ui.setStatus(MODE_STATUS, ctx.ui.theme.fg(color, `${arrow} ${label}`));
|
|
48
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createEditToolDefinition,
|
|
8
|
+
type EditToolInput,
|
|
9
|
+
type ExtensionContext,
|
|
10
|
+
type ExtensionToolContext,
|
|
11
|
+
generateDiffString,
|
|
12
|
+
type WriteToolInput,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
import { describe } from "#util/primitives.ts";
|
|
16
|
+
|
|
17
|
+
export type FileChange = { error: string } | { path: string; after: string; diff: string };
|
|
18
|
+
|
|
19
|
+
/** Approved edits and writes that have not run yet, by absolute path. */
|
|
20
|
+
export type PendingWrites = Map<string, { toolCallId: string; after: string }>;
|
|
21
|
+
|
|
22
|
+
// pi's own edit tool, run with a writeFile that keeps the result, so the preview and the
|
|
23
|
+
// error are exactly what the real call would produce.
|
|
24
|
+
export async function previewEdit(
|
|
25
|
+
ctx: ExtensionContext,
|
|
26
|
+
input: EditToolInput,
|
|
27
|
+
pending: PendingWrites,
|
|
28
|
+
): Promise<FileChange> {
|
|
29
|
+
let before = "";
|
|
30
|
+
let written: { path: string; after: string } | undefined;
|
|
31
|
+
|
|
32
|
+
const dryRun = createEditToolDefinition(ctx.cwd, {
|
|
33
|
+
operations: {
|
|
34
|
+
access: (path) =>
|
|
35
|
+
pending.has(path) ? Promise.resolve() : access(path, constants.R_OK | constants.W_OK),
|
|
36
|
+
readFile: async (path) => {
|
|
37
|
+
const content = pending.get(path)?.after ?? (await readFile(path, "utf8"));
|
|
38
|
+
before = content;
|
|
39
|
+
return Buffer.from(content);
|
|
40
|
+
},
|
|
41
|
+
writeFile: async (path, content) => {
|
|
42
|
+
written = { path, after: content };
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await dryRun.execute("preview", input, ctx.signal, undefined, previewContext(ctx));
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return { error: describe(error) };
|
|
51
|
+
}
|
|
52
|
+
if (!written) return { error: "the edit changed nothing" };
|
|
53
|
+
return { ...written, diff: generateDiffString(before, written.after).diff };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A tool runs with a context that can call other tools. The preview runs from a `tool_call` handler,
|
|
58
|
+
* which has none, and a dry run must not start real calls, so it gets no tools and any call fails
|
|
59
|
+
* the preview. The session context stays the prototype, so its getters keep reading live state.
|
|
60
|
+
*/
|
|
61
|
+
function previewContext(ctx: ExtensionContext): ExtensionToolContext {
|
|
62
|
+
return Object.assign(Object.create(ctx) as ExtensionContext, {
|
|
63
|
+
tools: [],
|
|
64
|
+
executeTool: (name: string) => Promise.reject(new Error(`a preview cannot call ${name}`)),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function previewWrite(
|
|
69
|
+
ctx: ExtensionContext,
|
|
70
|
+
input: WriteToolInput,
|
|
71
|
+
pending: PendingWrites,
|
|
72
|
+
): Promise<FileChange> {
|
|
73
|
+
const path = absolute(input.path, ctx.cwd);
|
|
74
|
+
const before = pending.get(path)?.after ?? (await readFile(path, "utf8").catch(() => ""));
|
|
75
|
+
return { path, after: input.content, diff: generateDiffString(before, input.content).diff };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function absolute(path: string, cwd: string): string {
|
|
79
|
+
if (path === "~") return homedir();
|
|
80
|
+
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
|
81
|
+
return resolve(cwd, path);
|
|
82
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FeatureScope } from "@adeildo/pi-kit";
|
|
2
|
+
|
|
3
|
+
import { TYPESAFE_BASE_URL, TYPESAFE_PROVIDER } from "#core/judge/backends/jev.ts";
|
|
4
|
+
|
|
5
|
+
export function registerTypesafeProvider(scope: FeatureScope): void {
|
|
6
|
+
scope.registerProvider(TYPESAFE_PROVIDER, {
|
|
7
|
+
name: "TypeSafe (Jev)",
|
|
8
|
+
baseUrl: TYPESAFE_BASE_URL,
|
|
9
|
+
apiKey: "$TYPESAFE_API_KEY",
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { isPermissionMode, type PermissionMode } from "#core/mode.ts";
|
|
4
|
+
import { NAME } from "#identity";
|
|
5
|
+
import type { SessionState } from "#pi/session.ts";
|
|
6
|
+
import { isRecord } from "#util/primitives.ts";
|
|
7
|
+
|
|
8
|
+
export const SESSION_ENTRY = `${NAME}:session`;
|
|
9
|
+
|
|
10
|
+
export type SessionEntry =
|
|
11
|
+
| { kind: "mode"; mode: PermissionMode }
|
|
12
|
+
| { kind: "always-yes"; toolName: string; level: string }
|
|
13
|
+
| { kind: "forget-always-yes" };
|
|
14
|
+
|
|
15
|
+
interface SessionSnapshot {
|
|
16
|
+
mode: PermissionMode;
|
|
17
|
+
alwaysYes: { toolName: string; level: string }[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function record(pi: ExtensionAPI, entry: SessionEntry): void {
|
|
21
|
+
pi.appendEntry(SESSION_ENTRY, entry);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function replay(branch: readonly unknown[], startMode: PermissionMode): SessionSnapshot {
|
|
25
|
+
const snapshot: SessionSnapshot = { mode: startMode, alwaysYes: [] };
|
|
26
|
+
|
|
27
|
+
for (const item of branch) {
|
|
28
|
+
const entry = sessionEntry(item);
|
|
29
|
+
switch (entry?.kind) {
|
|
30
|
+
case "mode":
|
|
31
|
+
snapshot.mode = entry.mode;
|
|
32
|
+
break;
|
|
33
|
+
case "always-yes":
|
|
34
|
+
snapshot.alwaysYes.push({ toolName: entry.toolName, level: entry.level });
|
|
35
|
+
break;
|
|
36
|
+
case "forget-always-yes":
|
|
37
|
+
snapshot.alwaysYes = [];
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return snapshot;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function restoreSession(state: SessionState, ctx: ExtensionContext): void {
|
|
45
|
+
const snapshot = replay(ctx.sessionManager.getBranch(), state.config.mode);
|
|
46
|
+
|
|
47
|
+
state.mode = snapshot.mode;
|
|
48
|
+
state.alwaysYes.forget("session");
|
|
49
|
+
for (const { toolName, level } of snapshot.alwaysYes) {
|
|
50
|
+
state.alwaysYes.add("session", toolName, level);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sessionEntry(item: unknown): SessionEntry | undefined {
|
|
55
|
+
if (!isRecord(item) || item.type !== "custom" || item.customType !== SESSION_ENTRY) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const data = item.data;
|
|
60
|
+
if (!isRecord(data)) return undefined;
|
|
61
|
+
|
|
62
|
+
if (data.kind === "mode" && isPermissionMode(data.mode)) {
|
|
63
|
+
return { kind: "mode", mode: data.mode };
|
|
64
|
+
}
|
|
65
|
+
if (data.kind === "always-yes" && isText(data.toolName) && isText(data.level)) {
|
|
66
|
+
return { kind: "always-yes", toolName: data.toolName, level: data.level };
|
|
67
|
+
}
|
|
68
|
+
if (data.kind === "forget-always-yes") return { kind: "forget-always-yes" };
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isText(value: unknown): value is string {
|
|
73
|
+
return typeof value === "string" && value !== "";
|
|
74
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { type FeatureScope } from "@adeildo/pi-kit";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
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, writeConfig } from "#core/config/settings.ts";
|
|
7
|
+
import { globalAlwaysYesPath, projectAlwaysYesPath } from "#core/config/store.ts";
|
|
8
|
+
import type { JudgeOutcome, JudgeRecord } from "#core/judge/types.ts";
|
|
9
|
+
import type { PermissionMode } from "#core/mode.ts";
|
|
10
|
+
import type { ToolAdapter } from "#core/tools.ts";
|
|
11
|
+
import { NAME } from "#identity";
|
|
12
|
+
import type { PendingWrites } from "#pi/preview.ts";
|
|
13
|
+
import { record } from "#pi/session-entries.ts";
|
|
14
|
+
import { TypingMonitor } from "#ui/typing.ts";
|
|
15
|
+
|
|
16
|
+
const JUDGE_FAILURE_LIMIT = 2;
|
|
17
|
+
const JUDGE_RETRY_MS = 60_000;
|
|
18
|
+
|
|
19
|
+
export interface JudgeHealth {
|
|
20
|
+
failures: number;
|
|
21
|
+
retryAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SessionState {
|
|
25
|
+
config: PermissionConfig;
|
|
26
|
+
mode: PermissionMode;
|
|
27
|
+
alwaysYes: AlwaysYes;
|
|
28
|
+
pendingNotes: Map<string, string>;
|
|
29
|
+
judgeCache: Map<string, JudgeOutcome>;
|
|
30
|
+
judgeLog: JudgeRecord[];
|
|
31
|
+
judgeWarned: Set<string>;
|
|
32
|
+
judgeHealth: JudgeHealth;
|
|
33
|
+
typing: TypingMonitor;
|
|
34
|
+
customTools: Map<string, Partial<ToolAdapter>>;
|
|
35
|
+
pendingWrites: PendingWrites;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createSession(): SessionState {
|
|
39
|
+
const config = defaultConfig();
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
config,
|
|
43
|
+
mode: config.mode,
|
|
44
|
+
alwaysYes: new AlwaysYes(),
|
|
45
|
+
pendingNotes: new Map(),
|
|
46
|
+
judgeCache: new Map(),
|
|
47
|
+
judgeLog: [],
|
|
48
|
+
judgeWarned: new Set(),
|
|
49
|
+
judgeHealth: { failures: 0, retryAt: 0 },
|
|
50
|
+
typing: new TypingMonitor(),
|
|
51
|
+
customTools: new Map(),
|
|
52
|
+
pendingWrites: new Map(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resetJudgeHealth(state: SessionState): void {
|
|
57
|
+
state.judgeHealth.failures = 0;
|
|
58
|
+
state.judgeHealth.retryAt = 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function noteJudgeFailure(state: SessionState, ctx: ExtensionContext): void {
|
|
62
|
+
state.judgeHealth.failures++;
|
|
63
|
+
if (state.judgeHealth.failures < JUDGE_FAILURE_LIMIT) return;
|
|
64
|
+
|
|
65
|
+
state.judgeHealth.failures = 0;
|
|
66
|
+
state.judgeHealth.retryAt = Date.now() + JUDGE_RETRY_MS;
|
|
67
|
+
ctx.ui.notify(
|
|
68
|
+
`${NAME}: judge paused for ${JUDGE_RETRY_MS / 1000}s after repeated failures; run /perm judge test`,
|
|
69
|
+
"warning",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Read at every session start, so an edit to the shared settings applies from the next session.
|
|
74
|
+
export function loadSessionConfig(
|
|
75
|
+
scope: FeatureScope,
|
|
76
|
+
state: SessionState,
|
|
77
|
+
ctx: ExtensionContext,
|
|
78
|
+
): void {
|
|
79
|
+
for (const warning of migrateConfig(scope)) ctx.ui.notify(`${NAME}: ${warning}`, "warning");
|
|
80
|
+
|
|
81
|
+
state.config = readConfig(scope);
|
|
82
|
+
state.typing.stop();
|
|
83
|
+
state.typing = new TypingMonitor(state.config.typing.pause, state.config.typing.maxWait);
|
|
84
|
+
}
|
|
85
|
+
|
|
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
|
+
export function openAlwaysYes(state: SessionState, ctx: ExtensionContext): void {
|
|
92
|
+
const project = projectAlwaysYesPath(ctx.cwd);
|
|
93
|
+
const trusted = ctx.isProjectTrusted();
|
|
94
|
+
const warnings = state.alwaysYes.open({
|
|
95
|
+
global: globalAlwaysYesPath(),
|
|
96
|
+
project: trusted ? project : undefined,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
for (const warning of warnings) ctx.ui.notify(`${NAME}: ${warning}`, "warning");
|
|
100
|
+
if (!trusted && savedFileExists(project)) {
|
|
101
|
+
ctx.ui.notify(`${NAME}: ${project} skipped, this project is not trusted`, "warning");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function rememberAlwaysYes(
|
|
106
|
+
pi: ExtensionAPI,
|
|
107
|
+
state: SessionState,
|
|
108
|
+
ctx: ExtensionContext,
|
|
109
|
+
scope: Scope,
|
|
110
|
+
toolName: string,
|
|
111
|
+
level: string,
|
|
112
|
+
): void {
|
|
113
|
+
if (scope === "session") record(pi, { kind: "always-yes", toolName, level });
|
|
114
|
+
const problem = state.alwaysYes.add(scope, toolName, level);
|
|
115
|
+
if (problem) ctx.ui.notify(`${NAME}: could not save always yes: ${problem}`, "warning");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function forgetAlwaysYes(
|
|
119
|
+
pi: ExtensionAPI,
|
|
120
|
+
state: SessionState,
|
|
121
|
+
ctx: ExtensionContext,
|
|
122
|
+
scope: Scope | "all",
|
|
123
|
+
): number {
|
|
124
|
+
if (scope === "session" || scope === "all") record(pi, { kind: "forget-always-yes" });
|
|
125
|
+
const { removed, errors } = state.alwaysYes.forget(scope);
|
|
126
|
+
for (const error of errors)
|
|
127
|
+
ctx.ui.notify(`${NAME}: could not delete always yes: ${error}`, "error");
|
|
128
|
+
return removed;
|
|
129
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
interface ClipboardImage {
|
|
7
|
+
bytes: Uint8Array;
|
|
8
|
+
mimeType: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ClipboardReader {
|
|
12
|
+
readClipboardImage: () => Promise<ClipboardImage | null>;
|
|
13
|
+
readClipboardText: () => Promise<string | null>;
|
|
14
|
+
extensionForImageMimeType: (mimeType: string) => string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let loaded: Promise<ClipboardReader | null> | undefined;
|
|
18
|
+
|
|
19
|
+
function loadClipboard(): Promise<ClipboardReader | null> {
|
|
20
|
+
loaded ??= (async () => {
|
|
21
|
+
try {
|
|
22
|
+
const root = import.meta.resolve("@earendil-works/pi-coding-agent");
|
|
23
|
+
const images = await import(new URL("./utils/clipboard-image.js", root).href);
|
|
24
|
+
const text = await import(new URL("./utils/clipboard.js", root).href);
|
|
25
|
+
return {
|
|
26
|
+
readClipboardImage: images.readClipboardImage,
|
|
27
|
+
extensionForImageMimeType: images.extensionForImageMimeType,
|
|
28
|
+
readClipboardText: text.readClipboardText,
|
|
29
|
+
};
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
return loaded;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function readClipboard(): Promise<string | null> {
|
|
39
|
+
const clipboard = await loadClipboard();
|
|
40
|
+
if (!clipboard) return null;
|
|
41
|
+
|
|
42
|
+
const image = await clipboard.readClipboardImage();
|
|
43
|
+
if (image) {
|
|
44
|
+
const extension = clipboard.extensionForImageMimeType(image.mimeType) ?? "png";
|
|
45
|
+
const path = join(tmpdir(), `pi-ask-permission-${randomUUID()}.${extension}`);
|
|
46
|
+
writeFileSync(path, Buffer.from(image.bytes));
|
|
47
|
+
return path;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return clipboard.readClipboardText();
|
|
51
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface Choice {
|
|
2
|
+
key: string;
|
|
3
|
+
decision: "allow" | "deny";
|
|
4
|
+
always: boolean;
|
|
5
|
+
label: string;
|
|
6
|
+
tone: "success" | "warning" | "error";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const CHOICES: Choice[] = [
|
|
10
|
+
{ key: "1", decision: "allow", always: false, label: "yes", tone: "success" },
|
|
11
|
+
{ key: "2", decision: "allow", always: true, label: "always yes", tone: "warning" },
|
|
12
|
+
{ key: "3", decision: "deny", always: false, label: "deny", tone: "error" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const FALLBACK_CHOICES: (Choice & { note: boolean })[] = CHOICES.flatMap((option, index) => [
|
|
16
|
+
{ ...option, key: String(index * 2 + 1), note: false },
|
|
17
|
+
{
|
|
18
|
+
...option,
|
|
19
|
+
key: String(index * 2 + 2),
|
|
20
|
+
note: true,
|
|
21
|
+
label: `${option.label}, ${option.decision === "allow" ? "with a note" : "with a reason"}`,
|
|
22
|
+
},
|
|
23
|
+
]);
|