@adeildo/pi-ask-permission 4.1.0 → 5.1.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 +1 -1
- package/README.md +114 -193
- package/assets/preview.png +0 -0
- package/package.json +3 -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 +19 -3
- package/src/pi/commands.ts +13 -185
- package/src/pi/events.ts +83 -46
- 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 +56 -12
- package/src/ui/judge-entry.ts +6 -16
- package/src/ui/questions.ts +194 -0
- package/src/ui/selector.ts +13 -5
- package/src/ui/clipboard.ts +0 -51
- package/src/ui/dialog.ts +0 -395
- package/src/ui/paste.ts +0 -27
- 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,22 @@
|
|
|
1
|
+
import { questions } from "@adeildo/pi-ask-questions";
|
|
1
2
|
import { createApp, defineFeature } from "@adeildo/pi-kit";
|
|
2
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
|
|
5
|
+
import { PERMISSION_SETTINGS, readConfig, SECTIONS } from "#core/config/settings.ts";
|
|
4
6
|
import { listenForTools } from "#pi/api.ts";
|
|
5
7
|
import { registerBashTimer } from "#pi/bash-timer.ts";
|
|
6
|
-
import {
|
|
8
|
+
import { registerShortcuts } from "#pi/commands.ts";
|
|
7
9
|
import { registerEvents } from "#pi/events.ts";
|
|
8
10
|
import { registerTypesafeProvider } from "#pi/provider.ts";
|
|
11
|
+
import { registerScreen } from "#pi/screen.ts";
|
|
9
12
|
import { createSession } from "#pi/session.ts";
|
|
10
13
|
import { registerJudgeEntry } from "#ui/judge-entry.ts";
|
|
11
14
|
|
|
12
15
|
export const permission = defineFeature({
|
|
13
16
|
id: "permission",
|
|
14
17
|
description: "Ask before a tool call runs, with a judge model for the routine ones",
|
|
18
|
+
sections: SECTIONS,
|
|
19
|
+
settings: PERMISSION_SETTINGS,
|
|
15
20
|
setup(scope) {
|
|
16
21
|
registerBashTimer(scope);
|
|
17
22
|
registerTypesafeProvider(scope);
|
|
@@ -20,10 +25,21 @@ export const permission = defineFeature({
|
|
|
20
25
|
const session = createSession();
|
|
21
26
|
registerEvents(scope, session);
|
|
22
27
|
listenForTools(scope, session.customTools);
|
|
23
|
-
|
|
28
|
+
registerShortcuts(scope, session);
|
|
29
|
+
registerScreen(scope, session);
|
|
30
|
+
|
|
31
|
+
// A change on the screen, or in another pi, applies to the next call.
|
|
32
|
+
for (const entry of PERMISSION_SETTINGS) {
|
|
33
|
+
entry.listen(scope, () => {
|
|
34
|
+
session.config = readConfig(scope);
|
|
35
|
+
session.judgeCache.clear();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
24
38
|
},
|
|
25
39
|
});
|
|
26
40
|
|
|
27
41
|
export default function piAskPermission(pi: ExtensionAPI): void {
|
|
28
|
-
|
|
42
|
+
// The dialog is the questions feature's. It comes with the package, and the kit keeps it from
|
|
43
|
+
// running twice when the questions package or the harness is installed as well.
|
|
44
|
+
createApp(pi, { name: "pi-ask-permission" }).use(questions).use(permission).build();
|
|
29
45
|
}
|
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,36 +7,40 @@ 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,
|
|
34
39
|
type SessionState,
|
|
35
40
|
} from "#pi/session.ts";
|
|
36
|
-
import { AskDialog } from "#ui/dialog.ts";
|
|
37
41
|
import { appendJudgeEntry } from "#ui/judge-entry.ts";
|
|
42
|
+
import { askThroughQuestions, type AskExtras } from "#ui/questions.ts";
|
|
38
43
|
import { askViaSelector } from "#ui/selector.ts";
|
|
39
|
-
import { notifyJudgePolicyWarning } from "#ui/settings/status.ts";
|
|
40
44
|
|
|
41
45
|
const JUDGE_STATUS = `${NAME}:judge`;
|
|
42
46
|
const TYPING_STATUS = "waiting for you to finish typing";
|
|
@@ -46,29 +50,27 @@ export function registerEvents(scope: FeatureScope, state: SessionState): void {
|
|
|
46
50
|
loadSessionConfig(scope, state, ctx);
|
|
47
51
|
openAlwaysYes(state, ctx);
|
|
48
52
|
restoreSession(state, ctx);
|
|
49
|
-
notifyJudgePolicyWarning(state
|
|
50
|
-
|
|
53
|
+
notifyJudgePolicyWarning(state, ctx);
|
|
54
|
+
renderStatus(ctx, state);
|
|
51
55
|
state.typing.start(ctx);
|
|
52
56
|
});
|
|
53
57
|
|
|
54
58
|
scope.on("session_tree", (_event, ctx) => {
|
|
55
59
|
restoreSession(state, ctx);
|
|
56
|
-
|
|
60
|
+
renderStatus(ctx, state);
|
|
57
61
|
});
|
|
58
62
|
|
|
59
63
|
scope.on("session_shutdown", (_event, ctx) => {
|
|
60
|
-
|
|
64
|
+
clearStatus(ctx);
|
|
61
65
|
state.typing.stop();
|
|
62
66
|
});
|
|
63
67
|
|
|
64
68
|
scope.on("tool_call", async (event, ctx) => {
|
|
65
|
-
const call = describeCall(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
state.customTools,
|
|
71
|
-
);
|
|
69
|
+
const call = describeCall(event.toolName, event.input, ctx.cwd, state.config, {
|
|
70
|
+
custom: state.customTools,
|
|
71
|
+
facts: toolFacts(scope),
|
|
72
|
+
nested: event.parentToolCallId !== undefined,
|
|
73
|
+
});
|
|
72
74
|
const outcome = await gate(scope, state, ctx, call, event);
|
|
73
75
|
|
|
74
76
|
announce(scope, {
|
|
@@ -101,6 +103,12 @@ export function registerEvents(scope: FeatureScope, state: SessionState): void {
|
|
|
101
103
|
|
|
102
104
|
type Outcome = Pick<Decided, "action" | "by" | "reason" | "note">;
|
|
103
105
|
|
|
106
|
+
/** What pi reports about a tool beside its name: the server it belongs to, and its own hints. */
|
|
107
|
+
function toolFacts(pi: ExtensionAPI): ToolFacts {
|
|
108
|
+
const tools = pi.getAllTools();
|
|
109
|
+
return (toolName) => tools.find((tool) => tool.name === toolName);
|
|
110
|
+
}
|
|
111
|
+
|
|
104
112
|
async function gate(
|
|
105
113
|
pi: ExtensionAPI,
|
|
106
114
|
state: SessionState,
|
|
@@ -108,8 +116,11 @@ async function gate(
|
|
|
108
116
|
call: Call,
|
|
109
117
|
event: ToolCallEvent,
|
|
110
118
|
): Promise<Outcome> {
|
|
111
|
-
const
|
|
112
|
-
|
|
119
|
+
const layers = gateLayers(state);
|
|
120
|
+
if (MODES[state.mode].judge) {
|
|
121
|
+
layers.push({ name: "judge", decide: (next) => runJudge(state, pi, ctx, next) });
|
|
122
|
+
}
|
|
123
|
+
const decision = await decide(call, layers);
|
|
113
124
|
|
|
114
125
|
if (decision.action === "allow") return { action: "allow", by: decision.by };
|
|
115
126
|
if (decision.action === "block")
|
|
@@ -129,9 +140,13 @@ async function gate(
|
|
|
129
140
|
});
|
|
130
141
|
|
|
131
142
|
state.typing.pause();
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
143
|
+
const reason = "reason" in decision ? decision.reason : undefined;
|
|
144
|
+
const leaving = "by" in decision && decision.by === "workspace";
|
|
145
|
+
const answer = await ask(pi, ctx, call, {
|
|
146
|
+
diff: change?.diff,
|
|
147
|
+
reason,
|
|
148
|
+
offer: leaving ? offerFor(call) : undefined,
|
|
149
|
+
}).finally(() => state.typing.resume());
|
|
135
150
|
|
|
136
151
|
if (answer.decision === "deny") {
|
|
137
152
|
return { action: "block", by: "you", reason: denyReason(answer.note), note: answer.note };
|
|
@@ -141,11 +156,22 @@ async function gate(
|
|
|
141
156
|
const scope = answer.scope ?? "session";
|
|
142
157
|
rememberAlwaysYes(pi, state, ctx, scope, call.toolName, answer.remember);
|
|
143
158
|
ctx.ui.notify(
|
|
144
|
-
`${NAME}: always yes for ${call.toolName}
|
|
159
|
+
`${NAME}: always yes for ${call.toolName}: ${answer.remember} (${SCOPE_LABEL[scope]})`,
|
|
145
160
|
"info",
|
|
146
161
|
);
|
|
147
162
|
}
|
|
148
163
|
|
|
164
|
+
if (answer.open) {
|
|
165
|
+
const { path, access, scope } = answer.open;
|
|
166
|
+
openFolder(pi, state, ctx, scope, path, access);
|
|
167
|
+
renderStatus(ctx, state);
|
|
168
|
+
const what =
|
|
169
|
+
access === "read"
|
|
170
|
+
? `reads open in ${shortenHome(path)}`
|
|
171
|
+
: `${shortenHome(path)} joins the workspace`;
|
|
172
|
+
ctx.ui.notify(`${NAME}: ${what}, ${SCOPE_LABEL[scope]}`, "info");
|
|
173
|
+
}
|
|
174
|
+
|
|
149
175
|
if (change)
|
|
150
176
|
state.pendingWrites.set(change.path, { toolCallId: event.toolCallId, after: change.after });
|
|
151
177
|
return { action: "allow", by: "you", note: answer.note };
|
|
@@ -175,6 +201,9 @@ async function runJudge(
|
|
|
175
201
|
toolName: call.toolName,
|
|
176
202
|
target: call.target,
|
|
177
203
|
rawInput: call.input,
|
|
204
|
+
source: judgeSource(call),
|
|
205
|
+
toolDescription: call.description,
|
|
206
|
+
intent: currentIntent(ctx),
|
|
178
207
|
cache: state.judgeCache,
|
|
179
208
|
onStatus: (status) => ctx.ui.setStatus(JUDGE_STATUS, status),
|
|
180
209
|
});
|
|
@@ -210,33 +239,41 @@ async function runJudge(
|
|
|
210
239
|
return undefined;
|
|
211
240
|
}
|
|
212
241
|
|
|
242
|
+
// What the tool name does not say: the server behind an MCP tool and what it declares, and a call
|
|
243
|
+
// that a script issued rather than the model. The judge reads both as data.
|
|
244
|
+
function judgeSource(call: Call): string | undefined {
|
|
245
|
+
const parts: string[] = [];
|
|
246
|
+
if (call.mcp) {
|
|
247
|
+
parts.push(
|
|
248
|
+
`MCP server "${call.mcp.server}", which declares the tool ${describeHints(call.hints)}`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (call.nested) parts.push("issued by a codemode script, not by the model");
|
|
252
|
+
return parts.length === 0 ? undefined : parts.join("; ");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// No offer when folderChoices finds nothing to open, like a path in the home itself.
|
|
256
|
+
function offerFor(call: Call): FolderOffer | undefined {
|
|
257
|
+
if (call.reach.kind !== "outside") return undefined;
|
|
258
|
+
const choices = folderChoices(call.reach.paths);
|
|
259
|
+
return choices && { ...choices, access: accessOf(call) };
|
|
260
|
+
}
|
|
261
|
+
|
|
213
262
|
async function ask(
|
|
263
|
+
pi: ExtensionAPI,
|
|
214
264
|
ctx: ExtensionContext,
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
diff: string | undefined,
|
|
265
|
+
call: Call,
|
|
266
|
+
extras: AskExtras,
|
|
218
267
|
): Promise<DialogAnswer> {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
}
|
|
268
|
+
const { toolName, target } = call;
|
|
269
|
+
// The questions feature draws the dialog. Without it, and in RPC hosts that cannot draw a
|
|
270
|
+
// terminal component, the host's own selector asks.
|
|
271
|
+
if (ctx.mode === "tui" && canAsk(pi.events)) {
|
|
272
|
+
const answer = await askThroughQuestions(pi.events, call, extras);
|
|
273
|
+
if (answer !== undefined) return answer;
|
|
237
274
|
}
|
|
238
275
|
|
|
239
|
-
return askViaSelector(ctx, toolName, target);
|
|
276
|
+
return askViaSelector(ctx, toolName, target, extras.offer);
|
|
240
277
|
}
|
|
241
278
|
|
|
242
279
|
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
|
}
|