@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.
Files changed (53) hide show
  1. package/CONTRIBUTING.md +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +274 -0
  4. package/assets/preview.png +0 -0
  5. package/package.json +63 -0
  6. package/src/core/always-yes.ts +183 -0
  7. package/src/core/answer.ts +8 -0
  8. package/src/core/config/decode.ts +118 -0
  9. package/src/core/config/patterns.ts +51 -0
  10. package/src/core/config/schema.ts +72 -0
  11. package/src/core/config/settings.ts +374 -0
  12. package/src/core/config/store.ts +57 -0
  13. package/src/core/decide.ts +106 -0
  14. package/src/core/judge/backends/factory.ts +30 -0
  15. package/src/core/judge/backends/jev.ts +227 -0
  16. package/src/core/judge/backends/pi-model.ts +174 -0
  17. package/src/core/judge/compose.ts +91 -0
  18. package/src/core/judge/config.ts +60 -0
  19. package/src/core/judge/decode.ts +51 -0
  20. package/src/core/judge/gate.ts +70 -0
  21. package/src/core/judge/pipeline.ts +106 -0
  22. package/src/core/judge/policy.ts +114 -0
  23. package/src/core/judge/probe.ts +51 -0
  24. package/src/core/judge/report.ts +73 -0
  25. package/src/core/judge/request.ts +78 -0
  26. package/src/core/judge/types.ts +72 -0
  27. package/src/core/mode.ts +42 -0
  28. package/src/core/readonly-bash.ts +689 -0
  29. package/src/core/tools.ts +208 -0
  30. package/src/core/workspace.ts +66 -0
  31. package/src/identity.ts +5 -0
  32. package/src/index.ts +29 -0
  33. package/src/pi/api.ts +44 -0
  34. package/src/pi/bash-timer.ts +49 -0
  35. package/src/pi/commands.ts +193 -0
  36. package/src/pi/events.ts +260 -0
  37. package/src/pi/mode.ts +48 -0
  38. package/src/pi/preview.ts +82 -0
  39. package/src/pi/provider.ts +11 -0
  40. package/src/pi/session-entries.ts +74 -0
  41. package/src/pi/session.ts +129 -0
  42. package/src/ui/clipboard.ts +51 -0
  43. package/src/ui/decision-options.ts +23 -0
  44. package/src/ui/dialog.ts +395 -0
  45. package/src/ui/judge-entry.ts +130 -0
  46. package/src/ui/paste.ts +27 -0
  47. package/src/ui/picker.ts +90 -0
  48. package/src/ui/selector.ts +42 -0
  49. package/src/ui/settings/judge.ts +299 -0
  50. package/src/ui/settings/screen.ts +268 -0
  51. package/src/ui/settings/status.ts +53 -0
  52. package/src/ui/typing.ts +72 -0
  53. package/src/util/primitives.ts +11 -0
