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 +18 -7
- package/core.ts +2 -2
- package/index.ts +42 -12
- package/package.json +1 -1
- package/smoke.mjs +18 -6
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)).
|
|
48
|
-
|
|
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
|
-
- `
|
|
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
|
-
- `
|
|
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": "
|
|
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"`
|
|
106
|
-
needs a new session. Anything malformed
|
|
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 = ["
|
|
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 : "
|
|
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
|
-
* - "
|
|
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
|
-
* - "
|
|
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: "
|
|
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 === "
|
|
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() === "
|
|
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() === "
|
|
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
|
-
|
|
359
|
+
fastened: "every chain of gated tool calls needs one approval",
|
|
359
360
|
auto: "auto-approve unless a call is dangerous",
|
|
360
|
-
|
|
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
|
|
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 (
|
|
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
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), "
|
|
17
|
+
assert.equal(normalizeMode(undefined), "fastened");
|
|
18
|
+
assert.equal(normalizeMode("fastened"), "fastened");
|
|
17
19
|
assert.equal(normalizeMode("auto"), "auto");
|
|
18
|
-
assert.equal(normalizeMode("
|
|
19
|
-
|
|
20
|
-
assert.equal(
|
|
21
|
-
assert.equal(
|
|
22
|
-
assert.equal(
|
|
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");
|