@andares/pi-toolkits 0.9.0 → 0.9.2
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 +10 -3
- package/README.zh-CN.md +10 -3
- package/package.json +1 -1
- package/src/features/autostart/autostart.test.ts +91 -0
- package/src/features/autostart/autostart.ts +24 -0
- package/src/features/autostart/index.ts +49 -3
- package/src/features/permission/index.test.ts +16 -1
- package/src/features/permission/index.ts +12 -2
- package/src/features/permission/modes.ts +10 -0
package/README.md
CHANGED
|
@@ -106,6 +106,8 @@ Enter with `/ask` (press it again to exit). Meant for the case where you only wa
|
|
|
106
106
|
- `/ask-perm allow allow allow ask` — positional form (`in-read in-write out-read out-write`), enables immediately without a dialog. Missing arguments fall back to the defaults. This is the form autostart uses.
|
|
107
107
|
- `/ask-perm` again — turns it off.
|
|
108
108
|
|
|
109
|
+
Repeating the argument form with the modes already in force is a **no-op**: no second "ON" notice and no loss of the session grants collected so far. Changing any mode notifies and clears those grants, because they were made under the old policy.
|
|
110
|
+
|
|
109
111
|
**Defaults**: in-project read/write allowed, outside read allowed, outside write asks.
|
|
110
112
|
|
|
111
113
|
| Scope | reads | writes / exec |
|
|
@@ -307,15 +309,20 @@ No file = feature disabled, zero overhead, silently skipped.
|
|
|
307
309
|
|
|
308
310
|
The global list runs first, so a project can override an earlier setting (for example a later `/ask-perm` with different arguments). Use the global file only when you want the same commands in every project; per-project files remain the recommended default.
|
|
309
311
|
|
|
312
|
+
An **identical command line** declared by both sources runs once: a duplicate is skipped for that replay, so a global `/ask-perm ...` copied into a project does not run (or announce itself) twice. Different commands still both run, which is what keeps a project override working.
|
|
313
|
+
|
|
310
314
|
**When it runs**:
|
|
311
315
|
|
|
312
316
|
| session_start reason | Replayed | Why |
|
|
313
317
|
| --- | --- | --- |
|
|
314
|
-
| `startup` / `new
|
|
315
|
-
| `
|
|
316
|
-
| `
|
|
318
|
+
| `startup` / `new`, empty session | ✅ | a new session, replay as declared |
|
|
319
|
+
| `startup` on a session that already has history | ❌ | that is a process started on an existing session (`pi -c` / `-r` / `--session` / `--fork`), and its branch already carries the state a replay would rebuild |
|
|
320
|
+
| `fork` (in-process) | ✅ | the branch point may predate the config's appearance |
|
|
321
|
+
| `resume` (in-process `/resume`) | ❌ | the restored branch already carries the mount state from its time, replaying would conflict |
|
|
317
322
|
| `reload` | ❌ | extensions are rebuilt, replaying would duplicate side effects |
|
|
318
323
|
|
|
324
|
+
> The `startup` split is not cosmetic: pi reports `startup` for **every** process start, including one that opens an existing session. `resume` is emitted only for in-process session switches, never for `pi -c`. Lines 1–2 of the table are therefore decided by the session's content (does it already hold a message?), not by the reason alone.
|
|
325
|
+
|
|
319
326
|
**Safety gates** (in order):
|
|
320
327
|
|
|
321
328
|
1. **project trust** — no command runs automatically in an untrusted directory (a warning is shown); a config parse failure likewise prevents execution (an error is shown)
|
package/README.zh-CN.md
CHANGED
|
@@ -106,6 +106,8 @@ pi -e ./src/index.ts
|
|
|
106
106
|
- `/ask-perm allow allow allow ask` —— 位置参数形式(`in-read in-write out-read out-write`),不弹窗直接开启;没给全的参数回落到默认值。这也是 autostart 使用的形式
|
|
107
107
|
- 再输入一次 `/ask-perm` —— 关闭
|
|
108
108
|
|
|
109
|
+
参数形式重复执行、且四项模式与当前一致时是**空操作**:不会再出一条「ON」提示,也不会清掉已经攒下的会话授权。只有模式真的变了才会提示并清空授权——那些授权是按旧策略给的。
|
|
110
|
+
|
|
109
111
|
**默认值**:项目内读/写全放行,项目外读放行,项目外写询问。
|
|
110
112
|
|
|
111
113
|
| 作用域 | 读 | 写 / 执行 |
|
|
@@ -307,15 +309,20 @@ run = [
|
|
|
307
309
|
|
|
308
310
|
全局列表先执行,项目列表后执行,因此项目可以覆盖此前的设置(例如后面再执行一条不同参数的 `/ask-perm`)。只有希望所有项目都用同一套命令时才用全局文件;仍然建议以项目级配置为主。
|
|
309
311
|
|
|
312
|
+
**完全相同的命令行**若同时出现在两个来源,一次重放里只执行一次:重复项跳过,所以把全局的 `/ask-perm ...` 复制到项目里不会再跑一遍、也不会再提示一遍。不同的命令仍然各跑一次——这正是「项目覆盖全局」得以成立的原因。
|
|
313
|
+
|
|
310
314
|
**执行时机**:
|
|
311
315
|
|
|
312
316
|
| session_start reason | 是否重放 | 原因 |
|
|
313
317
|
| --- | --- | --- |
|
|
314
|
-
| `startup` / `new
|
|
315
|
-
| `
|
|
316
|
-
| `
|
|
318
|
+
| `startup` / `new`,空会话 | ✅ | 新会话,按声明重放 |
|
|
319
|
+
| `startup`,但会话已有历史 | ❌ | 这是「进程启动即带着旧会话」(`pi -c` / `-r` / `--session` / `--fork`),其分支已携带重放本该重建的状态 |
|
|
320
|
+
| `fork`(进程内) | ✅ | 分支点可能早于配置出现 |
|
|
321
|
+
| `resume`(进程内 `/resume`) | ❌ | 恢复的分支已携带当时的挂载状态,重放反而会冲突 |
|
|
317
322
|
| `reload` | ❌ | 扩展重建,重放会重复副作用 |
|
|
318
323
|
|
|
324
|
+
> `startup` 这一行为什么要拆开:pi 对**每一次进程启动**都报 `startup`,包括「启动即打开旧会话」的情形;`resume` 只在进程内切换会话时发出,`pi -c` 永远不会报它。所以前两行的判定依据是**会话内容**(是否已有消息),不能只看 reason。
|
|
325
|
+
|
|
319
326
|
**安全门**(按序):
|
|
320
327
|
|
|
321
328
|
1. **project trust** —— 未信任目录不自动执行任何命令(warning 提示);配置解析失败同样不执行(error 提示)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andares/pi-toolkits",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Integrated toolkit extension for pi — ask mode (read-only Q&A), session-scoped permission checks, model favorites, prompt stash, third-party code review, session autostart, and a dedicated compaction model",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
GLOBAL_AUTOSTART_FILENAME,
|
|
20
20
|
parseAutostartConfig,
|
|
21
21
|
RUNNABLE_REASONS,
|
|
22
|
+
shouldReplay,
|
|
22
23
|
} from "./autostart.js";
|
|
23
24
|
import { registerAutostart } from "./index.js";
|
|
24
25
|
|
|
@@ -112,6 +113,28 @@ describe("RUNNABLE_REASONS", () => {
|
|
|
112
113
|
});
|
|
113
114
|
});
|
|
114
115
|
|
|
116
|
+
describe("shouldReplay", () => {
|
|
117
|
+
it("replays a genuinely new session", () => {
|
|
118
|
+
expect(shouldReplay("startup", false)).toBe(true);
|
|
119
|
+
expect(shouldReplay("new", false)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("skips a startup that already has history (a resumed process)", () => {
|
|
123
|
+
// `pi -c`, `-r`, `--session` and `--fork` all report `startup` for a
|
|
124
|
+
// session that already carries history.
|
|
125
|
+
expect(shouldReplay("startup", true)).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("replays a fork even though the branch has history", () => {
|
|
129
|
+
expect(shouldReplay("fork", true)).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("skips in-process resume and reload", () => {
|
|
133
|
+
expect(shouldReplay("resume", true)).toBe(false);
|
|
134
|
+
expect(shouldReplay("reload", true)).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
115
138
|
/* ------------------------------------------------------------------ */
|
|
116
139
|
/* registerAutostart integration (mocked pi surface) */
|
|
117
140
|
/* ------------------------------------------------------------------ */
|
|
@@ -122,6 +145,7 @@ type SessionStartHandler = (
|
|
|
122
145
|
cwd: string;
|
|
123
146
|
isProjectTrusted: () => boolean;
|
|
124
147
|
ui: { notify: (message: string, type?: string) => void };
|
|
148
|
+
sessionManager: { buildContextEntries: () => Array<{ type: string }> };
|
|
125
149
|
},
|
|
126
150
|
) => Promise<void>;
|
|
127
151
|
|
|
@@ -133,6 +157,8 @@ interface Harness {
|
|
|
133
157
|
commands: Array<{ name: string; source: string }>;
|
|
134
158
|
failCommands: Set<string>;
|
|
135
159
|
setTrusted(trusted: boolean): void;
|
|
160
|
+
/** Simulate a session that already had a conversation (resumed process). */
|
|
161
|
+
setHistory(entries: Array<{ type: string }>): void;
|
|
136
162
|
start(reason: string): Promise<void>;
|
|
137
163
|
}
|
|
138
164
|
|
|
@@ -158,6 +184,12 @@ async function makeHarness(
|
|
|
158
184
|
await writeFile(join(cwd, AUTOSTART_CONFIG_PATH), config, "utf8");
|
|
159
185
|
}
|
|
160
186
|
let trusted = true;
|
|
187
|
+
// Boot-level entries every session has; a resumed session adds messages.
|
|
188
|
+
let history: Array<{ type: string }> = [
|
|
189
|
+
{ type: "session" },
|
|
190
|
+
{ type: "model_change" },
|
|
191
|
+
{ type: "thinking_level_change" },
|
|
192
|
+
];
|
|
161
193
|
let handler: SessionStartHandler = async () => {};
|
|
162
194
|
const sent: Harness["sent"] = [];
|
|
163
195
|
const notifications: Harness["notifications"] = [];
|
|
@@ -191,6 +223,9 @@ async function makeHarness(
|
|
|
191
223
|
setTrusted(value: boolean) {
|
|
192
224
|
trusted = value;
|
|
193
225
|
},
|
|
226
|
+
setHistory(entries) {
|
|
227
|
+
history = entries;
|
|
228
|
+
},
|
|
194
229
|
async start(reason: string) {
|
|
195
230
|
await handler({ reason }, {
|
|
196
231
|
cwd,
|
|
@@ -200,6 +235,7 @@ async function makeHarness(
|
|
|
200
235
|
notifications.push({ message, type });
|
|
201
236
|
},
|
|
202
237
|
},
|
|
238
|
+
sessionManager: { buildContextEntries: () => history },
|
|
203
239
|
});
|
|
204
240
|
},
|
|
205
241
|
cwd,
|
|
@@ -266,6 +302,38 @@ describe("registerAutostart", () => {
|
|
|
266
302
|
expect(h.notifications).toEqual([]);
|
|
267
303
|
});
|
|
268
304
|
|
|
305
|
+
it("skips a process started on an existing session (pi -c reports startup)", async () => {
|
|
306
|
+
const h = await harness(fullConfig);
|
|
307
|
+
h.setHistory([
|
|
308
|
+
{ type: "session" },
|
|
309
|
+
{ type: "model_change" },
|
|
310
|
+
{ type: "message" },
|
|
311
|
+
]);
|
|
312
|
+
await h.start("startup");
|
|
313
|
+
expect(h.sent).toEqual([]);
|
|
314
|
+
expect(h.notifications).toEqual([]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("still replays when the session holds only boot-level entries", async () => {
|
|
318
|
+
const h = await harness(fullConfig);
|
|
319
|
+
h.setHistory([
|
|
320
|
+
{ type: "session" },
|
|
321
|
+
{ type: "model_change" },
|
|
322
|
+
{ type: "thinking_level_change" },
|
|
323
|
+
{ type: "session_info" },
|
|
324
|
+
{ type: "custom" },
|
|
325
|
+
]);
|
|
326
|
+
await h.start("startup");
|
|
327
|
+
expect(h.sent).toHaveLength(2);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("replays a fork of a session that has history", async () => {
|
|
331
|
+
const h = await harness(fullConfig);
|
|
332
|
+
h.setHistory([{ type: "message" }]);
|
|
333
|
+
await h.start("fork");
|
|
334
|
+
expect(h.sent).toHaveLength(2);
|
|
335
|
+
});
|
|
336
|
+
|
|
269
337
|
it("silently skips when no config file exists", async () => {
|
|
270
338
|
const h = await harness(null);
|
|
271
339
|
await h.start("startup");
|
|
@@ -350,6 +418,29 @@ describe("registerAutostart", () => {
|
|
|
350
418
|
expect(h.notifications[0]?.type).toBe("warning");
|
|
351
419
|
});
|
|
352
420
|
|
|
421
|
+
it("runs an identical command only once across sources", async () => {
|
|
422
|
+
const h = await harness(
|
|
423
|
+
"[command]\nrun = [\"/ask on\"]",
|
|
424
|
+
false,
|
|
425
|
+
"[command]\nrun = [\"/ask on\"]",
|
|
426
|
+
);
|
|
427
|
+
await h.start("startup");
|
|
428
|
+
expect(h.sent.map((entry) => entry.command)).toEqual(["/ask on"]);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("still runs different commands from both sources", async () => {
|
|
432
|
+
const h = await harness(
|
|
433
|
+
"[command]\nrun = [\"/ask on\", \"/add-dir ../p\"]",
|
|
434
|
+
false,
|
|
435
|
+
"[command]\nrun = [\"/ask on\"]",
|
|
436
|
+
);
|
|
437
|
+
await h.start("startup");
|
|
438
|
+
expect(h.sent.map((entry) => entry.command)).toEqual([
|
|
439
|
+
"/ask on",
|
|
440
|
+
"/add-dir ../p",
|
|
441
|
+
]);
|
|
442
|
+
});
|
|
443
|
+
|
|
353
444
|
it("keeps running the project config when the global one is absent", async () => {
|
|
354
445
|
const h = await harness('[command]\nrun = ["/ask on"]', false, null);
|
|
355
446
|
await h.start("startup");
|
|
@@ -41,6 +41,30 @@ export const RUNNABLE_REASONS: ReadonlySet<string> = new Set([
|
|
|
41
41
|
"fork",
|
|
42
42
|
]);
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Whether this session start should replay the command lists.
|
|
46
|
+
*
|
|
47
|
+
* The reason alone is not enough at process start. `pi -c` (`--continue`),
|
|
48
|
+
* `-r` (`--resume`), `--session <path>` and `--fork <path>` all open an
|
|
49
|
+
* existing session *before* the first runtime exists, and that runtime is
|
|
50
|
+
* created without an explicit session_start event, so extensions see pi's
|
|
51
|
+
* default reason — `startup` — not `resume` (verified on pi 1.0.0–1.3.0:
|
|
52
|
+
* `main.js` builds the initial session manager from those flags and calls
|
|
53
|
+
* `createAgentSessionRuntime` with no `sessionStartEvent`;
|
|
54
|
+
* `agent-session.js` defaults it to `{ reason: "startup" }`). `resume` is only
|
|
55
|
+
* emitted for in-process session switches, never for a resumed process.
|
|
56
|
+
*
|
|
57
|
+
* So the session content decides: a `startup` on a session that already has
|
|
58
|
+
* conversation history is a resumed (or forked) process, and its branch already
|
|
59
|
+
* carries the state a replay would rebuild. `hasHistory` is the same signal pi
|
|
60
|
+
* itself uses to recognise an existing session
|
|
61
|
+
* (`buildSessionContext().messages.length > 0`).
|
|
62
|
+
*/
|
|
63
|
+
export function shouldReplay(reason: string, hasHistory: boolean): boolean {
|
|
64
|
+
if (reason === "startup") return !hasHistory;
|
|
65
|
+
return RUNNABLE_REASONS.has(reason);
|
|
66
|
+
}
|
|
67
|
+
|
|
44
68
|
/** Parse/validation failure with a user-facing message. */
|
|
45
69
|
export class AutostartParseError extends Error {}
|
|
46
70
|
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Execution gates, in order:
|
|
14
14
|
*
|
|
15
|
-
* - reason filter —
|
|
15
|
+
* - reason filter — see `shouldReplay`: startup/new/fork replay, but a
|
|
16
|
+
* `startup` that already carries conversation history is a process started
|
|
17
|
+
* on an existing session (`pi -c`/`-r`/`--session`/`--fork`) and does not
|
|
16
18
|
* - config file exists — a missing file is a silent opt-in skip
|
|
17
19
|
* - project trust (project source only)
|
|
18
20
|
* - per-command pre-validation against `pi.getCommands()`: only commands
|
|
@@ -44,7 +46,7 @@ import {
|
|
|
44
46
|
commandNameOf,
|
|
45
47
|
GLOBAL_AUTOSTART_FILENAME,
|
|
46
48
|
parseAutostartConfig,
|
|
47
|
-
|
|
49
|
+
shouldReplay,
|
|
48
50
|
} from "./autostart.js";
|
|
49
51
|
|
|
50
52
|
export interface AutostartOptions {
|
|
@@ -63,6 +65,38 @@ function messageOf(error: unknown): string {
|
|
|
63
65
|
return error instanceof Error ? error.message : String(error);
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Entry types that prove the session already held a conversation.
|
|
70
|
+
*
|
|
71
|
+
* `buildContextEntries()` returns the active branch's entries including the
|
|
72
|
+
* boot-level ones (`session`, `model_change`, `thinking_level_change`), so
|
|
73
|
+
* presence alone is not enough — a brand-new session has those too.
|
|
74
|
+
*/
|
|
75
|
+
const HISTORY_ENTRY_TYPES: ReadonlySet<string> = new Set([
|
|
76
|
+
"message",
|
|
77
|
+
"custom_message",
|
|
78
|
+
"compaction",
|
|
79
|
+
"branch_summary",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether the session already carries conversation history.
|
|
84
|
+
*
|
|
85
|
+
* This is how a `startup` on an existing session (a resumed process) is told
|
|
86
|
+
* apart from a genuinely new one — see {@link shouldReplay}. An unreadable
|
|
87
|
+
* session manager counts as new, which keeps the feature working in hosts where
|
|
88
|
+
* the session surface is unavailable.
|
|
89
|
+
*/
|
|
90
|
+
function hasConversationHistory(ctx: ExtensionContext): boolean {
|
|
91
|
+
try {
|
|
92
|
+
return ctx.sessionManager
|
|
93
|
+
.buildContextEntries()
|
|
94
|
+
.some((entry) => HISTORY_ENTRY_TYPES.has(entry.type));
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
66
100
|
export function registerAutostart(
|
|
67
101
|
pi: ExtensionAPI,
|
|
68
102
|
options: AutostartOptions = {},
|
|
@@ -101,7 +135,10 @@ export function registerAutostart(
|
|
|
101
135
|
}
|
|
102
136
|
|
|
103
137
|
pi.on("session_start", async (event, ctx) => {
|
|
104
|
-
|
|
138
|
+
// `pi -c` / `-r` / `--session` / `--fork` report `startup` for a session
|
|
139
|
+
// that already has history; the content is what tells them apart. See
|
|
140
|
+
// shouldReplay in autostart.ts for the host-side evidence.
|
|
141
|
+
if (!shouldReplay(event.reason, hasConversationHistory(ctx))) return;
|
|
105
142
|
|
|
106
143
|
const sources: Source[] = [
|
|
107
144
|
{
|
|
@@ -126,6 +163,13 @@ export function registerAutostart(
|
|
|
126
163
|
),
|
|
127
164
|
);
|
|
128
165
|
|
|
166
|
+
// The same line may be declared by both sources (global defaults plus a
|
|
167
|
+
// project copy). Running an identical command twice in one replay is
|
|
168
|
+
// never meaningful — a repeated `/ask-perm` would re-notify, and a
|
|
169
|
+
// repeated `/add-dir` is pure noise — so identical text runs once.
|
|
170
|
+
// Different commands still both run, which keeps the project's override.
|
|
171
|
+
const dispatched = new Set<string>();
|
|
172
|
+
|
|
129
173
|
for (const source of sources) {
|
|
130
174
|
const commands = await readCommands(ctx, source);
|
|
131
175
|
if (commands === undefined || commands.length === 0) continue;
|
|
@@ -147,6 +191,8 @@ export function registerAutostart(
|
|
|
147
191
|
);
|
|
148
192
|
continue;
|
|
149
193
|
}
|
|
194
|
+
if (dispatched.has(command)) continue;
|
|
195
|
+
dispatched.add(command);
|
|
150
196
|
// One failing command must not cancel the rest of the list.
|
|
151
197
|
try {
|
|
152
198
|
await pi.sendUserMessage(command, { expandPromptTemplates: true });
|
|
@@ -269,15 +269,30 @@ describe("/ask-perm", () => {
|
|
|
269
269
|
expect(await h.evaluate(customEvent("mcp"))).toBeUndefined();
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
-
it("
|
|
272
|
+
it("is idempotent: repeating the same command notifies once and keeps grants", async () => {
|
|
273
273
|
const h = makeHarness();
|
|
274
274
|
await h.command("allow allow allow ask");
|
|
275
|
+
expect(h.notes).toHaveLength(1);
|
|
276
|
+
|
|
275
277
|
asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x"))).request.apply(
|
|
276
278
|
"allow-session",
|
|
277
279
|
);
|
|
280
|
+
await h.command("allow allow allow ask");
|
|
281
|
+
expect(h.notes).toHaveLength(1);
|
|
282
|
+
// The grant collected before the repeat is still in force.
|
|
278
283
|
expect(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).toBeUndefined();
|
|
284
|
+
});
|
|
279
285
|
|
|
286
|
+
it("notifies and clears session grants when the modes actually change", async () => {
|
|
287
|
+
const h = makeHarness();
|
|
280
288
|
await h.command("allow allow allow ask");
|
|
289
|
+
asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x"))).request.apply(
|
|
290
|
+
"allow-session",
|
|
291
|
+
);
|
|
292
|
+
expect(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).toBeUndefined();
|
|
293
|
+
|
|
294
|
+
await h.command("ask allow allow ask");
|
|
295
|
+
expect(h.notes).toHaveLength(2);
|
|
281
296
|
expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).kind).toBe("ask");
|
|
282
297
|
});
|
|
283
298
|
});
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import {
|
|
32
32
|
formatModes,
|
|
33
33
|
parsePermissionArgs,
|
|
34
|
+
sameModes,
|
|
34
35
|
shortMode,
|
|
35
36
|
} from "./modes.js";
|
|
36
37
|
import { openPermissionPanel } from "./panel.js";
|
|
@@ -253,9 +254,16 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
253
254
|
ctx.ui.notify(`ask-perm: ${parsed.error}`, "error");
|
|
254
255
|
return;
|
|
255
256
|
}
|
|
257
|
+
// Idempotent: the same command may arrive twice in one session start
|
|
258
|
+
// (both autostart sources declaring it, a duplicated config line, …).
|
|
259
|
+
// Re-applying an unchanged policy is a no-op — no second "ON" notice,
|
|
260
|
+
// and no loss of the session grants collected so far.
|
|
261
|
+
if (enabled && sameModes(modes, parsed.modes)) return;
|
|
262
|
+
|
|
263
|
+
const changed = !sameModes(modes, parsed.modes);
|
|
256
264
|
modes = parsed.modes;
|
|
257
|
-
// A
|
|
258
|
-
rules.clear();
|
|
265
|
+
// A changed policy invalidates grants made under the old one.
|
|
266
|
+
if (changed) rules.clear();
|
|
259
267
|
enabled = true;
|
|
260
268
|
syncStatus(ctx);
|
|
261
269
|
ctx.ui.notify(
|
|
@@ -284,7 +292,9 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
284
292
|
);
|
|
285
293
|
return;
|
|
286
294
|
}
|
|
295
|
+
// The panel only opens while off, so this always transitions on.
|
|
287
296
|
modes = chosen;
|
|
297
|
+
rules.clear();
|
|
288
298
|
enabled = true;
|
|
289
299
|
syncStatus(ctx);
|
|
290
300
|
ctx.ui.notify(
|
|
@@ -80,3 +80,13 @@ export function shortMode(mode: TriMode | "allow"): string {
|
|
|
80
80
|
return "D";
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
/** Structural equality for the four modes. */
|
|
85
|
+
export function sameModes(a: PermissionModes, b: PermissionModes): boolean {
|
|
86
|
+
return (
|
|
87
|
+
a.inRead === b.inRead &&
|
|
88
|
+
a.inWrite === b.inWrite &&
|
|
89
|
+
a.outRead === b.outRead &&
|
|
90
|
+
a.outWrite === b.outWrite
|
|
91
|
+
);
|
|
92
|
+
}
|