@@ -0,0 +1,208 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname } from "node:path";
3
+
4
+ import { commandWords, isReadOnlyCommand } from "#core/readonly-bash.ts";
5
+
6
+ export interface CallDescriptor {
7
+ summary: string;
8
+ levels: string[];
9
+ }
10
+
11
+ export type ToolInput = Record<string, unknown>;
12
+
13
+ export interface ToolAdapter {
14
+ describe(input: ToolInput): CallDescriptor;
15
+ /** Undefined counts as outside the workspace. */
16
+ paths(input: ToolInput): string[] | undefined;
17
+ edits?: boolean;
18
+ readOnly?(input: ToolInput): boolean;
19
+ }
20
+
21
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
22
+ const INPUT_SUMMARY_MAX = 400;
23
+
24
+ const bash: ToolAdapter = {
25
+ describe: commandTarget,
26
+ paths: (input) => commandPaths(text(input.command) ?? ""),
27
+ readOnly: (input) => isReadOnlyCommand(text(input.command) ?? ""),
28
+ };
29
+
30
+ const powershell: ToolAdapter = {
31
+ describe: commandTarget,
32
+ paths: () => undefined,
33
+ };
34
+
35
+ const fileReader: ToolAdapter = {
36
+ describe: (input) => {
37
+ const path = text(input.path) ?? "";
38
+ return { summary: path.trim() || "(no path)", levels: pathLevels(path) };
39
+ },
40
+ paths: (input) => [text(input.path)?.trim() || "."],
41
+ };
42
+
43
+ const fileWriter: ToolAdapter = { ...fileReader, edits: true };
44
+
45
+ const mcp: ToolAdapter = {
46
+ describe: mcpTarget,
47
+ paths: () => [],
48
+ };
49
+
50
+ const BUILT_IN: Record<string, ToolAdapter> = {
51
+ bash,
52
+ powershell,
53
+ read: fileReader,
54
+ grep: fileReader,
55
+ find: fileReader,
56
+ ls: fileReader,
57
+ write: fileWriter,
58
+ edit: fileWriter,
59
+ mcp,
60
+ };
61
+
62
+ export type CustomTools = ReadonlyMap<string, Partial<ToolAdapter>>;
63
+
64
+ export function toolAdapter(toolName: string, custom: CustomTools = new Map()): ToolAdapter {
65
+ const builtIn = BUILT_IN[toolName];
66
+ if (builtIn) return builtIn;
67
+
68
+ return {
69
+ describe: (input) => ({ summary: summarizeInput(input), levels: [toolName] }),
70
+ paths: () => [],
71
+ ...custom.get(toolName),
72
+ };
73
+ }
74
+
75
+ export function isBuiltInTool(toolName: string): boolean {
76
+ return toolName in BUILT_IN;
77
+ }
78
+
79
+ export function asToolInput(input: unknown): ToolInput {
80
+ return typeof input === "object" && input !== null ? (input as ToolInput) : {};
81
+ }
82
+
83
+ function commandTarget(input: ToolInput): CallDescriptor {
84
+ const command = text(input.command) ?? "";
85
+ return { summary: command.trim() || "(empty command)", levels: commandLevels(command) };
86
+ }
87
+
88
+ export function commandLevels(command: string): string[] {
89
+ const tokens = tokenize(command).filter((token) => !ASSIGNMENT.test(token));
90
+ const head = tokens[0];
91
+
92
+ const exact = command.trim();
93
+ if (head === undefined) return [exact || "(empty command)"];
94
+
95
+ const levels = [head];
96
+ if (tokens.length > 1) levels.push(tokens.slice(0, 2).join(" "));
97
+ if (!levels.includes(exact)) levels.push(exact);
98
+
99
+ return levels;
100
+ }
101
+
102
+ export function pathLevels(path: string): string[] {
103
+ const exact = path.trim();
104
+ if (!exact) return ["(no path)"];
105
+
106
+ const display = shortenHome(exact);
107
+ const directory = dirname(display);
108
+ if (directory === "." || directory === display) return [display];
109
+
110
+ return [directory, display];
111
+ }
112
+
113
+ export function commandPaths(command: string): string[] | undefined {
114
+ const words = commandWords(command);
115
+ if (words === undefined) return undefined;
116
+
117
+ const paths: string[] = [];
118
+ for (const word of words) {
119
+ const path = pathValue(word);
120
+ if (path !== undefined) paths.push(path);
121
+ }
122
+ return paths;
123
+ }
124
+
125
+ function pathValue(word: string): string | undefined {
126
+ const value = word.startsWith("-") ? word.slice(word.indexOf("=") + 1) : word;
127
+ if (value === "" || value.startsWith("-")) return undefined;
128
+ return /^(~|\/|\.\.?$|\.\.?\/)/.test(value) || value.includes("/") ? value : undefined;
129
+ }
130
+
131
+ export function shortenHome(path: string): string {
132
+ const home = homedir();
133
+ if (!home) return path;
134
+ if (path === home) return "~";
135
+ if (path.startsWith(`${home}/`) || path.startsWith(`${home}\\`))
136
+ return `~${path.slice(home.length)}`;
137
+ return path;
138
+ }
139
+
140
+ export function summarizeInput(input: ToolInput): string {
141
+ const keys = Object.keys(input);
142
+ if (keys.length === 0) return "(no input)";
143
+
144
+ try {
145
+ const json = JSON.stringify(input);
146
+ return json.length > INPUT_SUMMARY_MAX ? `${json.slice(0, INPUT_SUMMARY_MAX - 3)}...` : json;
147
+ } catch {
148
+ return `{ ${keys.join(", ")} }`;
149
+ }
150
+ }
151
+
152
+ export function tokenize(command: string): string[] {
153
+ const tokens: string[] = [];
154
+ let current = "";
155
+ let quote: '"' | "'" | null = null;
156
+
157
+ for (let index = 0; index < command.length; index++) {
158
+ const char = command[index];
159
+ if (char === undefined) break;
160
+
161
+ if (quote) {
162
+ if (char === quote) quote = null;
163
+ else current += char;
164
+ continue;
165
+ }
166
+
167
+ if (char === '"' || char === "'") {
168
+ quote = char;
169
+ continue;
170
+ }
171
+
172
+ if (char === "\\" && index + 1 < command.length) {
173
+ const escaped = command[index + 1];
174
+ if (escaped !== undefined) current += escaped;
175
+ index++;
176
+ continue;
177
+ }
178
+
179
+ if (/\s/.test(char)) {
180
+ if (current) {
181
+ tokens.push(current);
182
+ current = "";
183
+ }
184
+ continue;
185
+ }
186
+
187
+ current += char;
188
+ }
189
+
190
+ if (current) tokens.push(current);
191
+ return tokens;
192
+ }
193
+
194
+ function mcpTarget(input: ToolInput): CallDescriptor {
195
+ const server = text(input.server);
196
+ const tool = text(input.tool);
197
+
198
+ if (server && tool) {
199
+ return { summary: `${server}:${tool}`, levels: [server, `${server}:${tool}`] };
200
+ }
201
+ if (tool) return { summary: tool, levels: [tool] };
202
+ if (server) return { summary: server, levels: [server] };
203
+ return { summary: summarizeInput(input), levels: ["mcp"] };
204
+ }
205
+
206
+ function text(value: unknown): string | undefined {
207
+ return typeof value === "string" && value !== "" ? value : undefined;
208
+ }
@@ -0,0 +1,66 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { basename, dirname, join, normalize, resolve, sep } from "node:path";
4
+
5
+ import type { WorkspaceConfig } from "#core/config/schema.ts";
6
+
7
+ const PASTED_IMAGE_PREFIXES = ["pi-clipboard-", "pi-ask-permission-"];
8
+
9
+ export interface WorkspaceCheck {
10
+ outside: boolean;
11
+ path?: string;
12
+ }
13
+
14
+ // realpath, so a symlink out of the project does not count as inside.
15
+ export function checkWorkspace(
16
+ config: WorkspaceConfig,
17
+ cwd: string,
18
+ paths: string[] | undefined,
19
+ ): WorkspaceCheck {
20
+ if (paths === undefined) return { outside: true };
21
+
22
+ const roots = resolveRoots(config.roots, cwd);
23
+ const temp = canonical(tmpdir());
24
+ for (const path of paths) {
25
+ const absolute = canonical(resolvePath(path, cwd));
26
+ if (isPastedFile(absolute, temp)) continue;
27
+ if (!isInside(absolute, roots)) return { outside: true, path };
28
+ }
29
+ return { outside: false };
30
+ }
31
+
32
+ export function resolveRoots(roots: string[], cwd: string): string[] {
33
+ const list = roots.length > 0 ? roots : ["."];
34
+ return list.map((root) => canonical(resolvePath(root, cwd)));
35
+ }
36
+
37
+ function resolvePath(path: string, cwd: string): string {
38
+ if (path === "~") return homedir();
39
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2));
40
+ return resolve(cwd, path);
41
+ }
42
+
43
+ function canonical(path: string): string {
44
+ const lexical = normalize(path);
45
+ try {
46
+ return realpathSync(lexical);
47
+ } catch {
48
+ try {
49
+ return join(realpathSync(dirname(lexical)), basename(lexical));
50
+ } catch {
51
+ return lexical;
52
+ }
53
+ }
54
+ }
55
+
56
+ function isInside(path: string, roots: string[]): boolean {
57
+ return roots.some((root) => {
58
+ const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
59
+ return path === root || path.startsWith(prefix);
60
+ });
61
+ }
62
+
63
+ function isPastedFile(path: string, temp: string): boolean {
64
+ if (dirname(path) !== temp) return false;
65
+ return PASTED_IMAGE_PREFIXES.some((prefix) => basename(path).startsWith(prefix));
66
+ }
@@ -0,0 +1,5 @@
1
+ // The npm package is `@adeildo/pi-ask-permission`, but these names are written into session files
2
+ // (the judge card's customType), events, the status key and the config folder, so they stay
3
+ // `pi-ask-permission`. Another name would break every saved session on resume.
4
+ export const NAME = "pi-ask-permission";
5
+ export const CONFIG_DIR = "pi-ask-permission";
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { createApp, defineFeature } from "@adeildo/pi-kit";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+
4
+ import { listenForTools } from "#pi/api.ts";
5
+ import { registerBashTimer } from "#pi/bash-timer.ts";
6
+ import { registerCommands } from "#pi/commands.ts";
7
+ import { registerEvents } from "#pi/events.ts";
8
+ import { registerTypesafeProvider } from "#pi/provider.ts";
9
+ import { createSession } from "#pi/session.ts";
10
+ import { registerJudgeEntry } from "#ui/judge-entry.ts";
11
+
12
+ export const permission = defineFeature({
13
+ id: "permission",
14
+ description: "Ask before a tool call runs, with a judge model for the routine ones",
15
+ setup(scope) {
16
+ registerBashTimer(scope);
17
+ registerTypesafeProvider(scope);
18
+ registerJudgeEntry(scope);
19
+
20
+ const session = createSession();
21
+ registerEvents(scope, session);
22
+ listenForTools(scope, session.customTools);
23
+ registerCommands(scope, session);
24
+ },
25
+ });
26
+
27
+ export default function piAskPermission(pi: ExtensionAPI): void {
28
+ createApp(pi, { name: "pi-ask-permission" }).use(permission).build();
29
+ }
package/src/pi/api.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { FeatureScope } from "@adeildo/pi-kit";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+
4
+ import { isBuiltInTool, type ToolAdapter } from "#core/tools.ts";
5
+ import { NAME } from "#identity";
6
+ import { isRecord } from "#util/primitives.ts";
7
+
8
+ export const DECIDED_EVENT = `${NAME}:decided`;
9
+ export const TOOL_EVENT = `${NAME}:tool`;
10
+ export const ANSWER_ENTRY = `${NAME}:answer`;
11
+
12
+ export interface Decided {
13
+ toolCallId: string;
14
+ toolName: string;
15
+ summary: string;
16
+ action: "allow" | "block";
17
+ /** The layer that decided, `you` for the dialog, or `no UI`. */
18
+ by: string;
19
+ reason?: string;
20
+ note?: string;
21
+ }
22
+
23
+ export function announce(pi: ExtensionAPI, decided: Decided): void {
24
+ pi.events.emit(DECIDED_EVENT, decided);
25
+ if (decided.by === "you") pi.appendEntry(ANSWER_ENTRY, decided);
26
+ }
27
+
28
+ // Emit from session_start: every extension has loaded by then, so this one is listening.
29
+ export function listenForTools(
30
+ scope: FeatureScope,
31
+ tools: Map<string, Partial<ToolAdapter>>,
32
+ ): void {
33
+ scope.events.on(TOOL_EVENT, (data) => {
34
+ if (!isRecord(data) || typeof data.name !== "string" || isBuiltInTool(data.name)) return;
35
+
36
+ const adapter: Partial<ToolAdapter> = {};
37
+ if (typeof data.edits === "boolean") adapter.edits = data.edits;
38
+ if (typeof data.paths === "function") adapter.paths = data.paths as ToolAdapter["paths"];
39
+ if (typeof data.describe === "function") {
40
+ adapter.describe = data.describe as ToolAdapter["describe"];
41
+ }
42
+ tools.set(data.name, adapter);
43
+ });
44
+ }
@@ -0,0 +1,49 @@
1
+ import type { FeatureScope } from "@adeildo/pi-kit";
2
+ import {
3
+ createBashToolDefinition,
4
+ type ExtensionContext,
5
+ getAgentDir,
6
+ SettingsManager,
7
+ } from "@earendil-works/pi-coding-agent";
8
+
9
+ // pi starts the "Took" clock before the dialog answers; this restarts it when bash runs.
10
+ export function registerBashTimer(scope: FeatureScope): void {
11
+ const template = createBashToolDefinition(process.cwd());
12
+ const renderResult = template.renderResult;
13
+ if (!renderResult) return;
14
+
15
+ const startedAt = new Map<string, number>();
16
+
17
+ scope.on("session_shutdown", () => {
18
+ startedAt.clear();
19
+ });
20
+
21
+ const timed: typeof template = {
22
+ ...template,
23
+ execute(toolCallId, params, signal, onUpdate, ctx) {
24
+ startedAt.set(toolCallId, Date.now());
25
+ return bashFor(ctx).execute(toolCallId, params, signal, onUpdate, ctx);
26
+ },
27
+ renderResult(result, options, theme, context) {
28
+ const start = startedAt.get(context.toolCallId);
29
+ if (start !== undefined) context.state.startedAt = start;
30
+
31
+ const component = renderResult(result, options, theme, context);
32
+ if (!options.isPartial) startedAt.delete(context.toolCallId);
33
+ return component;
34
+ },
35
+ };
36
+
37
+ scope.registerTool(timed);
38
+ }
39
+
40
+ // Per call, so an untrusted project's settings.json cannot prefix every command.
41
+ function bashFor(ctx: ExtensionContext): ReturnType<typeof createBashToolDefinition> {
42
+ const settings = SettingsManager.create(ctx.cwd, getAgentDir(), {
43
+ projectTrusted: ctx.isProjectTrusted(),
44
+ });
45
+ return createBashToolDefinition(ctx.cwd, {
46
+ commandPrefix: settings.getShellCommandPrefix(),
47
+ shellPath: settings.getShellPath(),
48
+ });
49
+ }
@@ -0,0 +1,193 @@
1
+ import type { FeatureScope } from "@adeildo/pi-kit";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { type AutocompleteItem, Key } from "@earendil-works/pi-tui";
4
+
5
+ import { type Scope, SCOPE_LABEL } from "#core/always-yes.ts";
6
+ import { TYPESAFE_PROVIDER } from "#core/judge/backends/jev.ts";
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";
10
+ import { NAME } from "#identity";
11
+ import { setSessionMode } from "#pi/mode.ts";
12
+ import { forgetAlwaysYes, resetJudgeHealth, saveConfig, type SessionState } from "#pi/session.ts";
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
+ });
161
+
162
+ scope.registerShortcut(Key.alt("m"), {
163
+ description: `${NAME}: cycle mode (manual, accept edits, auto)`,
164
+ handler: cycleMode,
165
+ });
166
+ }
167
+
168
+ const FORGET_SCOPES: Record<string, Scope | "all"> = {
169
+ session: "session",
170
+ project: "project",
171
+ everywhere: "global",
172
+ all: "all",
173
+ };
174
+
175
+ const SUBCOMMANDS: AutocompleteItem[] = [
176
+ suggestion("mode", "Switch to the next mode (also Alt+M)"),
177
+ ...PERMISSION_MODES.map((mode) => suggestion(`mode ${mode}`, MODE_DESCRIPTION[mode])),
178
+ suggestion("status", "Show the config, always yes, and file paths"),
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 };
191
+ }
192
+
193
+ const VERBS = ["mode", "status", "forget", "judge"];