@andares/pi-toolkits 0.9.2 → 0.9.4
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 +2 -0
- package/README.zh-CN.md +2 -0
- package/package.json +1 -1
- package/src/features/autostart/autostart.test.ts +51 -2
- package/src/features/autostart/index.ts +29 -0
- package/src/features/permission/index.test.ts +48 -1
- package/src/features/permission/index.ts +35 -8
- package/src/lib/quiet.test.ts +22 -0
- package/src/lib/quiet.ts +35 -0
package/README.md
CHANGED
|
@@ -133,6 +133,8 @@ Repeating the argument form with the modes already in force is a **no-op**: no s
|
|
|
133
133
|
|
|
134
134
|
**Composition with `/ask`**: the two features are independent gates on one bus. Each answers only its own question — ask: "does this mutate?", permission: "does this cross the boundary?" — and either can block. Ask mode still denies credential reads, and permission still applies while the read-only sandbox is active. Neither feature needs to know the other exists.
|
|
135
135
|
|
|
136
|
+
**Startup is quiet**: when the command comes from autostart, the chat notice is suppressed and only the footer status (`perm i:… o:…`) announces the mode. This is deliberate — the host keeps every info notice in the chat but renders all of them from one shared "last status" field, so an info notice emitted after another extension's warning (for example `/lens-toggle`) makes the earlier line display the new text too, i.e. the same message twice. A manual `/ask-perm` still prints the notice.
|
|
137
|
+
|
|
136
138
|
**Enable it for every session** (the state itself is never persisted — this is the opt-in):
|
|
137
139
|
|
|
138
140
|
```toml
|
package/README.zh-CN.md
CHANGED
|
@@ -133,6 +133,8 @@ pi -e ./src/index.ts
|
|
|
133
133
|
|
|
134
134
|
**与 `/ask` 的组合**:两者是同一总线上的独立闸门,各自只回答自己的问题 —— ask 回答「会不会产生变更」,permission 回答「有没有越过边界」,任一拦截都生效。所以 ask 模式下凭据仍然读不到,permission 在只读沙箱下也照常工作;两个功能互不需要知道对方存在。
|
|
135
135
|
|
|
136
|
+
**启动时保持安静**:命令由 autostart 触发时不再往聊天区发提示,只由 footer 状态(`perm i:… o:…`)体现。这是有意的——宿主会把每条 info 提示留在聊天记录里,但所有这种行都渲染同一个「最新状态」字段,于是紧跟在别的扩展 warning(例如 `/lens-toggle`)之后的 info 提示会让前一行也显示新文案,也就是同一条消息出现两次。手动执行 `/ask-perm` 仍然会提示。
|
|
137
|
+
|
|
136
138
|
**每个会话自动开启**(状态本身不落盘,这就是持久化的替身):
|
|
137
139
|
|
|
138
140
|
```toml
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andares/pi-toolkits",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
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",
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
RUNNABLE_REASONS,
|
|
22
22
|
shouldReplay,
|
|
23
23
|
} from "./autostart.js";
|
|
24
|
+
import { consumeQuiet } from "../../lib/quiet.js";
|
|
24
25
|
import { registerAutostart } from "./index.js";
|
|
25
26
|
|
|
26
27
|
describe("parseAutostartConfig", () => {
|
|
@@ -145,7 +146,10 @@ type SessionStartHandler = (
|
|
|
145
146
|
cwd: string;
|
|
146
147
|
isProjectTrusted: () => boolean;
|
|
147
148
|
ui: { notify: (message: string, type?: string) => void };
|
|
148
|
-
sessionManager: {
|
|
149
|
+
sessionManager: {
|
|
150
|
+
buildContextEntries: () => Array<{ type: string }>;
|
|
151
|
+
getSessionId: () => string;
|
|
152
|
+
};
|
|
149
153
|
},
|
|
150
154
|
) => Promise<void>;
|
|
151
155
|
|
|
@@ -156,9 +160,11 @@ interface Harness {
|
|
|
156
160
|
notifications: Array<{ message: string; type?: string }>;
|
|
157
161
|
commands: Array<{ name: string; source: string }>;
|
|
158
162
|
failCommands: Set<string>;
|
|
163
|
+
quietAtDispatch: Array<{ command: string; quiet: boolean }>;
|
|
159
164
|
setTrusted(trusted: boolean): void;
|
|
160
165
|
/** Simulate a session that already had a conversation (resumed process). */
|
|
161
166
|
setHistory(entries: Array<{ type: string }>): void;
|
|
167
|
+
setSessionId(id: string): void;
|
|
162
168
|
start(reason: string): Promise<void>;
|
|
163
169
|
}
|
|
164
170
|
|
|
@@ -190,13 +196,16 @@ async function makeHarness(
|
|
|
190
196
|
{ type: "model_change" },
|
|
191
197
|
{ type: "thinking_level_change" },
|
|
192
198
|
];
|
|
199
|
+
let sessionId = "session-1";
|
|
193
200
|
let handler: SessionStartHandler = async () => {};
|
|
194
201
|
const sent: Harness["sent"] = [];
|
|
195
202
|
const notifications: Harness["notifications"] = [];
|
|
196
203
|
const commands: Harness["commands"] = [
|
|
197
204
|
{ name: "add-dir", source: "extension" },
|
|
198
205
|
{ name: "ask", source: "extension" },
|
|
206
|
+
{ name: "ask-perm", source: "extension" },
|
|
199
207
|
];
|
|
208
|
+
const quietAtDispatch: Harness["quietAtDispatch"] = [];
|
|
200
209
|
const failCommands = new Set<string>();
|
|
201
210
|
const pi = {
|
|
202
211
|
on: (event: string, h: SessionStartHandler) => {
|
|
@@ -208,6 +217,7 @@ async function makeHarness(
|
|
|
208
217
|
options?: { expandPromptTemplates?: boolean },
|
|
209
218
|
) => {
|
|
210
219
|
sent.push({ command, options }); // records attempts, including failures
|
|
220
|
+
quietAtDispatch.push({ command, quiet: consumeQuiet("ask-perm") });
|
|
211
221
|
if (failCommands.has(command)) {
|
|
212
222
|
return Promise.reject(new Error("boom"));
|
|
213
223
|
}
|
|
@@ -217,6 +227,7 @@ async function makeHarness(
|
|
|
217
227
|
return {
|
|
218
228
|
handler,
|
|
219
229
|
sent,
|
|
230
|
+
quietAtDispatch,
|
|
220
231
|
notifications,
|
|
221
232
|
commands,
|
|
222
233
|
failCommands,
|
|
@@ -226,6 +237,9 @@ async function makeHarness(
|
|
|
226
237
|
setHistory(entries) {
|
|
227
238
|
history = entries;
|
|
228
239
|
},
|
|
240
|
+
setSessionId(id) {
|
|
241
|
+
sessionId = id;
|
|
242
|
+
},
|
|
229
243
|
async start(reason: string) {
|
|
230
244
|
await handler({ reason }, {
|
|
231
245
|
cwd,
|
|
@@ -235,7 +249,10 @@ async function makeHarness(
|
|
|
235
249
|
notifications.push({ message, type });
|
|
236
250
|
},
|
|
237
251
|
},
|
|
238
|
-
sessionManager: {
|
|
252
|
+
sessionManager: {
|
|
253
|
+
buildContextEntries: () => history,
|
|
254
|
+
getSessionId: () => sessionId,
|
|
255
|
+
},
|
|
239
256
|
});
|
|
240
257
|
},
|
|
241
258
|
cwd,
|
|
@@ -286,6 +303,8 @@ describe("registerAutostart", () => {
|
|
|
286
303
|
it("replays on new and fork", async () => {
|
|
287
304
|
const h = await harness('[command]\nrun = ["/ask on"]');
|
|
288
305
|
await h.start("new");
|
|
306
|
+
// A fork is a different session, so it replays again.
|
|
307
|
+
h.setSessionId("forked-session");
|
|
289
308
|
await h.start("fork");
|
|
290
309
|
expect(h.sent).toEqual([
|
|
291
310
|
{ command: "/ask on", options: { expandPromptTemplates: true } },
|
|
@@ -302,6 +321,36 @@ describe("registerAutostart", () => {
|
|
|
302
321
|
expect(h.notifications).toEqual([]);
|
|
303
322
|
});
|
|
304
323
|
|
|
324
|
+
it("marks our own command quiet for the dispatch and clears the mark", async () => {
|
|
325
|
+
const h = await harness('[command]\nrun = ["/ask-perm allow"]');
|
|
326
|
+
await h.start("startup");
|
|
327
|
+
expect(h.quietAtDispatch).toEqual([
|
|
328
|
+
{ command: "/ask-perm allow", quiet: true },
|
|
329
|
+
]);
|
|
330
|
+
expect(consumeQuiet("ask-perm")).toBe(false);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("leaves other extensions' commands unmarked", async () => {
|
|
334
|
+
const h = await harness(fullConfig);
|
|
335
|
+
await h.start("startup");
|
|
336
|
+
expect(h.quietAtDispatch.every((entry) => entry.quiet === false)).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("replays once per session even if session_start is emitted twice", async () => {
|
|
340
|
+
const h = await harness(fullConfig);
|
|
341
|
+
await h.start("startup");
|
|
342
|
+
await h.start("startup");
|
|
343
|
+
expect(h.sent).toHaveLength(2);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("replays again for a new session id", async () => {
|
|
347
|
+
const h = await harness(fullConfig);
|
|
348
|
+
await h.start("startup");
|
|
349
|
+
h.setSessionId("session-2");
|
|
350
|
+
await h.start("startup");
|
|
351
|
+
expect(h.sent).toHaveLength(4);
|
|
352
|
+
});
|
|
353
|
+
|
|
305
354
|
it("skips a process started on an existing session (pi -c reports startup)", async () => {
|
|
306
355
|
const h = await harness(fullConfig);
|
|
307
356
|
h.setHistory([
|
|
@@ -40,6 +40,7 @@ import { readFile } from "node:fs/promises";
|
|
|
40
40
|
import { join } from "node:path";
|
|
41
41
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
42
42
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
43
|
+
import { clearQuiet, markQuiet } from "../../lib/quiet.js";
|
|
43
44
|
import {
|
|
44
45
|
AUTOSTART_CONFIG_PATH,
|
|
45
46
|
AutostartParseError,
|
|
@@ -87,6 +88,15 @@ const HISTORY_ENTRY_TYPES: ReadonlySet<string> = new Set([
|
|
|
87
88
|
* session manager counts as new, which keeps the feature working in hosts where
|
|
88
89
|
* the session surface is unavailable.
|
|
89
90
|
*/
|
|
91
|
+
/** Session id, or `undefined` when the host exposes none. */
|
|
92
|
+
function sessionIdOf(ctx: ExtensionContext): string | undefined {
|
|
93
|
+
try {
|
|
94
|
+
return ctx.sessionManager.getSessionId();
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
90
100
|
function hasConversationHistory(ctx: ExtensionContext): boolean {
|
|
91
101
|
try {
|
|
92
102
|
return ctx.sessionManager
|
|
@@ -102,6 +112,11 @@ export function registerAutostart(
|
|
|
102
112
|
options: AutostartOptions = {},
|
|
103
113
|
): void {
|
|
104
114
|
const agentDir = options.agentDir ?? getAgentDir();
|
|
115
|
+
// One replay per session. The host can emit `session_start` more than once
|
|
116
|
+
// for the same session (a runtime rebind during startup reports `startup`
|
|
117
|
+
// again), and replaying on the second emission would announce everything
|
|
118
|
+
// twice.
|
|
119
|
+
const replayedSessions = new Set<string>();
|
|
105
120
|
|
|
106
121
|
async function readCommands(
|
|
107
122
|
ctx: ExtensionContext,
|
|
@@ -140,6 +155,12 @@ export function registerAutostart(
|
|
|
140
155
|
// shouldReplay in autostart.ts for the host-side evidence.
|
|
141
156
|
if (!shouldReplay(event.reason, hasConversationHistory(ctx))) return;
|
|
142
157
|
|
|
158
|
+
const sessionId = sessionIdOf(ctx);
|
|
159
|
+
if (sessionId !== undefined) {
|
|
160
|
+
if (replayedSessions.has(sessionId)) return;
|
|
161
|
+
replayedSessions.add(sessionId);
|
|
162
|
+
}
|
|
163
|
+
|
|
143
164
|
const sources: Source[] = [
|
|
144
165
|
{
|
|
145
166
|
path: join(agentDir, GLOBAL_AUTOSTART_FILENAME),
|
|
@@ -193,6 +214,12 @@ export function registerAutostart(
|
|
|
193
214
|
}
|
|
194
215
|
if (dispatched.has(command)) continue;
|
|
195
216
|
dispatched.add(command);
|
|
217
|
+
// Our own commands stay silent on startup: the host keeps every
|
|
218
|
+
// info notice and re-renders old lines with the newest text, so a
|
|
219
|
+
// second info notice after another extension's warning would show
|
|
220
|
+
// the same message twice (see lib/quiet.ts).
|
|
221
|
+
const quiet = name === "ask-perm";
|
|
222
|
+
if (quiet) markQuiet(name);
|
|
196
223
|
// One failing command must not cancel the rest of the list.
|
|
197
224
|
try {
|
|
198
225
|
await pi.sendUserMessage(command, { expandPromptTemplates: true });
|
|
@@ -201,6 +228,8 @@ export function registerAutostart(
|
|
|
201
228
|
`autostart: "${command}" failed — ${messageOf(err)}`,
|
|
202
229
|
"error",
|
|
203
230
|
);
|
|
231
|
+
} finally {
|
|
232
|
+
if (quiet) clearQuiet(name);
|
|
204
233
|
}
|
|
205
234
|
}
|
|
206
235
|
}
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
ToolCallEvent,
|
|
16
16
|
} from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import type { AskDecision, GateHub, ToolGate } from "../../lib/gates.js";
|
|
18
|
+
import { markQuiet } from "../../lib/quiet.js";
|
|
18
19
|
import { registerPermission } from "./index.js";
|
|
19
20
|
|
|
20
21
|
function bashEvent(command: string): ToolCallEvent {
|
|
@@ -51,6 +52,7 @@ interface Harness {
|
|
|
51
52
|
readonly statuses: Array<[string, string | undefined]>;
|
|
52
53
|
readonly notes: Array<{ message: string; kind?: string }>;
|
|
53
54
|
readonly branch: Array<Record<string, unknown>>;
|
|
55
|
+
setSessionId(id: string): void;
|
|
54
56
|
evaluate(event: ToolCallEvent): Promise<unknown>;
|
|
55
57
|
command(args: string): Promise<void>;
|
|
56
58
|
sessionStart(): Promise<void>;
|
|
@@ -62,6 +64,7 @@ function makeHarness(): Harness {
|
|
|
62
64
|
const statuses: Harness["statuses"] = [];
|
|
63
65
|
const notes: Harness["notes"] = [];
|
|
64
66
|
const branch: Harness["branch"] = [];
|
|
67
|
+
let sessionId = "session-1";
|
|
65
68
|
const ctx = {
|
|
66
69
|
cwd,
|
|
67
70
|
hasUI: true,
|
|
@@ -73,7 +76,10 @@ function makeHarness(): Harness {
|
|
|
73
76
|
custom: async () => undefined,
|
|
74
77
|
select: async () => undefined,
|
|
75
78
|
},
|
|
76
|
-
sessionManager: {
|
|
79
|
+
sessionManager: {
|
|
80
|
+
getBranch: () => branch,
|
|
81
|
+
getSessionId: () => sessionId,
|
|
82
|
+
},
|
|
77
83
|
} as unknown as ExtensionContext;
|
|
78
84
|
|
|
79
85
|
const captured: ToolGate[] = [];
|
|
@@ -110,6 +116,9 @@ function makeHarness(): Harness {
|
|
|
110
116
|
statuses,
|
|
111
117
|
notes,
|
|
112
118
|
branch,
|
|
119
|
+
setSessionId(id: string) {
|
|
120
|
+
sessionId = id;
|
|
121
|
+
},
|
|
113
122
|
async evaluate(event) {
|
|
114
123
|
return gate.evaluate(event, ctx);
|
|
115
124
|
},
|
|
@@ -214,6 +223,25 @@ describe("/ask-perm", () => {
|
|
|
214
223
|
expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
|
|
215
224
|
});
|
|
216
225
|
|
|
226
|
+
it("keeps the state when the same session emits session_start again", async () => {
|
|
227
|
+
const h = makeHarness();
|
|
228
|
+
await h.sessionStart(); // the real first emission
|
|
229
|
+
await h.command("allow allow allow ask");
|
|
230
|
+
// A startup rebind reports the same session again; state must survive.
|
|
231
|
+
await h.sessionStart();
|
|
232
|
+
expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).kind).toBe(
|
|
233
|
+
"ask",
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("resets on session_start for a different session", async () => {
|
|
238
|
+
const h = makeHarness();
|
|
239
|
+
await h.command("allow allow allow ask");
|
|
240
|
+
h.setSessionId("session-2");
|
|
241
|
+
await h.sessionStart();
|
|
242
|
+
expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
|
|
217
245
|
it("resets on session_start", async () => {
|
|
218
246
|
const h = makeHarness();
|
|
219
247
|
await h.command("allow allow allow ask");
|
|
@@ -269,6 +297,25 @@ describe("/ask-perm", () => {
|
|
|
269
297
|
expect(await h.evaluate(customEvent("mcp"))).toBeUndefined();
|
|
270
298
|
});
|
|
271
299
|
|
|
300
|
+
it("suppresses the chat notice when autostart started the command", async () => {
|
|
301
|
+
const h = makeHarness();
|
|
302
|
+
markQuiet("ask-perm");
|
|
303
|
+
await h.command("allow allow allow ask");
|
|
304
|
+
// The footer carries the state; the chat stays quiet at startup.
|
|
305
|
+
expect(h.notes).toHaveLength(0);
|
|
306
|
+
expect(h.statuses.at(-1)?.[1]).toContain("perm");
|
|
307
|
+
expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).kind).toBe(
|
|
308
|
+
"ask",
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("still reports an invalid argument from autostart", async () => {
|
|
313
|
+
const h = makeHarness();
|
|
314
|
+
markQuiet("ask-perm");
|
|
315
|
+
await h.command("deny");
|
|
316
|
+
expect(h.notes.at(-1)?.kind).toBe("error");
|
|
317
|
+
});
|
|
318
|
+
|
|
272
319
|
it("is idempotent: repeating the same command notifies once and keeps grants", async () => {
|
|
273
320
|
const h = makeHarness();
|
|
274
321
|
await h.command("allow allow allow ask");
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
ToolCallEvent,
|
|
23
23
|
} from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import type { AskRequest, GateHub } from "../../lib/gates.js";
|
|
25
|
+
import { consumeQuiet } from "../../lib/quiet.js";
|
|
25
26
|
import {
|
|
26
27
|
DEFAULT_MODES,
|
|
27
28
|
type PermissionModes,
|
|
@@ -99,6 +100,10 @@ function toolUnit(
|
|
|
99
100
|
export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
100
101
|
let enabled = false;
|
|
101
102
|
let modes: PermissionModes = { ...DEFAULT_MODES };
|
|
103
|
+
// Session this state belongs to. A runtime rebind can emit `session_start`
|
|
104
|
+
// again for the same session; resetting then would wipe a mode that the
|
|
105
|
+
// autostart replay had just enabled, so only a different session resets.
|
|
106
|
+
let currentSessionId: string | undefined;
|
|
102
107
|
const rules = new SessionRules();
|
|
103
108
|
let rootsCache:
|
|
104
109
|
| { cwd: string; branchLength: number; roots: string[] }
|
|
@@ -111,6 +116,15 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
111
116
|
rootsCache = undefined;
|
|
112
117
|
}
|
|
113
118
|
|
|
119
|
+
/** Session id, or `undefined` when the host exposes none. */
|
|
120
|
+
function sessionIdOf(ctx: ExtensionContext): string | undefined {
|
|
121
|
+
try {
|
|
122
|
+
return ctx.sessionManager.getSessionId();
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
114
128
|
function syncStatus(ctx: ExtensionContext): void {
|
|
115
129
|
if (!enabled) {
|
|
116
130
|
ctx.ui.setStatus(PERMISSION_STATUS_KEY, undefined);
|
|
@@ -233,12 +247,16 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
233
247
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
234
248
|
|
|
235
249
|
pi.on("session_start", (_event, ctx) => {
|
|
250
|
+
const sessionId = sessionIdOf(ctx);
|
|
251
|
+
// Same session, repeated emission (a startup rebind): keep the state.
|
|
252
|
+
if (sessionId !== undefined && sessionId === currentSessionId) {
|
|
253
|
+
syncStatus(ctx);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
currentSessionId = sessionId;
|
|
236
257
|
reset();
|
|
237
258
|
syncStatus(ctx);
|
|
238
259
|
});
|
|
239
|
-
pi.on("session_shutdown", () => {
|
|
240
|
-
reset();
|
|
241
|
-
});
|
|
242
260
|
|
|
243
261
|
// ── Command ────────────────────────────────────────────────────────────
|
|
244
262
|
|
|
@@ -246,12 +264,21 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
246
264
|
description:
|
|
247
265
|
"Toggle permission checks; with arguments: /ask-perm [in-read] [in-write] [out-read] [out-write]",
|
|
248
266
|
handler: async (args, ctx) => {
|
|
267
|
+
// Started by autostart: the footer status is the notice (lib/quiet.ts).
|
|
268
|
+
const quietNotice = consumeQuiet("ask-perm");
|
|
269
|
+
const notify = (
|
|
270
|
+
message: string,
|
|
271
|
+
type: "info" | "warning" | "error" = "info",
|
|
272
|
+
): void => {
|
|
273
|
+
if (type === "info" && quietNotice) return;
|
|
274
|
+
ctx.ui.notify(message, type);
|
|
275
|
+
};
|
|
249
276
|
const raw = args.trim();
|
|
250
277
|
|
|
251
278
|
if (raw !== "") {
|
|
252
279
|
const parsed = parsePermissionArgs(raw);
|
|
253
280
|
if ("error" in parsed) {
|
|
254
|
-
|
|
281
|
+
notify(`ask-perm: ${parsed.error}`, "error");
|
|
255
282
|
return;
|
|
256
283
|
}
|
|
257
284
|
// Idempotent: the same command may arrive twice in one session start
|
|
@@ -266,7 +293,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
266
293
|
if (changed) rules.clear();
|
|
267
294
|
enabled = true;
|
|
268
295
|
syncStatus(ctx);
|
|
269
|
-
|
|
296
|
+
notify(
|
|
270
297
|
`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
|
|
271
298
|
"info",
|
|
272
299
|
);
|
|
@@ -277,7 +304,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
277
304
|
enabled = false;
|
|
278
305
|
rules.clear();
|
|
279
306
|
syncStatus(ctx);
|
|
280
|
-
|
|
307
|
+
notify(
|
|
281
308
|
"Permission OFF — no boundary checks in this session.",
|
|
282
309
|
"info",
|
|
283
310
|
);
|
|
@@ -286,7 +313,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
286
313
|
|
|
287
314
|
const chosen = await openPermissionPanel(ctx, modes);
|
|
288
315
|
if (!chosen) {
|
|
289
|
-
|
|
316
|
+
notify(
|
|
290
317
|
"ask-perm: cancelled. Use /ask-perm allow allow allow ask to enable from the argument form.",
|
|
291
318
|
"info",
|
|
292
319
|
);
|
|
@@ -297,7 +324,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
297
324
|
rules.clear();
|
|
298
325
|
enabled = true;
|
|
299
326
|
syncStatus(ctx);
|
|
300
|
-
|
|
327
|
+
notify(
|
|
301
328
|
`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
|
|
302
329
|
"info",
|
|
303
330
|
);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { clearQuiet, consumeQuiet, markQuiet } from "./quiet.js";
|
|
3
|
+
|
|
4
|
+
describe("quiet handoff", () => {
|
|
5
|
+
it("consumes a mark exactly once", () => {
|
|
6
|
+
markQuiet("ask-perm");
|
|
7
|
+
expect(consumeQuiet("ask-perm")).toBe(true);
|
|
8
|
+
expect(consumeQuiet("ask-perm")).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("ignores other commands", () => {
|
|
12
|
+
markQuiet("ask-perm");
|
|
13
|
+
expect(consumeQuiet("third-review")).toBe(false);
|
|
14
|
+
expect(consumeQuiet("ask-perm")).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("can be cleared without a consumer", () => {
|
|
18
|
+
markQuiet("ask-perm");
|
|
19
|
+
clearQuiet("ask-perm");
|
|
20
|
+
expect(consumeQuiet("ask-perm")).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
package/src/lib/quiet.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiet handoff for commands dispatched by autostart.
|
|
3
|
+
*
|
|
4
|
+
* The host's interactive status line keeps every info notice in the chat, but
|
|
5
|
+
* each line renders one shared "last status message" field
|
|
6
|
+
* (`interactive-mode.js#showStatus`: `new ThemedText(() => ... this.lastStatusMessage)`).
|
|
7
|
+
* A warning emitted between two info notices breaks the coalescing, so a second
|
|
8
|
+
* info notice creates a new line — and *both* lines then render the newest
|
|
9
|
+
* text. The visible result is the same message twice, separated by the warning.
|
|
10
|
+
*
|
|
11
|
+
* That is a host display quirk, not a second execution. Startup replay is the
|
|
12
|
+
* worst case: `/add-dir` (info) → `/lens-toggle` (warning) → `/ask-perm` (info)
|
|
13
|
+
* shows "Permission ON" twice. Autostart therefore marks the commands it
|
|
14
|
+
* dispatches as quiet, and our own commands skip the chat notice when they were
|
|
15
|
+
* started that way. The footer status still reflects the new state.
|
|
16
|
+
*
|
|
17
|
+
* The mark is keyed by command name and is always cleared by the dispatcher
|
|
18
|
+
* (`finally`), so it cannot leak into a later manual invocation.
|
|
19
|
+
*/
|
|
20
|
+
const quietCommands = new Set<string>();
|
|
21
|
+
|
|
22
|
+
/** Mark `commandName` as dispatched by autostart (no chat notice). */
|
|
23
|
+
export function markQuiet(commandName: string): void {
|
|
24
|
+
quietCommands.add(commandName);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Drop a mark whether or not the command consumed it. */
|
|
28
|
+
export function clearQuiet(commandName: string): void {
|
|
29
|
+
quietCommands.delete(commandName);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `true` once, when the command was marked quiet; consumes the mark. */
|
|
33
|
+
export function consumeQuiet(commandName: string): boolean {
|
|
34
|
+
return quietCommands.delete(commandName);
|
|
35
|
+
}
|