@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/index.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { createApp, defineFeature } from "@adeildo/pi-kit";
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
|
|
4
|
+
import { PERMISSION_SETTINGS, readConfig, SECTIONS } from "#core/config/settings.ts";
|
|
4
5
|
import { listenForTools } from "#pi/api.ts";
|
|
5
6
|
import { registerBashTimer } from "#pi/bash-timer.ts";
|
|
6
|
-
import {
|
|
7
|
+
import { registerShortcuts } from "#pi/commands.ts";
|
|
7
8
|
import { registerEvents } from "#pi/events.ts";
|
|
8
9
|
import { registerTypesafeProvider } from "#pi/provider.ts";
|
|
10
|
+
import { registerScreen } from "#pi/screen.ts";
|
|
9
11
|
import { createSession } from "#pi/session.ts";
|
|
10
12
|
import { registerJudgeEntry } from "#ui/judge-entry.ts";
|
|
11
13
|
|
|
12
14
|
export const permission = defineFeature({
|
|
13
15
|
id: "permission",
|
|
14
16
|
description: "Ask before a tool call runs, with a judge model for the routine ones",
|
|
17
|
+
sections: SECTIONS,
|
|
18
|
+
settings: PERMISSION_SETTINGS,
|
|
15
19
|
setup(scope) {
|
|
16
20
|
registerBashTimer(scope);
|
|
17
21
|
registerTypesafeProvider(scope);
|
|
@@ -20,7 +24,16 @@ export const permission = defineFeature({
|
|
|
20
24
|
const session = createSession();
|
|
21
25
|
registerEvents(scope, session);
|
|
22
26
|
listenForTools(scope, session.customTools);
|
|
23
|
-
|
|
27
|
+
registerShortcuts(scope, session);
|
|
28
|
+
registerScreen(scope, session);
|
|
29
|
+
|
|
30
|
+
// A change on the screen, or in another pi, applies to the next call.
|
|
31
|
+
for (const entry of PERMISSION_SETTINGS) {
|
|
32
|
+
entry.listen(scope, () => {
|
|
33
|
+
session.config = readConfig(scope);
|
|
34
|
+
session.judgeCache.clear();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
24
37
|
},
|
|
25
38
|
});
|
|
26
39
|
|
package/src/pi/commands.ts
CHANGED
|
@@ -1,193 +1,21 @@
|
|
|
1
|
+
// Two keys for what a session changes the most. Everything else is on the settings screen.
|
|
1
2
|
import type { FeatureScope } from "@adeildo/pi-kit";
|
|
2
|
-
import
|
|
3
|
-
import { type AutocompleteItem, Key } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { probeJudge } from "#core/judge/probe.ts";
|
|
8
|
-
import { judgeLogText } from "#core/judge/report.ts";
|
|
9
|
-
import { MODE_DESCRIPTION, nextMode, parseMode, PERMISSION_MODES } from "#core/mode.ts";
|
|
5
|
+
import { nextMode, PERMISSION_MODES } from "#core/mode.ts";
|
|
6
|
+
import { toggleOutside } from "#core/workspace.ts";
|
|
10
7
|
import { NAME } from "#identity";
|
|
11
|
-
import { setSessionMode } from "#pi/mode.ts";
|
|
12
|
-
import {
|
|
13
|
-
import { alwaysYesCount, openSettings } from "#ui/settings/screen.ts";
|
|
14
|
-
import { notifyJudgePolicyWarning, statusText } from "#ui/settings/status.ts";
|
|
15
|
-
|
|
16
|
-
const JUDGE_STATUS = `${NAME}:judge`;
|
|
17
|
-
|
|
18
|
-
export function registerCommands(scope: FeatureScope, state: SessionState): void {
|
|
19
|
-
const openSettingsFor = (ctx: ExtensionContext): Promise<void> =>
|
|
20
|
-
openSettings(ctx, {
|
|
21
|
-
config: state.config,
|
|
22
|
-
alwaysYes: state.alwaysYes,
|
|
23
|
-
mode: () => state.mode,
|
|
24
|
-
setMode: (mode) => setSessionMode(scope, state, mode, ctx, false),
|
|
25
|
-
save: () => saveConfig(scope, state, ctx),
|
|
26
|
-
onJudgeChange: () => state.judgeCache.clear(),
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
function notifyStatus(ctx: ExtensionContext): void {
|
|
30
|
-
ctx.ui.notify(statusText(state.config, state.alwaysYes, ctx.cwd, state.mode), "info");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function cycleMode(ctx: ExtensionContext): void {
|
|
34
|
-
setSessionMode(scope, state, nextMode(state.mode), ctx);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function runJudgeProbe(ctx: ExtensionContext): Promise<void> {
|
|
38
|
-
ctx.ui.setStatus(JUDGE_STATUS, "judge: testing\u2026");
|
|
39
|
-
const auth = ctx.modelRegistry.getProviderAuthStatus(TYPESAFE_PROVIDER);
|
|
40
|
-
const probe = await probeJudge(
|
|
41
|
-
state.config.judge,
|
|
42
|
-
{
|
|
43
|
-
resolveApiKey: () => ctx.modelRegistry.getApiKeyForProvider(TYPESAFE_PROVIDER),
|
|
44
|
-
modelRegistry: ctx.modelRegistry,
|
|
45
|
-
},
|
|
46
|
-
ctx.signal,
|
|
47
|
-
).finally(() => ctx.ui.setStatus(JUDGE_STATUS, undefined));
|
|
48
|
-
|
|
49
|
-
if (!probe.ok) {
|
|
50
|
-
const source = auth.configured
|
|
51
|
-
? `key from ${auth.label ?? auth.source}`
|
|
52
|
-
: "no key configured";
|
|
53
|
-
const suffix = state.config.judge.provider === "jev" ? ` (${source})` : "";
|
|
54
|
-
ctx.ui.notify(`${NAME}: judge test failed: ${probe.detail}${suffix}`, "error");
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
resetJudgeHealth(state);
|
|
59
|
-
|
|
60
|
-
const summary = `judge test ok \u00b7 ${probe.model ?? state.config.judge.model} \u00b7 ${probe.elapsedMs}ms`;
|
|
61
|
-
if (probe.elapsedMs > state.config.judge.timeoutMs) {
|
|
62
|
-
ctx.ui.notify(
|
|
63
|
-
`${NAME}: ${summary}. Slower than the ${state.config.judge.timeoutMs}ms timeout, raise judge.timeoutMs`,
|
|
64
|
-
"warning",
|
|
65
|
-
);
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
ctx.ui.notify(`${NAME}: ${summary}`, "info");
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function judgeCommand(ctx: ExtensionContext, argument: string | undefined): Promise<void> {
|
|
73
|
-
if (argument === "log") {
|
|
74
|
-
ctx.ui.notify(judgeLogText(state.judgeLog), "info");
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (argument === "test") {
|
|
79
|
-
await runJudgeProbe(ctx);
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (argument === "on" || argument === "off") {
|
|
84
|
-
state.config.judge.enabled = argument === "on";
|
|
85
|
-
saveConfig(scope, state, ctx);
|
|
86
|
-
state.judgeCache.clear();
|
|
87
|
-
if (argument === "on") {
|
|
88
|
-
resetJudgeHealth(state);
|
|
89
|
-
notifyJudgePolicyWarning(state.config, ctx);
|
|
90
|
-
}
|
|
91
|
-
ctx.ui.notify(`${NAME}: judge ${argument}`, "info");
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
await settingsOrStatus(ctx);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function modeCommand(ctx: ExtensionContext, argument: string | undefined): void {
|
|
99
|
-
if (argument === undefined) {
|
|
100
|
-
cycleMode(ctx);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const mode = parseMode(argument);
|
|
105
|
-
if (!mode) {
|
|
106
|
-
ctx.ui.notify(`${NAME}: mode takes ${PERMISSION_MODES.join(", ")}`, "warning");
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
setSessionMode(scope, state, mode, ctx);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function forgetCommand(ctx: ExtensionContext, argument = "session"): void {
|
|
114
|
-
const level = FORGET_SCOPES[argument];
|
|
115
|
-
if (!level) {
|
|
116
|
-
ctx.ui.notify(`${NAME}: forget takes ${Object.keys(FORGET_SCOPES).join(", ")}`, "warning");
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const where = level === "all" ? "every scope" : SCOPE_LABEL[level];
|
|
121
|
-
const removed = alwaysYesCount(forgetAlwaysYes(scope, state, ctx, level));
|
|
122
|
-
ctx.ui.notify(`${NAME}: forgot ${removed} from ${where}`, "info");
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async function settingsOrStatus(ctx: ExtensionContext): Promise<void> {
|
|
126
|
-
if (ctx.mode === "tui") await openSettingsFor(ctx);
|
|
127
|
-
else notifyStatus(ctx);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
scope.registerCommand("perm", {
|
|
131
|
-
description: `${NAME}: settings, mode, status, forget, judge`,
|
|
132
|
-
getArgumentCompletions: (prefix) => {
|
|
133
|
-
const typed = prefix.trimStart().toLowerCase();
|
|
134
|
-
const matches = SUBCOMMANDS.filter((command) => command.value.startsWith(typed));
|
|
135
|
-
return matches.length > 0 ? matches : null;
|
|
136
|
-
},
|
|
137
|
-
handler: async (args, ctx) => {
|
|
138
|
-
const [verb, argument] = args.trim().toLowerCase().split(/\s+/);
|
|
139
|
-
|
|
140
|
-
switch (verb) {
|
|
141
|
-
case "":
|
|
142
|
-
await settingsOrStatus(ctx);
|
|
143
|
-
return;
|
|
144
|
-
case "mode":
|
|
145
|
-
modeCommand(ctx, argument);
|
|
146
|
-
return;
|
|
147
|
-
case "status":
|
|
148
|
-
notifyStatus(ctx);
|
|
149
|
-
return;
|
|
150
|
-
case "forget":
|
|
151
|
-
forgetCommand(ctx, argument);
|
|
152
|
-
return;
|
|
153
|
-
case "judge":
|
|
154
|
-
await judgeCommand(ctx, argument);
|
|
155
|
-
return;
|
|
156
|
-
default:
|
|
157
|
-
ctx.ui.notify(`${NAME}: /perm takes ${VERBS.join(", ")}`, "warning");
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
});
|
|
8
|
+
import { setSessionMode, setSessionOutside } from "#pi/mode.ts";
|
|
9
|
+
import type { SessionState } from "#pi/session.ts";
|
|
161
10
|
|
|
11
|
+
export function registerShortcuts(scope: FeatureScope, state: SessionState): void {
|
|
162
12
|
scope.registerShortcut(Key.alt("m"), {
|
|
163
|
-
description: `${NAME}: cycle mode (
|
|
164
|
-
handler:
|
|
13
|
+
description: `${NAME}: cycle mode (${PERMISSION_MODES.join(", ")})`,
|
|
14
|
+
handler: (ctx) => setSessionMode(scope, state, nextMode(state.mode), ctx),
|
|
165
15
|
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const FORGET_SCOPES: Record<string, Scope | "all"> = {
|
|
169
|
-
session: "session",
|
|
170
|
-
project: "project",
|
|
171
|
-
everywhere: "global",
|
|
172
|
-
all: "all",
|
|
173
|
-
};
|
|
174
16
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
suggestion("forget", "Forget this session's always yes"),
|
|
180
|
-
suggestion("forget project", "Forget this project's always yes"),
|
|
181
|
-
suggestion("forget everywhere", "Forget the always yes that applies everywhere"),
|
|
182
|
-
suggestion("forget all", "Forget every always yes"),
|
|
183
|
-
suggestion("judge on", "Turn the judge on"),
|
|
184
|
-
suggestion("judge off", "Turn the judge off"),
|
|
185
|
-
suggestion("judge log", "Show this session's judge decisions"),
|
|
186
|
-
suggestion("judge test", "Send one real request and report the result"),
|
|
187
|
-
];
|
|
188
|
-
|
|
189
|
-
function suggestion(value: string, description: string): AutocompleteItem {
|
|
190
|
-
return { value, label: value, description };
|
|
17
|
+
scope.registerShortcut(Key.alt("w"), {
|
|
18
|
+
description: `${NAME}: ask or allow calls outside the workspace, this session`,
|
|
19
|
+
handler: (ctx) => setSessionOutside(scope, state, toggleOutside(state.outside), ctx),
|
|
20
|
+
});
|
|
191
21
|
}
|
|
192
|
-
|
|
193
|
-
const VERBS = ["mode", "status", "forget", "judge"];
|
package/src/pi/events.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { canAsk, type FeatureScope } from "@adeildo/pi-kit";
|
|
2
2
|
import {
|
|
3
3
|
type ExtensionAPI,
|
|
4
4
|
type ExtensionContext,
|
|
@@ -7,27 +7,32 @@ import {
|
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
|
|
9
9
|
import { SCOPE_LABEL } from "#core/always-yes.ts";
|
|
10
|
-
import type { DialogAnswer } from "#core/answer.ts";
|
|
10
|
+
import type { DialogAnswer, FolderOffer } from "#core/answer.ts";
|
|
11
11
|
import { noUIMode } from "#core/config/patterns.ts";
|
|
12
12
|
import {
|
|
13
|
+
accessOf,
|
|
13
14
|
type Call,
|
|
14
15
|
decide,
|
|
15
16
|
describeCall,
|
|
16
17
|
gateLayers,
|
|
17
|
-
type Layer,
|
|
18
18
|
type Verdict,
|
|
19
19
|
} from "#core/decide.ts";
|
|
20
20
|
import { judgeGate } from "#core/judge/gate.ts";
|
|
21
21
|
import { judgeVerdictText, remember, warnOnce } from "#core/judge/report.ts";
|
|
22
|
-
import type
|
|
22
|
+
import { describeHints, type ToolFacts } from "#core/mcp.ts";
|
|
23
|
+
import { MODES } from "#core/mode.ts";
|
|
24
|
+
import { shortenHome } from "#core/tools.ts";
|
|
25
|
+
import { folderChoices } from "#core/workspace.ts";
|
|
23
26
|
import { NAME } from "#identity";
|
|
24
27
|
import { announce, type Decided } from "#pi/api.ts";
|
|
25
|
-
import {
|
|
28
|
+
import { currentIntent } from "#pi/intent.ts";
|
|
29
|
+
import { clearStatus, notifyJudgePolicyWarning, renderStatus } from "#pi/mode.ts";
|
|
26
30
|
import { type FileChange, type PendingWrites, previewEdit, previewWrite } from "#pi/preview.ts";
|
|
27
31
|
import { restoreSession } from "#pi/session-entries.ts";
|
|
28
32
|
import {
|
|
29
33
|
loadSessionConfig,
|
|
30
34
|
openAlwaysYes,
|
|
35
|
+
openFolder,
|
|
31
36
|
noteJudgeFailure,
|
|
32
37
|
rememberAlwaysYes,
|
|
33
38
|
resetJudgeHealth,
|
|
@@ -35,8 +40,8 @@ import {
|
|
|
35
40
|
} from "#pi/session.ts";
|
|
36
41
|
import { AskDialog } from "#ui/dialog.ts";
|
|
37
42
|
import { appendJudgeEntry } from "#ui/judge-entry.ts";
|
|
43
|
+
import { askThroughQuestions, type AskExtras } from "#ui/questions.ts";
|
|
38
44
|
import { askViaSelector } from "#ui/selector.ts";
|
|
39
|
-
import { notifyJudgePolicyWarning } from "#ui/settings/status.ts";
|
|
40
45
|
|
|
41
46
|
const JUDGE_STATUS = `${NAME}:judge`;
|
|
42
47
|
const TYPING_STATUS = "waiting for you to finish typing";
|
|
@@ -46,29 +51,27 @@ export function registerEvents(scope: FeatureScope, state: SessionState): void {
|
|
|
46
51
|
loadSessionConfig(scope, state, ctx);
|
|
47
52
|
openAlwaysYes(state, ctx);
|
|
48
53
|
restoreSession(state, ctx);
|
|
49
|
-
notifyJudgePolicyWarning(state
|
|
50
|
-
|
|
54
|
+
notifyJudgePolicyWarning(state, ctx);
|
|
55
|
+
renderStatus(ctx, state);
|
|
51
56
|
state.typing.start(ctx);
|
|
52
57
|
});
|
|
53
58
|
|
|
54
59
|
scope.on("session_tree", (_event, ctx) => {
|
|
55
60
|
restoreSession(state, ctx);
|
|
56
|
-
|
|
61
|
+
renderStatus(ctx, state);
|
|
57
62
|
});
|
|
58
63
|
|
|
59
64
|
scope.on("session_shutdown", (_event, ctx) => {
|
|
60
|
-
|
|
65
|
+
clearStatus(ctx);
|
|
61
66
|
state.typing.stop();
|
|
62
67
|
});
|
|
63
68
|
|
|
64
69
|
scope.on("tool_call", async (event, ctx) => {
|
|
65
|
-
const call = describeCall(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
state.customTools,
|
|
71
|
-
);
|
|
70
|
+
const call = describeCall(event.toolName, event.input, ctx.cwd, state.config, {
|
|
71
|
+
custom: state.customTools,
|
|
72
|
+
facts: toolFacts(scope),
|
|
73
|
+
nested: event.parentToolCallId !== undefined,
|
|
74
|
+
});
|
|
72
75
|
const outcome = await gate(scope, state, ctx, call, event);
|
|
73
76
|
|
|
74
77
|
announce(scope, {
|
|
@@ -101,6 +104,12 @@ export function registerEvents(scope: FeatureScope, state: SessionState): void {
|
|
|
101
104
|
|
|
102
105
|
type Outcome = Pick<Decided, "action" | "by" | "reason" | "note">;
|
|
103
106
|
|
|
107
|
+
/** What pi reports about a tool beside its name: the server it belongs to, and its own hints. */
|
|
108
|
+
function toolFacts(pi: ExtensionAPI): ToolFacts {
|
|
109
|
+
const tools = pi.getAllTools();
|
|
110
|
+
return (toolName) => tools.find((tool) => tool.name === toolName);
|
|
111
|
+
}
|
|
112
|
+
|
|
104
113
|
async function gate(
|
|
105
114
|
pi: ExtensionAPI,
|
|
106
115
|
state: SessionState,
|
|
@@ -108,8 +117,11 @@ async function gate(
|
|
|
108
117
|
call: Call,
|
|
109
118
|
event: ToolCallEvent,
|
|
110
119
|
): Promise<Outcome> {
|
|
111
|
-
const
|
|
112
|
-
|
|
120
|
+
const layers = gateLayers(state);
|
|
121
|
+
if (MODES[state.mode].judge) {
|
|
122
|
+
layers.push({ name: "judge", decide: (next) => runJudge(state, pi, ctx, next) });
|
|
123
|
+
}
|
|
124
|
+
const decision = await decide(call, layers);
|
|
113
125
|
|
|
114
126
|
if (decision.action === "allow") return { action: "allow", by: decision.by };
|
|
115
127
|
if (decision.action === "block")
|
|
@@ -129,9 +141,13 @@ async function gate(
|
|
|
129
141
|
});
|
|
130
142
|
|
|
131
143
|
state.typing.pause();
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
144
|
+
const reason = "reason" in decision ? decision.reason : undefined;
|
|
145
|
+
const leaving = "by" in decision && decision.by === "workspace";
|
|
146
|
+
const answer = await ask(pi, ctx, call, {
|
|
147
|
+
diff: change?.diff,
|
|
148
|
+
reason,
|
|
149
|
+
offer: leaving ? offerFor(call) : undefined,
|
|
150
|
+
}).finally(() => state.typing.resume());
|
|
135
151
|
|
|
136
152
|
if (answer.decision === "deny") {
|
|
137
153
|
return { action: "block", by: "you", reason: denyReason(answer.note), note: answer.note };
|
|
@@ -141,11 +157,22 @@ async function gate(
|
|
|
141
157
|
const scope = answer.scope ?? "session";
|
|
142
158
|
rememberAlwaysYes(pi, state, ctx, scope, call.toolName, answer.remember);
|
|
143
159
|
ctx.ui.notify(
|
|
144
|
-
`${NAME}: always yes for ${call.toolName}
|
|
160
|
+
`${NAME}: always yes for ${call.toolName}: ${answer.remember} (${SCOPE_LABEL[scope]})`,
|
|
145
161
|
"info",
|
|
146
162
|
);
|
|
147
163
|
}
|
|
148
164
|
|
|
165
|
+
if (answer.open) {
|
|
166
|
+
const { path, access, scope } = answer.open;
|
|
167
|
+
openFolder(pi, state, ctx, scope, path, access);
|
|
168
|
+
renderStatus(ctx, state);
|
|
169
|
+
const what =
|
|
170
|
+
access === "read"
|
|
171
|
+
? `reads open in ${shortenHome(path)}`
|
|
172
|
+
: `${shortenHome(path)} joins the workspace`;
|
|
173
|
+
ctx.ui.notify(`${NAME}: ${what}, ${SCOPE_LABEL[scope]}`, "info");
|
|
174
|
+
}
|
|
175
|
+
|
|
149
176
|
if (change)
|
|
150
177
|
state.pendingWrites.set(change.path, { toolCallId: event.toolCallId, after: change.after });
|
|
151
178
|
return { action: "allow", by: "you", note: answer.note };
|
|
@@ -175,6 +202,9 @@ async function runJudge(
|
|
|
175
202
|
toolName: call.toolName,
|
|
176
203
|
target: call.target,
|
|
177
204
|
rawInput: call.input,
|
|
205
|
+
source: judgeSource(call),
|
|
206
|
+
toolDescription: call.description,
|
|
207
|
+
intent: currentIntent(ctx),
|
|
178
208
|
cache: state.judgeCache,
|
|
179
209
|
onStatus: (status) => ctx.ui.setStatus(JUDGE_STATUS, status),
|
|
180
210
|
});
|
|
@@ -210,12 +240,38 @@ async function runJudge(
|
|
|
210
240
|
return undefined;
|
|
211
241
|
}
|
|
212
242
|
|
|
243
|
+
// What the tool name does not say: the server behind an MCP tool and what it declares, and a call
|
|
244
|
+
// that a script issued rather than the model. The judge reads both as data.
|
|
245
|
+
function judgeSource(call: Call): string | undefined {
|
|
246
|
+
const parts: string[] = [];
|
|
247
|
+
if (call.mcp) {
|
|
248
|
+
parts.push(
|
|
249
|
+
`MCP server "${call.mcp.server}", which declares the tool ${describeHints(call.hints)}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (call.nested) parts.push("issued by a codemode script, not by the model");
|
|
253
|
+
return parts.length === 0 ? undefined : parts.join("; ");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// No offer when folderChoices finds nothing to open, like a path in the home itself.
|
|
257
|
+
function offerFor(call: Call): FolderOffer | undefined {
|
|
258
|
+
if (call.reach.kind !== "outside") return undefined;
|
|
259
|
+
const choices = folderChoices(call.reach.paths);
|
|
260
|
+
return choices && { ...choices, access: accessOf(call) };
|
|
261
|
+
}
|
|
262
|
+
|
|
213
263
|
async function ask(
|
|
264
|
+
pi: ExtensionAPI,
|
|
214
265
|
ctx: ExtensionContext,
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
diff: string | undefined,
|
|
266
|
+
call: Call,
|
|
267
|
+
extras: AskExtras,
|
|
218
268
|
): Promise<DialogAnswer> {
|
|
269
|
+
const { toolName, target } = call;
|
|
270
|
+
// One dialog for both: the questions feature draws this when it is installed.
|
|
271
|
+
if (ctx.mode === "tui" && canAsk(pi.events)) {
|
|
272
|
+
const answer = await askThroughQuestions(pi.events, call, extras);
|
|
273
|
+
if (answer !== undefined) return answer;
|
|
274
|
+
}
|
|
219
275
|
if (ctx.mode === "tui") {
|
|
220
276
|
try {
|
|
221
277
|
const answer = await ctx.ui.custom<DialogAnswer>(
|
|
@@ -224,7 +280,10 @@ async function ask(
|
|
|
224
280
|
theme,
|
|
225
281
|
toolName,
|
|
226
282
|
target,
|
|
227
|
-
|
|
283
|
+
mcp: call.mcp,
|
|
284
|
+
hints: call.hints,
|
|
285
|
+
nested: call.nested,
|
|
286
|
+
...extras,
|
|
228
287
|
keybindings,
|
|
229
288
|
requestRender: () => tui.requestRender(),
|
|
230
289
|
complete: done,
|
|
@@ -236,7 +295,7 @@ async function ask(
|
|
|
236
295
|
}
|
|
237
296
|
}
|
|
238
297
|
|
|
239
|
-
return askViaSelector(ctx, toolName, target);
|
|
298
|
+
return askViaSelector(ctx, toolName, target, extras.offer);
|
|
240
299
|
}
|
|
241
300
|
|
|
242
301
|
function sendNote(pi: ExtensionAPI, note: string, toolName: string): void {
|
package/src/pi/intent.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// What the user is after, for the judge. Today the last thing they typed; a service that knows the
|
|
2
|
+
// current intent better takes this place later, and the judge does not change.
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { isRecord } from "#util/primitives.ts";
|
|
6
|
+
|
|
7
|
+
export function currentIntent(ctx: Pick<ExtensionContext, "sessionManager">): string | undefined {
|
|
8
|
+
const branch: readonly unknown[] = ctx.sessionManager.getBranch();
|
|
9
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
10
|
+
const text = userText(branch[index]);
|
|
11
|
+
if (text !== undefined) return text;
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function userText(entry: unknown): string | undefined {
|
|
17
|
+
if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) return undefined;
|
|
18
|
+
if (entry.message.role !== "user") return undefined;
|
|
19
|
+
|
|
20
|
+
return contentText(entry.message.content).trim() || undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A message holds a string, or parts where only the text ones say anything. */
|
|
24
|
+
function contentText(content: unknown): string {
|
|
25
|
+
if (typeof content === "string") return content;
|
|
26
|
+
if (!Array.isArray(content)) return "";
|
|
27
|
+
|
|
28
|
+
const texts: string[] = [];
|
|
29
|
+
for (const part of content) {
|
|
30
|
+
if (isRecord(part) && part.type === "text" && typeof part.text === "string")
|
|
31
|
+
texts.push(part.text);
|
|
32
|
+
}
|
|
33
|
+
return texts.join("\n");
|
|
34
|
+
}
|
package/src/pi/mode.ts
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
import type { OutsideScope } from "#core/config/schema.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { policyWarning } from "#core/judge/policy.ts";
|
|
5
|
+
import { MODES, type PermissionMode } from "#core/mode.ts";
|
|
6
|
+
import { OUTSIDE_DESCRIPTION } from "#core/workspace.ts";
|
|
5
7
|
import { NAME } from "#identity";
|
|
6
8
|
import { record } from "#pi/session-entries.ts";
|
|
7
9
|
import type { SessionState } from "#pi/session.ts";
|
|
8
10
|
|
|
9
11
|
export const MODE_STATUS = `${NAME}:mode`;
|
|
10
12
|
|
|
13
|
+
const ARROW: Record<PermissionMode, string> = {
|
|
14
|
+
manual: "",
|
|
15
|
+
edits: "\u23f5 ",
|
|
16
|
+
judge: "\u23f5 ",
|
|
17
|
+
full: "\u23f5\u23f5 ",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const OUTSIDE_TAG: Record<OutsideScope, string | undefined> = {
|
|
21
|
+
ask: undefined,
|
|
22
|
+
allow: "anywhere",
|
|
23
|
+
deny: "workspace only",
|
|
24
|
+
};
|
|
25
|
+
|
|
11
26
|
export function setSessionMode(
|
|
12
27
|
pi: ExtensionAPI,
|
|
13
28
|
state: SessionState,
|
|
@@ -17,32 +32,64 @@ export function setSessionMode(
|
|
|
17
32
|
): void {
|
|
18
33
|
if (mode !== state.mode) record(pi, { kind: "mode", mode });
|
|
19
34
|
state.mode = mode;
|
|
20
|
-
|
|
35
|
+
renderStatus(ctx, state);
|
|
21
36
|
|
|
22
|
-
if (announce)
|
|
23
|
-
|
|
24
|
-
|
|
37
|
+
if (!announce) return;
|
|
38
|
+
ctx.ui.notify(`${NAME}: ${mode}, ${MODES[mode].description}`, "info");
|
|
39
|
+
notifyJudgePolicyWarning(state, ctx);
|
|
25
40
|
}
|
|
26
41
|
|
|
27
|
-
export function
|
|
42
|
+
export function setSessionOutside(
|
|
43
|
+
pi: ExtensionAPI,
|
|
44
|
+
state: SessionState,
|
|
45
|
+
outside: OutsideScope,
|
|
46
|
+
ctx: ExtensionContext,
|
|
47
|
+
announce = true,
|
|
48
|
+
): void {
|
|
49
|
+
if (outside !== state.outside) record(pi, { kind: "outside", outside });
|
|
50
|
+
state.outside = outside;
|
|
51
|
+
renderStatus(ctx, state);
|
|
52
|
+
|
|
53
|
+
if (!announce) return;
|
|
54
|
+
ctx.ui.notify(`${NAME}: outside ${outside}, ${OUTSIDE_DESCRIPTION[outside]}`, "info");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function notifyJudgePolicyWarning(
|
|
58
|
+
state: Pick<SessionState, "config" | "mode">,
|
|
59
|
+
ctx: ExtensionContext,
|
|
60
|
+
): void {
|
|
61
|
+
if (state.mode !== "judge") return;
|
|
62
|
+
const warning = policyWarning(state.config.judge.policy);
|
|
63
|
+
if (warning) ctx.ui.notify(`${NAME}: ${warning}`, "warning");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function clearStatus(ctx: ExtensionContext): void {
|
|
28
67
|
if (!ctx.hasUI) return;
|
|
29
68
|
ctx.ui.setStatus(MODE_STATUS, undefined);
|
|
30
69
|
}
|
|
31
70
|
|
|
32
|
-
export function
|
|
71
|
+
export function renderStatus(
|
|
33
72
|
ctx: ExtensionContext,
|
|
34
|
-
|
|
35
|
-
outside: OutsideScope,
|
|
73
|
+
state: Pick<SessionState, "mode" | "outside" | "folders">,
|
|
36
74
|
): void {
|
|
37
|
-
|
|
38
|
-
|
|
75
|
+
const tag = OUTSIDE_TAG[state.outside];
|
|
76
|
+
const folders = state.folders.total();
|
|
77
|
+
const quiet = state.mode === "manual" && tag === undefined;
|
|
78
|
+
if (quiet && folders === 0) {
|
|
79
|
+
clearStatus(ctx);
|
|
39
80
|
return;
|
|
40
81
|
}
|
|
41
82
|
if (!ctx.hasUI) return;
|
|
42
83
|
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
84
|
+
const theme = ctx.ui.theme;
|
|
85
|
+
const parts: string[] = [];
|
|
86
|
+
if (!quiet) {
|
|
87
|
+
const label = tag === undefined ? state.mode : `${state.mode} (${tag})`;
|
|
88
|
+
const color = state.outside === "allow" ? "error" : "warning";
|
|
89
|
+
parts.push(theme.fg(color, `${ARROW[state.mode]}${label}`));
|
|
90
|
+
}
|
|
91
|
+
// Open folders widen the workspace, so the status bar counts them.
|
|
92
|
+
if (folders > 0)
|
|
93
|
+
parts.push(theme.fg("dim", `+${folders} ${folders === 1 ? "folder" : "folders"}`));
|
|
94
|
+
ctx.ui.setStatus(MODE_STATUS, parts.join(" "));
|
|
48
95
|
}
|