pi-seatbelt 0.2.0 → 0.3.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/README.md CHANGED
@@ -44,13 +44,16 @@ packages as peers.
44
44
  ## Three modes, one key
45
45
 
46
46
  Press the mode key to cycle: `alt+b` by default, or any key you set (see
47
- `shortcut` under [Configure](#configure)). The status bar always shows
48
- where you are: `perm:all`, `perm:auto`, `perm:off`. The choice persists.
47
+ `shortcut` under [Configure](#configure)). Or switch without the keyboard:
48
+ `/seatbelt:mode` cycles, `/seatbelt:mode auto` sets a mode directly. The
49
+ status bar always shows
50
+ where you are: `seatbelt:fastened 🔒` (green), `seatbelt:auto 🤖` (yellow),
51
+ `seatbelt:unfastened 💀` (red). The choice persists.
49
52
 
50
- - `all`: every chain asks once. The default.
53
+ - `fastened`: every chain asks once. The default.
51
54
  - `auto`: no dialog unless something is `DANGEROUS`. Labels decide what runs
52
55
  silently, so pair it with a good policy file.
53
- - `off`: no gating, no classifier calls, no cost.
56
+ - `unfastened`: no gating, no classifier calls, no cost.
54
57
 
55
58
  Headless modes (print, JSON) have no dialogs: only `safe` calls auto-run,
56
59
  everything else is blocked with a reason.
@@ -91,7 +94,7 @@ built-in regex fallback labels calls and the policy file is not consulted.
91
94
  {
92
95
  "model": "litellm/DeepSeek-V4-Flash",
93
96
  "timeoutMs": 20000,
94
- "mode": "all",
97
+ "mode": "fastened",
95
98
  "shortcut": "alt+b",
96
99
  "tools": ["bash", "write", "edit", "mcp__slack__*"]
97
100
  }
@@ -102,8 +105,16 @@ built-in regex fallback labels calls and the policy file is not consulted.
102
105
  whole server, `*` for everything). Non-bash calls are shown to the
103
106
  labeller as tool name plus arguments, truncated to 500 characters.
104
107
  - `shortcut`: the key that cycles the permission modes, as a pi key id
105
- (`"alt+b"`, `"ctrl+shift+p"`, `"f9"`). Read at startup, so a change
106
- needs a new session. Anything malformed falls back to `alt+b`.
108
+ (`"alt+b"`, `"ctrl+shift+p"`, `"f9"`, `"super+b"` for ⌘ on macOS).
109
+ Read at startup, so a change needs a new session. Anything malformed
110
+ falls back to `alt+b`.
111
+ Some combos need the terminal to report modifiers distinctly: through
112
+ tmux without `set -s extended-keys on`, `ctrl+shift+b` arrives as plain
113
+ `ctrl+b` (cursor-left) — `alt`- and `F`1–`F12` keys work everywhere.
114
+ `super` (⌘) needs a terminal with Kitty keyboard protocol support
115
+ (Ghostty, kitty, WezTerm) and is usually intercepted by the terminal or
116
+ OS before it reaches pi. If a key combo does not work, `/seatbelt:mode`
117
+ always does.
107
118
  - `model`: the labeller. Pick one with `/seatbelt`, or pass it directly:
108
119
  `/seatbelt litellm/gpt-4o-mini`. Fast and cheap works well; the prompt
109
120
  is tiny. Without a model, or if the call fails or times out, the regex
package/core.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type Danger = "safe" | "caution" | "dangerous";
2
2
 
3
- export const PERMISSION_MODES = ["all", "auto", "off"] as const;
3
+ export const PERMISSION_MODES = ["fastened", "auto", "unfastened"] as const;
4
4
  export type PermissionMode = (typeof PERMISSION_MODES)[number];
5
5
 
6
6
  export function isPermissionMode(value: unknown): value is PermissionMode {
@@ -10,7 +10,7 @@ export function isPermissionMode(value: unknown): value is PermissionMode {
10
10
  }
11
11
 
12
12
  export function normalizeMode(value: unknown): PermissionMode {
13
- return isPermissionMode(value) ? value : "all";
13
+ return isPermissionMode(value) ? value : "fastened";
14
14
  }
15
15
 
16
16
  export function nextMode(mode: PermissionMode): PermissionMode {
package/index.ts CHANGED
@@ -20,10 +20,10 @@
20
20
  *
21
21
  * Permission modes (cycled with the configured shortcut key, default alt+b,
22
22
  * shown in the status bar):
23
- * - "all" (default): one approval dialog per chain of gated tool calls.
23
+ * - "fastened" (default): one approval dialog per chain of gated tool calls.
24
24
  * - "auto": chains without a dangerous call run without asking; a dangerous
25
25
  * call still shows the approval dialog.
26
- * - "off": no approval requests at all.
26
+ * - "unfastened": no approval requests at all.
27
27
  * In headless modes (no UI) only "safe" calls are auto-allowed.
28
28
  */
29
29
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -41,6 +41,7 @@ import {
41
41
  formatSingle,
42
42
  normalizeMode,
43
43
  normalizeShortcut,
44
+ isPermissionMode,
44
45
  nextMode,
45
46
  plainStyle,
46
47
  withPolicy,
@@ -63,7 +64,7 @@ contains only this header, the built-in defaults apply unchanged.
63
64
 
64
65
  const CONFIG_TEMPLATE = {
65
66
  timeoutMs: 20000,
66
- mode: "all",
67
+ mode: "fastened",
67
68
  shortcut: DEFAULT_SHORTCUT,
68
69
  tools: ["bash"],
69
70
  };
@@ -238,7 +239,7 @@ async function approveBatch(
238
239
  ctx: ExtensionContext,
239
240
  ): Promise<void> {
240
241
  const mode = getCurrentMode();
241
- if (mode === "off") return;
242
+ if (mode === "unfastened") return;
242
243
  const assessments = await classifyCalls(entries, taskContext, ctx);
243
244
  const displays = entries.map((e) => describeCall(e.tool, e.args));
244
245
  let isAllowed: boolean;
@@ -277,7 +278,7 @@ export default function (pi: ExtensionAPI) {
277
278
  )
278
279
  .map((c) => ({ id: c.id, tool: c.name, args: c.arguments ?? {} }));
279
280
  if (calls.length === 0) return;
280
- if (getCurrentMode() === "off") return;
281
+ if (getCurrentMode() === "unfastened") return;
281
282
  const taskContext = message.content
282
283
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
283
284
  .map((c) => c.text)
@@ -288,7 +289,7 @@ export default function (pi: ExtensionAPI) {
288
289
 
289
290
  pi.on("tool_call", async (event, ctx) => {
290
291
  if (!matchesTool(getGatedTools(), event.toolName)) return undefined;
291
- if (getCurrentMode() === "off") return undefined;
292
+ if (getCurrentMode() === "unfastened") return undefined;
292
293
  if (!decisions.has(event.toolCallId)) {
293
294
  // Nested or otherwise unseen call (for example a codemode script): approve it on its own
294
295
  const entry: GatedCall = { id: event.toolCallId, tool: event.toolName, args: getCallArgs(event.input) };
@@ -355,20 +356,49 @@ export default function (pi: ExtensionAPI) {
355
356
  });
356
357
 
357
358
  const MODE_HINTS = {
358
- all: "every chain of gated tool calls needs one approval",
359
+ fastened: "every chain of gated tool calls needs one approval",
359
360
  auto: "auto-approve unless a call is dangerous",
360
- off: "no approval requests",
361
+ unfastened: "no approval requests",
361
362
  } satisfies Record<PermissionMode, string>;
362
363
 
364
+ // Status-bar wording: gated (fastened), no gating (unfastened), the
365
+ // labeller decides what runs (auto).
366
+ const MODE_LABELS = {
367
+ fastened: "fastened 🔒",
368
+ auto: "auto 🤖",
369
+ unfastened: "unfastened 💀",
370
+ } satisfies Record<PermissionMode, string>;
371
+
372
+ const MODE_COLOR = {
373
+ fastened: "success",
374
+ auto: "warning",
375
+ unfastened: "error",
376
+ } satisfies Record<PermissionMode, "success" | "warning" | "error">;
377
+
363
378
  function updateStatus(ctx: ExtensionContext): void {
364
379
  if (!ctx.hasUI) return;
365
380
  const mode = getCurrentMode();
366
- const color = mode === "all" ? "accent" : mode === "auto" ? "success" : "dim";
367
- const text =
368
- ctx.mode === "tui" ? ctx.ui.theme.fg(color, `perm:${mode}`) : `perm:${mode}`;
381
+ const label = `seatbelt:${MODE_LABELS[mode]}`;
382
+ const text = ctx.mode === "tui" ? ctx.ui.theme.fg(MODE_COLOR[mode], label) : label;
369
383
  ctx.ui.setStatus("seatbelt", text);
370
384
  }
371
385
 
386
+ pi.registerCommand("seatbelt:mode", {
387
+ description: "Switch seatbelt mode (fastened, auto, unfastened); no argument cycles",
388
+ handler: async (args, ctx) => {
389
+ const wanted = args.trim();
390
+ if (wanted && !isPermissionMode(wanted)) {
391
+ ctx.ui.notify(`Unknown mode: ${wanted}. Use fastened, auto, or unfastened.`, "error");
392
+ return;
393
+ }
394
+ // Safe cast: wanted is non-empty and passed isPermissionMode above.
395
+ const mode = (wanted || nextMode(getCurrentMode())) as PermissionMode;
396
+ writeConfig({ ...readConfig(), mode });
397
+ updateStatus(ctx);
398
+ ctx.ui.notify(`Seatbelt: ${mode} (${MODE_HINTS[mode]})`, "info");
399
+ },
400
+ });
401
+
372
402
  pi.on("session_start", (_event, ctx) => {
373
403
  updateStatus(ctx);
374
404
  if (hasSeededFreshConfig && ctx.hasUI) {
@@ -385,7 +415,7 @@ export default function (pi: ExtensionAPI) {
385
415
  ExtensionAPI["registerShortcut"]
386
416
  >[0];
387
417
  pi.registerShortcut(shortcut, {
388
- description: "Cycle seatbelt mode (all, auto, off)",
418
+ description: "Cycle seatbelt mode (fastened, auto, unfastened)",
389
419
  handler: (ctx) => {
390
420
  const mode = nextMode(getCurrentMode());
391
421
  writeConfig({ ...readConfig(), mode });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-seatbelt",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A safe, ergonomic permission system for pi: one approval per chain, danger labels, your rules.",
5
5
  "keywords": [
6
6
  "pi-package",
package/smoke.mjs CHANGED
@@ -8,18 +8,30 @@ import {
8
8
  formatSingle,
9
9
  normalizeMode,
10
10
  normalizeShortcut,
11
+ isPermissionMode,
11
12
  nextMode,
12
13
  withPolicy,
13
14
  } from "./core.ts";
14
15
 
15
16
  // permission modes
16
- assert.equal(normalizeMode(undefined), "all");
17
+ assert.equal(normalizeMode(undefined), "fastened");
18
+ assert.equal(normalizeMode("fastened"), "fastened");
17
19
  assert.equal(normalizeMode("auto"), "auto");
18
- assert.equal(normalizeMode("off"), "off");
19
- assert.equal(normalizeMode("nonsense"), "all");
20
- assert.equal(nextMode("all"), "auto");
21
- assert.equal(nextMode("auto"), "off");
22
- assert.equal(nextMode("off"), "all");
20
+ assert.equal(normalizeMode("unfastened"), "unfastened");
21
+ // pre-rename values are no longer valid and fall back to the default
22
+ assert.equal(normalizeMode("all"), "fastened");
23
+ assert.equal(normalizeMode("off"), "fastened");
24
+ assert.equal(normalizeMode("nonsense"), "fastened");
25
+ assert.equal(nextMode("fastened"), "auto");
26
+ assert.equal(nextMode("auto"), "unfastened");
27
+ assert.equal(nextMode("unfastened"), "fastened");
28
+ assert.ok(isPermissionMode("fastened"));
29
+ assert.ok(isPermissionMode("auto"));
30
+ assert.ok(isPermissionMode("unfastened"));
31
+ assert.ok(!isPermissionMode("all"));
32
+ assert.ok(!isPermissionMode("off"));
33
+ assert.ok(!isPermissionMode("nonsense"));
34
+ assert.ok(!isPermissionMode(undefined));
23
35
 
24
36
  // shortcut normalization
25
37
  assert.equal(normalizeShortcut(undefined), "alt+b");