@aprimediet/permission-modes 1.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 (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/index.ts +657 -0
  4. package/package.json +34 -0
  5. package/utils.ts +198 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aditya Prima
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # @aprimediet/permission-modes
2
+
3
+ Claude-Code-style **permission modes** for the [pi coding agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent). Four modes, cycled with **Shift+Tab**, that control how tool calls and file edits get approved. The model is **not** changed per mode — only the approval behavior.
4
+
5
+ ## Modes
6
+
7
+ | Mode | edit / write | bash | agent control |
8
+ |---|---|---|---|
9
+ | **default** `●` | prompt on each edit/write (`Allow` / `Allow all → auto` / `Block`) | mutating commands prompt; read-only pass | — |
10
+ | **plan** `⏸` | disabled (stripped from the active tool set) | read-only allowlist only; mutating commands blocked | produce a numbered `Plan:`, then **Execute / Stay / Refine** |
11
+ | **accept-edits** `✎` | auto-approved | mutating commands still prompt; read-only pass | — |
12
+ | **auto** `▶` | auto-approved | auto-approved | auto-continues until done, bounded by `/auto-depth` |
13
+
14
+ **Cycle (Shift+Tab):** default → plan → accept-edits → auto → default.
15
+
16
+ When there is no interactive UI (`pi -p`, `--mode json`), anything that would prompt is **blocked** instead of silently allowed.
17
+
18
+ ## Commands, shortcut, flag
19
+
20
+ | Kind | Name | Behavior |
21
+ |---|---|---|
22
+ | Command | `/default`, `/plan`, `/accept-edits`, `/auto` | switch to that mode |
23
+ | Command | `/mode [name]` | set the given mode, or pick from a list |
24
+ | Command | `/auto-depth <n>` | cap auto-mode follow-ups (`0` = unlimited; default 20) |
25
+ | Shortcut | `Shift+Tab` | cycle modes |
26
+ | Flag | `--permission-mode <name>` | start in a mode (default `default`) |
27
+
28
+ > The start-mode flag is `--permission-mode` (not `--mode`) because pi already has a built-in `--mode` for output format (text/json/rpc).
29
+
30
+ ### Plan mode flow
31
+
32
+ In plan mode the agent explores read-only and emits a numbered list under a `Plan:` header. On completion you choose:
33
+
34
+ - **Execute the plan** — switches to auto, restores edit/write, runs the steps; a `☐/☑` widget advances as the agent emits `[DONE:n]` tags, and you get **Plan Complete! ✓** at the end.
35
+ - **Stay in plan mode** — keep iterating.
36
+ - **Refine the plan** — opens an editor; your notes are sent back as a follow-up.
37
+
38
+ ## UI
39
+
40
+ - A status pill and a custom footer showing **mode · cwd [git-branch] · provider/model**.
41
+ - While the agent is streaming, the working indicator shows live **token / tok-s / cost / % context** stats; it reverts to the default loader when idle.
42
+
43
+ Current mode and the auto-follow-up depth **persist** across `/reload` and session resume.
44
+
45
+ ## Install / run
46
+
47
+ ```bash
48
+ # Install as a package (scoped npm name; or from a git remote / local path)
49
+ pi install npm:@aprimediet/permission-modes
50
+ pi list # verify it loaded
51
+
52
+ # Or run it directly for a quick try (no install)
53
+ pi -e ./extensions/permission-modes/index.ts
54
+
55
+ # During development, hot-reload after edits
56
+ /reload
57
+ ```
58
+
59
+ Auto-discovery also works: drop this folder at `~/.pi/agent/extensions/permission-modes/` (global) or `.pi/extensions/permission-modes/` (project) and pi loads `index.ts` automatically.
60
+
61
+ ## Layout
62
+
63
+ ```
64
+ permission-modes/ # @aprimediet/permission-modes
65
+ ├── package.json # pi manifest: { "extensions": ["./index.ts"] }
66
+ ├── index.ts # the extension (default-exported factory)
67
+ └── utils.ts # bash allowlist + Plan: extraction + [DONE:n] helpers
68
+ ```
69
+
70
+ Third-party deps: none. The five pi-core packages are peer dependencies (bundled by pi). The model is never switched — Claude Code keeps one model across all modes, and the footer only *displays* it.
package/index.ts ADDED
@@ -0,0 +1,657 @@
1
+ /**
2
+ * @aprimediet/permission-modes
3
+ *
4
+ * A Claude-Code-style permission-mode system for the pi coding agent.
5
+ *
6
+ * Four modes, cycled with Shift+Tab (default → plan → accept-edits → auto → default):
7
+ * - default Ask before each file edit/write; mutating bash prompts.
8
+ * - plan Read-only; edit/write disabled, bash restricted to an allowlist;
9
+ * produce a numbered Plan:, then Execute / Stay / Refine.
10
+ * - accept-edits Auto-approve edit/write; mutating bash still prompts.
11
+ * - auto Auto-approve everything and auto-continue (bounded by /auto-depth).
12
+ *
13
+ * The model is NOT changed per mode (Claude Code keeps one model across modes); the
14
+ * footer only displays the current model as `variant / thinking`
15
+ * (variant = the model's display name; thinking = the current thinking level).
16
+ */
17
+
18
+ import type {
19
+ ExtensionAPI,
20
+ ExtensionContext,
21
+ } from "@earendil-works/pi-coding-agent";
22
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
23
+ import { homedir } from "node:os";
24
+ import {
25
+ extractTodoItems,
26
+ formatCount,
27
+ isCompletionSignal,
28
+ isSafeCommand,
29
+ markCompletedSteps,
30
+ type TodoItem,
31
+ } from "./utils.ts";
32
+
33
+ type Mode = "default" | "plan" | "accept-edits" | "auto";
34
+
35
+ const MODE_CYCLE: Mode[] = ["default", "plan", "accept-edits", "auto"];
36
+
37
+ const MODE_META: Record<Mode, { icon: string; label: string; role: string }> = {
38
+ default: { icon: "●", label: "Default", role: "muted" },
39
+ plan: { icon: "⏸", label: "Plan", role: "warning" },
40
+ "accept-edits": { icon: "✎", label: "Accept", role: "success" },
41
+ auto: { icon: "▶", label: "Auto", role: "accent" },
42
+ };
43
+
44
+ // Tools available in plan mode (edit/write are stripped).
45
+ const PLAN_TOOLS = ["read", "bash", "grep", "find", "ls"];
46
+ const PLAN_DISABLED = new Set(["edit", "write"]);
47
+
48
+ const MODE_CONTEXT: Record<Mode, string> = {
49
+ default:
50
+ "[DEFAULT MODE] Standard mode. File edits/writes and destructive shell commands require explicit user approval before they run.",
51
+ "accept-edits":
52
+ "[ACCEPT-EDITS MODE ACTIVE] File edit/write tool calls are auto-approved. Other potentially destructive operations (e.g. mutating shell commands) still require confirmation. Proceed efficiently and only pause for genuinely risky actions.",
53
+ plan: `[PLAN MODE ACTIVE]
54
+ You are in a read-only exploration mode. The edit and write tools are disabled and bash is restricted to read-only commands.
55
+
56
+ Investigate as needed, then produce a detailed, numbered plan under a "Plan:" header:
57
+
58
+ Plan:
59
+ 1. First step
60
+ 2. Second step
61
+ ...
62
+
63
+ Do NOT make any changes — only describe what you would do.`,
64
+ auto: "[AUTO MODE ACTIVE] All tool calls are auto-approved. Work autonomously without asking for permission until the task is complete. When everything is done, say the task is complete.",
65
+ };
66
+
67
+ type Block = { block: true; reason: string } | undefined;
68
+
69
+ export default function permissionModesExtension(pi: ExtensionAPI): void {
70
+ // ---- state -------------------------------------------------------------
71
+ let currentMode: Mode = "default";
72
+ let autoFollowUpDepth = 20;
73
+ let autoFollowUpCount = 0;
74
+ let isStepping = false;
75
+ let toolsBeforePlanMode: string[] | undefined;
76
+ let planExecuting = false;
77
+ let planTodos: TodoItem[] = [];
78
+
79
+ // streaming stats (for the working-indicator readout)
80
+ let streamStart = 0;
81
+ let outputAtStart = 0;
82
+ let lastTps = 0;
83
+ let gitBranch = "";
84
+
85
+ // ---- small helpers -----------------------------------------------------
86
+ const isAssistant = (m: any): boolean =>
87
+ !!m && m.role === "assistant" && Array.isArray(m.content);
88
+
89
+ const getText = (m: any): string =>
90
+ Array.isArray(m?.content)
91
+ ? m.content
92
+ .filter((c: any) => c?.type === "text")
93
+ .map((c: any) => c.text)
94
+ .join("\n")
95
+ : typeof m?.content === "string"
96
+ ? m.content
97
+ : "";
98
+
99
+ const hasToolCalls = (m: any): boolean =>
100
+ Array.isArray(m?.content) &&
101
+ m.content.some(
102
+ (c: any) =>
103
+ c &&
104
+ (c.type === "toolCall" ||
105
+ c.type === "tool_call" ||
106
+ c.type === "toolUse"),
107
+ );
108
+
109
+ function persistState(): void {
110
+ pi.appendEntry("modes", { currentMode, autoFollowUpDepth });
111
+ }
112
+
113
+ // ---- tool gating -------------------------------------------------------
114
+ function applyToolRestrictions(): void {
115
+ if (currentMode === "plan") {
116
+ if (toolsBeforePlanMode === undefined)
117
+ toolsBeforePlanMode = pi.getActiveTools();
118
+ const kept = toolsBeforePlanMode.filter((t) => !PLAN_DISABLED.has(t));
119
+ pi.setActiveTools([...new Set([...kept, ...PLAN_TOOLS])]);
120
+ } else if (toolsBeforePlanMode !== undefined) {
121
+ pi.setActiveTools(toolsBeforePlanMode);
122
+ toolsBeforePlanMode = undefined;
123
+ }
124
+ }
125
+
126
+ // ---- mode switching ----------------------------------------------------
127
+ function setMode(mode: Mode, ctx: ExtensionContext): void {
128
+ currentMode = mode;
129
+ autoFollowUpCount = 0;
130
+ isStepping = false;
131
+ // A manual switch always cancels any in-flight plan execution.
132
+ planExecuting = false;
133
+ planTodos = [];
134
+ if (ctx.hasUI) ctx.ui.setWidget("plan-todos", undefined);
135
+ applyToolRestrictions();
136
+ updateStatus(ctx);
137
+ persistState();
138
+ }
139
+
140
+ function cycleMode(ctx: ExtensionContext): void {
141
+ const idx = MODE_CYCLE.indexOf(currentMode);
142
+ setMode(MODE_CYCLE[(idx + 1) % MODE_CYCLE.length], ctx);
143
+ if (ctx.hasUI) ctx.ui.notify(`Mode: ${MODE_META[currentMode].label}`);
144
+ }
145
+
146
+ // ---- UI: status, footer, plan widget, working stats --------------------
147
+ function updateStatus(ctx: ExtensionContext): void {
148
+ if (!ctx.hasUI) return;
149
+ const m = MODE_META[currentMode];
150
+ ctx.ui.setStatus("modes", ctx.ui.theme.fg(m.role, `${m.icon} ${m.label}`));
151
+ ctx.ui.setWorkingIndicator({
152
+ frames: [ctx.ui.theme.fg(m.role, "●")],
153
+ intervalMs: 500,
154
+ });
155
+ }
156
+
157
+ function shortenPath(p: string): string {
158
+ const home = homedir();
159
+ return p && p.startsWith(home) ? `~${p.slice(home.length)}` : p;
160
+ }
161
+
162
+ function layoutThree(
163
+ left: string,
164
+ center: string,
165
+ right: string,
166
+ width: number,
167
+ ): string {
168
+ const lw = visibleWidth(left);
169
+ const cw = visibleWidth(center);
170
+ const rw = visibleWidth(right);
171
+ if (lw + cw + rw + 2 <= width) {
172
+ const leftGap = Math.max(1, Math.floor((width - cw) / 2) - lw);
173
+ const rightGap = Math.max(1, width - lw - leftGap - cw - rw);
174
+ return left + " ".repeat(leftGap) + center + " ".repeat(rightGap) + right;
175
+ }
176
+ const gap = Math.max(1, width - lw - rw);
177
+ return truncateToWidth(left + " ".repeat(gap) + right, width);
178
+ }
179
+
180
+ function installFooter(ctx: ExtensionContext): void {
181
+ if (!ctx.hasUI) return;
182
+ ctx.ui.setFooter((_tui: any, theme: any) => ({
183
+ render(width: number): string[] {
184
+ const m = MODE_META[currentMode];
185
+ const left = theme.fg(
186
+ m.role,
187
+ `${m.icon} ${m.label} (shift+tab to cycle)`,
188
+ );
189
+ const cwd = shortenPath(ctx.cwd);
190
+ const center = theme.fg(
191
+ "muted",
192
+ gitBranch ? `${cwd} [${gitBranch}]` : cwd,
193
+ );
194
+ const md = (ctx as any).model;
195
+ let modelStr = "";
196
+ if (md) {
197
+ // variant = the model's display name (fall back to the id if no name)
198
+ modelStr = md.name ? String(md.name) : String(md.id ?? "");
199
+ // thinking = current thinking/reasoning level (off|minimal|low|medium|high|xhigh)
200
+ const thinking =
201
+ typeof (pi as any).getThinkingLevel === "function"
202
+ ? (pi as any).getThinkingLevel()
203
+ : undefined;
204
+ if (thinking) modelStr += ` / ${thinking}`;
205
+ }
206
+ const right = theme.fg("dim", modelStr);
207
+ return [layoutThree(left, center, right, width)];
208
+ },
209
+ invalidate() {},
210
+ }));
211
+ }
212
+
213
+ function updatePlanWidget(ctx: ExtensionContext): void {
214
+ if (!ctx.hasUI) return;
215
+ if (!planTodos.length) {
216
+ ctx.ui.setWidget("plan-todos", undefined);
217
+ return;
218
+ }
219
+ const lines = planTodos.map((t) =>
220
+ t.completed
221
+ ? ctx.ui.theme.fg("success", "☑ ") +
222
+ ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(t.text))
223
+ : `${ctx.ui.theme.fg("muted", "☐ ")}${t.text}`,
224
+ );
225
+ ctx.ui.setWidget("plan-todos", lines);
226
+ }
227
+
228
+ function computeStats(ctx: ExtensionContext): {
229
+ input: number;
230
+ output: number;
231
+ cacheRead: number;
232
+ cacheWrite: number;
233
+ cost: number;
234
+ } {
235
+ const acc = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
236
+ try {
237
+ for (const entry of (ctx.sessionManager as any).getBranch() ?? []) {
238
+ if (entry?.type !== "message") continue;
239
+ const u = entry.message?.usage;
240
+ if (!u) continue;
241
+ acc.input += u.input || 0;
242
+ acc.output += u.output || 0;
243
+ acc.cacheRead += u.cacheRead || 0;
244
+ acc.cacheWrite += u.cacheWrite || 0;
245
+ acc.cost += u.cost?.total || 0;
246
+ }
247
+ } catch {
248
+ /* ignore */
249
+ }
250
+ return acc;
251
+ }
252
+
253
+ function renderWorkingMessage(ctx: ExtensionContext): string {
254
+ const s = computeStats(ctx);
255
+ const parts = [`↑${formatCount(s.input)}`, `↓${formatCount(s.output)}`];
256
+ if (s.cacheRead) parts.push(`R${formatCount(s.cacheRead)}`);
257
+ if (lastTps > 0) parts.push(`⚡${Math.round(lastTps)} tok/s`);
258
+ parts.push(`$${s.cost.toFixed(3)}`);
259
+ const usage = (ctx as any).getContextUsage?.();
260
+ if (usage && usage.maxTokens) {
261
+ parts.push(`${Math.round((usage.tokens / usage.maxTokens) * 100)}% ctx`);
262
+ }
263
+ return `Working… (${parts.join(" · ")})`;
264
+ }
265
+
266
+ function refreshWorkingMessage(ctx: ExtensionContext): void {
267
+ if (!ctx.hasUI) return;
268
+ ctx.ui.setWorkingMessage(
269
+ ctx.ui.theme.fg(MODE_META[currentMode].role, renderWorkingMessage(ctx)),
270
+ );
271
+ }
272
+
273
+ // ---- prompts -----------------------------------------------------------
274
+ async function promptApproval(
275
+ ctx: ExtensionContext,
276
+ tool: string,
277
+ label: string,
278
+ ): Promise<Block> {
279
+ if (!ctx.hasUI)
280
+ return {
281
+ block: true,
282
+ reason: `${tool} blocked: no UI available to confirm.`,
283
+ };
284
+ const choice = await ctx.ui.select(`Allow ${tool} ${label}?`, [
285
+ "Allow",
286
+ "Block",
287
+ ]);
288
+ if (choice !== "Allow")
289
+ return { block: true, reason: `${tool} blocked by user` };
290
+ return undefined;
291
+ }
292
+
293
+ // ---- commands / shortcut / flag ---------------------------------------
294
+ for (const mode of ["default", "plan", "accept-edits", "auto"] as Mode[]) {
295
+ pi.registerCommand(mode, {
296
+ description: `Switch to ${MODE_META[mode].label} mode`,
297
+ handler: async (_args, ctx) => setMode(mode, ctx),
298
+ });
299
+ }
300
+
301
+ pi.registerCommand("mode", {
302
+ description:
303
+ "Show or set the permission mode (default | plan | accept-edits | auto)",
304
+ handler: async (args, ctx) => {
305
+ const arg = (args ?? "").trim();
306
+ if (arg && (MODE_CYCLE as string[]).includes(arg)) {
307
+ setMode(arg as Mode, ctx);
308
+ return;
309
+ }
310
+ if (!ctx.hasUI) return;
311
+ const choice = await ctx.ui.select(
312
+ "Select mode:",
313
+ MODE_CYCLE.map((m) => MODE_META[m].label),
314
+ );
315
+ const picked = MODE_CYCLE.find((m) => MODE_META[m].label === choice);
316
+ if (picked) setMode(picked, ctx);
317
+ },
318
+ });
319
+
320
+ pi.registerCommand("auto-depth", {
321
+ description: "Set auto-mode follow-up depth cap (0 = unlimited)",
322
+ handler: async (args, ctx) => {
323
+ const n = parseInt((args ?? "").trim(), 10);
324
+ if (!Number.isNaN(n) && n >= 0) {
325
+ autoFollowUpDepth = n;
326
+ persistState();
327
+ if (ctx.hasUI)
328
+ ctx.ui.notify(`Auto follow-up depth: ${n === 0 ? "unlimited" : n}`);
329
+ } else if (ctx.hasUI) {
330
+ ctx.ui.notify(
331
+ `Auto follow-up depth: ${autoFollowUpDepth === 0 ? "unlimited" : autoFollowUpDepth}`,
332
+ "info",
333
+ );
334
+ }
335
+ },
336
+ });
337
+
338
+ pi.registerShortcut("shift+tab", {
339
+ description: "Cycle mode: Default → Plan → Accept-edits → Auto",
340
+ handler: async (ctx) => cycleMode(ctx),
341
+ });
342
+
343
+ // Alt+T: cycle the thinking level. pi has no built-in cycle helper, and setThinkingLevel
344
+ // clamps to the model's capabilities, so we advance to the next level the model actually
345
+ // accepts (skipping ones it clamps away). The footer reflects the new level live.
346
+ const THINKING_LEVELS = [
347
+ "off",
348
+ "minimal",
349
+ "low",
350
+ "medium",
351
+ "high",
352
+ "xhigh",
353
+ ] as const;
354
+ function cycleThinkingLevel(ctx: ExtensionContext): void {
355
+ const get = (): string =>
356
+ typeof (pi as any).getThinkingLevel === "function"
357
+ ? (pi as any).getThinkingLevel()
358
+ : "off";
359
+ const setLevel = (pi as any).setThinkingLevel as
360
+ | ((l: string) => void)
361
+ | undefined;
362
+ if (typeof setLevel !== "function") return;
363
+ const cur = get();
364
+ let i = THINKING_LEVELS.indexOf(cur as (typeof THINKING_LEVELS)[number]);
365
+ if (i < 0) i = 0;
366
+ for (let step = 1; step <= THINKING_LEVELS.length; step++) {
367
+ const next = THINKING_LEVELS[(i + step) % THINKING_LEVELS.length];
368
+ setLevel(next);
369
+ const applied = get();
370
+ if (applied !== cur) {
371
+ if (ctx.hasUI) ctx.ui.notify(`Thinking: ${applied}`, "info");
372
+ return;
373
+ }
374
+ }
375
+ if (ctx.hasUI)
376
+ ctx.ui.notify(
377
+ `Thinking: ${get()} (model supports no other levels)`,
378
+ "info",
379
+ );
380
+ }
381
+
382
+ pi.registerShortcut("alt+t", {
383
+ description:
384
+ "Cycle thinking level (off → minimal → low → medium → high → xhigh)",
385
+ handler: async (ctx) => cycleThinkingLevel(ctx),
386
+ });
387
+
388
+ // NB: pi has a built-in `--mode` (output mode: text/json/rpc), so the start-mode
389
+ // flag must use a distinct name to avoid being shadowed at parse time.
390
+ pi.registerFlag("permission-mode", {
391
+ description:
392
+ "Start in a permission mode: default, plan, accept-edits, or auto",
393
+ type: "string",
394
+ default: "default",
395
+ });
396
+
397
+ // ---- tool_call gate ----------------------------------------------------
398
+ pi.on("tool_call", async (event, ctx): Promise<Block> => {
399
+ const tool = event.toolName;
400
+ const input = (event.input ?? {}) as Record<string, unknown>;
401
+
402
+ // PLAN: edit/write already stripped; restrict bash to the read-only allowlist.
403
+ if (currentMode === "plan") {
404
+ if (tool === "bash") {
405
+ const cmd = String(input.command ?? "");
406
+ if (!isSafeCommand(cmd)) {
407
+ return {
408
+ block: true,
409
+ reason: `Plan mode: read-only commands only. Use /plan to exit plan mode first.\n Command: ${cmd}`,
410
+ };
411
+ }
412
+ }
413
+ return undefined;
414
+ }
415
+
416
+ // AUTO: approve everything.
417
+ if (currentMode === "auto") return undefined;
418
+
419
+ // ACCEPT-EDITS: auto-approve edit/write; mutating bash still prompts.
420
+ if (currentMode === "accept-edits") {
421
+ if (tool === "edit" || tool === "write") return undefined;
422
+ if (tool === "bash") {
423
+ const cmd = String(input.command ?? "");
424
+ if (isSafeCommand(cmd)) return undefined;
425
+ return promptApproval(ctx, tool, `"${cmd}"`);
426
+ }
427
+ return undefined;
428
+ }
429
+
430
+ // DEFAULT: prompt on edit/write (with "Allow all → auto"); mutating bash prompts.
431
+ if (tool === "edit" || tool === "write") {
432
+ const path = String(input.path ?? "(unknown)");
433
+ if (!ctx.hasUI)
434
+ return {
435
+ block: true,
436
+ reason: `${tool} blocked: no UI available to confirm.`,
437
+ };
438
+ const choice = await ctx.ui.select(`Allow ${tool} on ${path}?`, [
439
+ "Allow",
440
+ "Allow all (enable auto)",
441
+ "Block",
442
+ ]);
443
+ if (choice === "Allow all (enable auto)") {
444
+ setMode("auto", ctx);
445
+ return undefined;
446
+ }
447
+ if (choice !== "Allow")
448
+ return { block: true, reason: `${tool} blocked by user on ${path}` };
449
+ return undefined;
450
+ }
451
+ if (tool === "bash") {
452
+ const cmd = String(input.command ?? "");
453
+ if (isSafeCommand(cmd)) return undefined;
454
+ return promptApproval(ctx, tool, `"${cmd}"`);
455
+ }
456
+ return undefined;
457
+ });
458
+
459
+ // ---- context injection + dedup ----------------------------------------
460
+ pi.on("before_agent_start", async () => {
461
+ if (planExecuting && planTodos.length) {
462
+ const remaining = planTodos
463
+ .filter((t) => !t.completed)
464
+ .map((t) => `${t.step}. ${t.text}`)
465
+ .join("\n");
466
+ return {
467
+ message: {
468
+ customType: "modes-context",
469
+ content: `[EXECUTING PLAN — full tool access]\n\nRemaining steps:\n${remaining}\n\nExecute each step in order. After finishing a step, include a [DONE:n] tag in your reply.`,
470
+ display: false,
471
+ },
472
+ };
473
+ }
474
+ const content = MODE_CONTEXT[currentMode];
475
+ if (content)
476
+ return {
477
+ message: { customType: "modes-context", content, display: false },
478
+ };
479
+ return undefined;
480
+ });
481
+
482
+ pi.on("context", async (event) => {
483
+ const msgs = event.messages as any[];
484
+ let lastIdx = -1;
485
+ for (let i = 0; i < msgs.length; i++) {
486
+ if (msgs[i]?.customType === "modes-context") lastIdx = i;
487
+ }
488
+ if (lastIdx === -1) return undefined;
489
+ return {
490
+ messages: msgs.filter(
491
+ (m, i) => m?.customType !== "modes-context" || i === lastIdx,
492
+ ),
493
+ };
494
+ });
495
+
496
+ // ---- streaming-stat working message -----------------------------------
497
+ pi.on("turn_start", async (_event, ctx) => {
498
+ streamStart = Date.now();
499
+ outputAtStart = computeStats(ctx).output;
500
+ refreshWorkingMessage(ctx);
501
+ });
502
+ pi.on("before_provider_request", async (_event, ctx) =>
503
+ refreshWorkingMessage(ctx),
504
+ );
505
+ pi.on("message_update", async (_event, ctx) => refreshWorkingMessage(ctx));
506
+
507
+ // ---- turn_end: tps + plan-step tracking + auto follow-up ---------------
508
+ pi.on("turn_end", async (event, ctx) => {
509
+ try {
510
+ gitBranch = (ctx.sessionManager as any).getGitBranch?.() ?? gitBranch;
511
+ } catch {
512
+ /* ignore */
513
+ }
514
+
515
+ const stats = computeStats(ctx);
516
+ const elapsed = Math.max((Date.now() - streamStart) / 1000, 0.001);
517
+ const delta = stats.output - outputAtStart;
518
+ if (delta > 0) lastTps = delta / elapsed;
519
+ refreshWorkingMessage(ctx);
520
+
521
+ const msg = event.message;
522
+ if (!isAssistant(msg)) return;
523
+ const text = getText(msg);
524
+
525
+ if (planExecuting && planTodos.length) {
526
+ if (markCompletedSteps(text, planTodos) > 0) updatePlanWidget(ctx);
527
+ persistState();
528
+ }
529
+
530
+ // if (currentMode === "auto" && !isStepping) {
531
+ // if (autoFollowUpDepth > 0 && autoFollowUpCount >= autoFollowUpDepth)
532
+ // return;
533
+ // if (hasToolCalls(msg) && !isCompletionSignal(text)) {
534
+ // isStepping = true;
535
+ // autoFollowUpCount++;
536
+ // pi.sendUserMessage(
537
+ // "Continue. Auto mode is active — proceed without asking.",
538
+ // {
539
+ // deliverAs: "followUp",
540
+ // },
541
+ // );
542
+ // }
543
+ // }
544
+ });
545
+
546
+ // ---- agent_end: idle reset + plan complete + plan offer ----------------
547
+ pi.on("agent_end", async (event, ctx) => {
548
+ isStepping = false;
549
+ if (ctx.hasUI) ctx.ui.setWorkingMessage(); // restore default loader when idle
550
+
551
+ // Plan execution in progress: announce completion when all steps are done.
552
+ if (planExecuting && planTodos.length) {
553
+ if (planTodos.every((t) => t.completed)) {
554
+ if (ctx.hasUI) {
555
+ pi.sendMessage(
556
+ {
557
+ customType: "plan-complete",
558
+ content: "**Plan Complete!** ✓",
559
+ display: true,
560
+ },
561
+ { triggerTurn: false },
562
+ );
563
+ ctx.ui.setWidget("plan-todos", undefined);
564
+ }
565
+ planExecuting = false;
566
+ planTodos = [];
567
+ persistState();
568
+ }
569
+ return;
570
+ }
571
+
572
+ // In plan mode (and interactive): extract the plan and offer next action.
573
+ if (currentMode !== "plan" || !ctx.hasUI) return;
574
+ const lastAssistant = [...(event.messages as any[])]
575
+ .reverse()
576
+ .find(isAssistant);
577
+ if (!lastAssistant) return;
578
+ const extracted = extractTodoItems(getText(lastAssistant));
579
+ if (!extracted.length) return;
580
+ planTodos = extracted;
581
+ persistState();
582
+
583
+ const choice = await ctx.ui.select("Plan ready — what next?", [
584
+ "Execute the plan",
585
+ "Stay in plan mode",
586
+ "Refine the plan",
587
+ ]);
588
+
589
+ if (choice === "Execute the plan") {
590
+ planExecuting = true;
591
+ currentMode = "auto";
592
+ autoFollowUpCount = 0;
593
+ isStepping = false;
594
+ applyToolRestrictions(); // restores edit/write
595
+ updateStatus(ctx);
596
+ updatePlanWidget(ctx);
597
+ persistState();
598
+ const steps = planTodos.map((t) => `${t.step}. ${t.text}`).join("\n");
599
+ pi.sendMessage(
600
+ {
601
+ customType: "modes-execute",
602
+ content: `Execute the plan now. Steps:\n${steps}\n\nStart with step 1. After finishing each step, include a [DONE:n] tag in your reply.`,
603
+ display: true,
604
+ },
605
+ { triggerTurn: true, deliverAs: "followUp" },
606
+ );
607
+ } else if (choice === "Refine the plan") {
608
+ const refinement = await ctx.ui.editor("Refine the plan:", "");
609
+ if (refinement && refinement.trim()) {
610
+ pi.sendUserMessage(refinement.trim(), { deliverAs: "followUp" });
611
+ }
612
+ }
613
+ });
614
+
615
+ // ---- session start / resume -------------------------------------------
616
+ async function onSessionStart(
617
+ _event: unknown,
618
+ ctx: ExtensionContext,
619
+ ): Promise<void> {
620
+ const flag = pi.getFlag("permission-mode");
621
+ if (typeof flag === "string" && (MODE_CYCLE as string[]).includes(flag)) {
622
+ currentMode = flag as Mode;
623
+ }
624
+
625
+ // Restore the latest persisted mode entry (overrides the flag).
626
+ try {
627
+ const entries = (ctx.sessionManager as any).getEntries?.() ?? [];
628
+ const last = [...entries]
629
+ .reverse()
630
+ .find((e: any) => e?.type === "custom" && e?.customType === "modes");
631
+ if (last?.data) {
632
+ let m = last.data.currentMode;
633
+ if (m === "normal") m = "default"; // legacy
634
+ if ((MODE_CYCLE as string[]).includes(m)) currentMode = m;
635
+ if (typeof last.data.autoFollowUpDepth === "number")
636
+ autoFollowUpDepth = last.data.autoFollowUpDepth;
637
+ }
638
+ } catch {
639
+ /* ignore */
640
+ }
641
+
642
+ try {
643
+ gitBranch = (ctx.sessionManager as any).getGitBranch?.() ?? "";
644
+ } catch {
645
+ /* ignore */
646
+ }
647
+
648
+ applyToolRestrictions();
649
+ if (ctx.hasUI) {
650
+ installFooter(ctx);
651
+ updateStatus(ctx);
652
+ }
653
+ }
654
+
655
+ pi.on("session_start", onSessionStart);
656
+ pi.on("session_tree", onSessionStart);
657
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@aprimediet/permission-modes",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Claude-Code-style permission modes (default / plan / accept-edits / auto) for the pi coding agent.",
6
+ "keywords": ["pi-package"],
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/aprimediet/pi-permission-modes.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/aprimediet/pi-permission-modes/issues"
14
+ },
15
+ "homepage": "https://github.com/aprimediet/pi-permission-modes#readme",
16
+ "publishConfig": {
17
+ "access": "public",
18
+ "registry": "https://registry.npmjs.org/"
19
+ },
20
+ "scripts": {
21
+ "prepublishOnly": "node -e \"const pkg=require('./package.json'); if(!pkg.files||pkg.files.length===0){console.error('files field empty — abort');process.exit(1)}; console.log('Publishing '+pkg.name+'@'+pkg.version+' with files:', pkg.files)\"",
22
+ "pack:dry": "npm pack --dry-run"
23
+ },
24
+ "pi": {
25
+ "extensions": ["./index.ts"]
26
+ },
27
+ "files": ["*.ts", "README.md", "LICENSE"],
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-coding-agent": "*",
30
+ "@earendil-works/pi-ai": "*",
31
+ "@earendil-works/pi-tui": "*",
32
+ "typebox": "*"
33
+ }
34
+ }
package/utils.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Pure helpers for the permission-modes extension.
3
+ *
4
+ * - Bash read-only classifier used by Plan mode and the default/accept-edits
5
+ * gates (SAFE allowlist AND not DESTRUCTIVE).
6
+ * - Numbered "Plan:" extraction and [DONE:n] step tracking used by Plan mode's
7
+ * execute/track flow.
8
+ *
9
+ * Ported from pi's bundled `examples/extensions/plan-mode/utils.ts`.
10
+ */
11
+
12
+ // Commands that mutate state — never allowed in plan mode, and prompt elsewhere.
13
+ const DESTRUCTIVE_PATTERNS: RegExp[] = [
14
+ /\brm\b/i,
15
+ /\brmdir\b/i,
16
+ /\bmv\b/i,
17
+ /\bcp\b/i,
18
+ /\bmkdir\b/i,
19
+ /\btouch\b/i,
20
+ /\bchmod\b/i,
21
+ /\bchown\b/i,
22
+ /\bchgrp\b/i,
23
+ /\bln\b/i,
24
+ /\btee\b/i,
25
+ /\btruncate\b/i,
26
+ /\bdd\b/i,
27
+ /\bshred\b/i,
28
+ /(^|[^<])>(?!>)/, // single redirect (not >>)
29
+ />>/, // append redirect
30
+ /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
31
+ /\byarn\s+(add|remove|install|publish)/i,
32
+ /\bpnpm\s+(add|remove|install|publish)/i,
33
+ /\bpip\s+(install|uninstall)/i,
34
+ /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
35
+ /\bbrew\s+(install|uninstall|upgrade)/i,
36
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
37
+ /\bsudo\b/i,
38
+ /\bsu\b/i,
39
+ /\bkill\b/i,
40
+ /\bpkill\b/i,
41
+ /\bkillall\b/i,
42
+ /\breboot\b/i,
43
+ /\bshutdown\b/i,
44
+ /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
45
+ /\bservice\s+\S+\s+(start|stop|restart)/i,
46
+ /\b(vim?|nano|emacs|code|subl)\b/i,
47
+ ];
48
+
49
+ // Read-only commands allowed without confirmation.
50
+ const SAFE_PATTERNS: RegExp[] = [
51
+ /^\s*cat\b/,
52
+ /^\s*head\b/,
53
+ /^\s*tail\b/,
54
+ /^\s*less\b/,
55
+ /^\s*more\b/,
56
+ /^\s*grep\b/,
57
+ /^\s*find\b/,
58
+ /^\s*ls\b/,
59
+ /^\s*pwd\b/,
60
+ /^\s*echo\b/,
61
+ /^\s*printf\b/,
62
+ /^\s*wc\b/,
63
+ /^\s*sort\b/,
64
+ /^\s*uniq\b/,
65
+ /^\s*diff\b/,
66
+ /^\s*file\b/,
67
+ /^\s*stat\b/,
68
+ /^\s*du\b/,
69
+ /^\s*df\b/,
70
+ /^\s*tree\b/,
71
+ /^\s*which\b/,
72
+ /^\s*whereis\b/,
73
+ /^\s*type\b/,
74
+ /^\s*env\b/,
75
+ /^\s*printenv\b/,
76
+ /^\s*uname\b/,
77
+ /^\s*whoami\b/,
78
+ /^\s*id\b/,
79
+ /^\s*date\b/,
80
+ /^\s*cal\b/,
81
+ /^\s*uptime\b/,
82
+ /^\s*ps\b/,
83
+ /^\s*top\b/,
84
+ /^\s*htop\b/,
85
+ /^\s*free\b/,
86
+ /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
87
+ /^\s*git\s+ls-/i,
88
+ /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
89
+ /^\s*yarn\s+(list|info|why|audit)/i,
90
+ /^\s*node\s+--version/i,
91
+ /^\s*python\s+--version/i,
92
+ /^\s*curl\s/i,
93
+ /^\s*wget\s+-O\s*-/i,
94
+ /^\s*jq\b/,
95
+ /^\s*sed\s+-n/i,
96
+ /^\s*awk\b/,
97
+ /^\s*rg\b/,
98
+ /^\s*fd\b/,
99
+ /^\s*bat\b/,
100
+ /^\s*eza\b/,
101
+ ];
102
+
103
+ /** A command is "safe" iff it matches the allowlist AND no destructive pattern. */
104
+ export function isSafeCommand(command: string): boolean {
105
+ const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
106
+ const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
107
+ return !isDestructive && isSafe;
108
+ }
109
+
110
+ export interface TodoItem {
111
+ step: number;
112
+ text: string;
113
+ completed: boolean;
114
+ }
115
+
116
+ export function cleanStepText(text: string): string {
117
+ let cleaned = text
118
+ .replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // strip bold/italic
119
+ .replace(/`([^`]+)`/g, "$1") // strip inline code
120
+ .replace(
121
+ /^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
122
+ "",
123
+ )
124
+ .replace(/\s+/g, " ")
125
+ .trim();
126
+
127
+ if (cleaned.length > 0) {
128
+ cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
129
+ }
130
+ if (cleaned.length > 50) {
131
+ cleaned = `${cleaned.slice(0, 47)}...`;
132
+ }
133
+ return cleaned;
134
+ }
135
+
136
+ /** Extract a numbered list under a `Plan:` header into TodoItems. */
137
+ export function extractTodoItems(message: string): TodoItem[] {
138
+ const items: TodoItem[] = [];
139
+ const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
140
+ if (!headerMatch) return items;
141
+
142
+ const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
143
+ const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
144
+
145
+ for (const match of planSection.matchAll(numberedPattern)) {
146
+ const text = match[2]
147
+ .trim()
148
+ .replace(/\*{1,2}$/, "")
149
+ .trim();
150
+ if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
151
+ const cleaned = cleanStepText(text);
152
+ if (cleaned.length > 3) {
153
+ items.push({ step: items.length + 1, text: cleaned, completed: false });
154
+ }
155
+ }
156
+ }
157
+ return items;
158
+ }
159
+
160
+ export function extractDoneSteps(message: string): number[] {
161
+ const steps: number[] = [];
162
+ for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
163
+ const step = Number(match[1]);
164
+ if (Number.isFinite(step)) steps.push(step);
165
+ }
166
+ return steps;
167
+ }
168
+
169
+ /** Mark any `[DONE:n]` steps found in `text` complete. Returns how many tags were seen. */
170
+ export function markCompletedSteps(text: string, items: TodoItem[]): number {
171
+ const doneSteps = extractDoneSteps(text);
172
+ for (const step of doneSteps) {
173
+ const item = items.find((t) => t.step === step);
174
+ if (item) item.completed = true;
175
+ }
176
+ return doneSteps.length;
177
+ }
178
+
179
+ const COMPLETION_SIGNALS: RegExp[] = [
180
+ /\b(plan|task|work|job|everything|all)\s+(is\s+|are\s+|has\s+been\s+)?(complete|completed|done|finished)\b/i,
181
+ /\ball\s+done\b/i,
182
+ /\bno\s+(more|further)\s+(steps|tasks|actions|work)\b/i,
183
+ /\b(i'?m|i\s+am)\s+(done|finished)\b/i,
184
+ /\bfinished\b/i,
185
+ ];
186
+
187
+ /** Heuristic: does the assistant text claim the work is finished? */
188
+ export function isCompletionSignal(text: string): boolean {
189
+ return COMPLETION_SIGNALS.some((p) => p.test(text));
190
+ }
191
+
192
+ /** Compact token count, e.g. 1234 -> "1.2k", 12000 -> "12k". */
193
+ export function formatCount(n: number): string {
194
+ if (!Number.isFinite(n) || n <= 0) return "0";
195
+ if (n < 1000) return String(Math.round(n));
196
+ const k = n / 1000;
197
+ return `${k >= 10 ? Math.round(k) : k.toFixed(1)}k`;
198
+ }