@andares/pi-toolkits 0.9.4 → 0.9.5
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 -4
- package/README.zh-CN.md +1 -3
- package/package.json +1 -1
- package/src/features/autostart/autostart.test.ts +15 -10
- package/src/features/autostart/index.ts +8 -8
- package/src/features/permission/index.test.ts +31 -20
- package/src/features/permission/index.ts +45 -27
- package/src/lib/startup.test.ts +26 -0
- package/src/lib/startup.ts +36 -0
- package/src/lib/quiet.test.ts +0 -22
- package/src/lib/quiet.ts +0 -35
package/README.md
CHANGED
|
@@ -106,7 +106,7 @@ 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
|
|
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 records the change and clears those grants, because they were made under the old policy.
|
|
110
110
|
|
|
111
111
|
**Defaults**: in-project read/write allowed, outside read allowed, outside write asks.
|
|
112
112
|
|
|
@@ -133,8 +133,6 @@ 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
|
-
|
|
138
136
|
**Enable it for every session** (the state itself is never persisted — this is the opt-in):
|
|
139
137
|
|
|
140
138
|
```toml
|
|
@@ -143,7 +141,7 @@ Repeating the argument form with the modes already in force is a **no-op**: no s
|
|
|
143
141
|
run = ["/ask-perm allow allow allow ask"]
|
|
144
142
|
```
|
|
145
143
|
|
|
146
|
-
> **Implementation notes**:
|
|
144
|
+
> **Implementation notes**: a new session resets the state on `session_start` (a repeated emission for the same session keeps it); the footer shows `perm i:A/A o:A/ask` while on (`A` allow, `a` ask, `D` deny). Subagent sessions start with their own state and do not inherit the parent's mode — when child sessions must be gated too, enable it through the project's autostart file. POSIX hosts (Linux / macOS / WSL) are the tested target; native Windows Git-Bash path spellings are approximated, not verified. bash decomposition uses `shell-quote`; unknown variables expand to a sentinel that makes the unit ask instead of silently becoming an empty path. Added directories are read from pi-add-dir's session entry (`add-dir:state`). Heuristic, not a sandbox: project-internal destructive commands are covered by the fixed table, not by a sandbox. One known blind spot: a *read* command's bare filename operand (`cat notes.txt`) is not collected as a target, so with `out read = ask` only absolute-path-shaped read operands are questioned — reads never write, so the four modes' write side is unaffected.
|
|
147
145
|
|
|
148
146
|
---
|
|
149
147
|
|
package/README.zh-CN.md
CHANGED
|
@@ -133,8 +133,6 @@ 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
|
-
|
|
138
136
|
**每个会话自动开启**(状态本身不落盘,这就是持久化的替身):
|
|
139
137
|
|
|
140
138
|
```toml
|
|
@@ -143,7 +141,7 @@ pi -e ./src/index.ts
|
|
|
143
141
|
run = ["/ask-perm allow allow allow ask"]
|
|
144
142
|
```
|
|
145
143
|
|
|
146
|
-
>
|
|
144
|
+
> **实现说明**:新会话在 `session_start` 重置状态(同一会话的重复 emission 保持状态);开启时 footer 显示 `perm i:A/A o:A/ask`(`A` allow、`a` ask、`D` deny)。子代理会话有自己的状态、不继承父会话的模式;需要子会话也受控时,通过项目 autostart 再开启一次。实测目标是 POSIX 宿主(Linux/macOS/WSL);原生 Windows Git-Bash 的路径拼写只做了近似,未验证。bash 拆解使用 `shell-quote`;未知变量会展开成哨兵而不是空字符串,使该命令转为询问而不是被悄悄当成空路径。add-dir 目录从 pi-add-dir 的会话条目 `add-dir:state` 读取。启发式,不是沙箱:项目内的破坏性操作由固定表兜底,而不是靠沙箱。已知盲区一处:**读命令的裸文件名参数**(`cat notes.txt`)不被收集为目标,所以把 `out read` 调成 `ask` 时只有绝对路径形状的读目标会被问——读本身不会写,四格模式的写侧不受影响。
|
|
147
145
|
|
|
148
146
|
---
|
|
149
147
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andares/pi-toolkits",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
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,7 +21,7 @@ import {
|
|
|
21
21
|
RUNNABLE_REASONS,
|
|
22
22
|
shouldReplay,
|
|
23
23
|
} from "./autostart.js";
|
|
24
|
-
import {
|
|
24
|
+
import { consumeStartupCommand } from "../../lib/startup.js";
|
|
25
25
|
import { registerAutostart } from "./index.js";
|
|
26
26
|
|
|
27
27
|
describe("parseAutostartConfig", () => {
|
|
@@ -160,7 +160,7 @@ interface Harness {
|
|
|
160
160
|
notifications: Array<{ message: string; type?: string }>;
|
|
161
161
|
commands: Array<{ name: string; source: string }>;
|
|
162
162
|
failCommands: Set<string>;
|
|
163
|
-
|
|
163
|
+
startupAtDispatch: Array<{ command: string; startup: boolean }>;
|
|
164
164
|
setTrusted(trusted: boolean): void;
|
|
165
165
|
/** Simulate a session that already had a conversation (resumed process). */
|
|
166
166
|
setHistory(entries: Array<{ type: string }>): void;
|
|
@@ -205,7 +205,7 @@ async function makeHarness(
|
|
|
205
205
|
{ name: "ask", source: "extension" },
|
|
206
206
|
{ name: "ask-perm", source: "extension" },
|
|
207
207
|
];
|
|
208
|
-
const
|
|
208
|
+
const startupAtDispatch: Harness["startupAtDispatch"] = [];
|
|
209
209
|
const failCommands = new Set<string>();
|
|
210
210
|
const pi = {
|
|
211
211
|
on: (event: string, h: SessionStartHandler) => {
|
|
@@ -217,7 +217,10 @@ async function makeHarness(
|
|
|
217
217
|
options?: { expandPromptTemplates?: boolean },
|
|
218
218
|
) => {
|
|
219
219
|
sent.push({ command, options }); // records attempts, including failures
|
|
220
|
-
|
|
220
|
+
startupAtDispatch.push({
|
|
221
|
+
command,
|
|
222
|
+
startup: consumeStartupCommand("ask-perm"),
|
|
223
|
+
});
|
|
221
224
|
if (failCommands.has(command)) {
|
|
222
225
|
return Promise.reject(new Error("boom"));
|
|
223
226
|
}
|
|
@@ -227,7 +230,7 @@ async function makeHarness(
|
|
|
227
230
|
return {
|
|
228
231
|
handler,
|
|
229
232
|
sent,
|
|
230
|
-
|
|
233
|
+
startupAtDispatch,
|
|
231
234
|
notifications,
|
|
232
235
|
commands,
|
|
233
236
|
failCommands,
|
|
@@ -321,19 +324,21 @@ describe("registerAutostart", () => {
|
|
|
321
324
|
expect(h.notifications).toEqual([]);
|
|
322
325
|
});
|
|
323
326
|
|
|
324
|
-
it("marks our own command
|
|
327
|
+
it("marks our own command as a startup notice and clears the mark", async () => {
|
|
325
328
|
const h = await harness('[command]\nrun = ["/ask-perm allow"]');
|
|
326
329
|
await h.start("startup");
|
|
327
|
-
expect(h.
|
|
328
|
-
{ command: "/ask-perm allow",
|
|
330
|
+
expect(h.startupAtDispatch).toEqual([
|
|
331
|
+
{ command: "/ask-perm allow", startup: true },
|
|
329
332
|
]);
|
|
330
|
-
expect(
|
|
333
|
+
expect(consumeStartupCommand("ask-perm")).toBe(false);
|
|
331
334
|
});
|
|
332
335
|
|
|
333
336
|
it("leaves other extensions' commands unmarked", async () => {
|
|
334
337
|
const h = await harness(fullConfig);
|
|
335
338
|
await h.start("startup");
|
|
336
|
-
expect(h.
|
|
339
|
+
expect(h.startupAtDispatch.every((entry) => entry.startup === false)).toBe(
|
|
340
|
+
true,
|
|
341
|
+
);
|
|
337
342
|
});
|
|
338
343
|
|
|
339
344
|
it("replays once per session even if session_start is emitted twice", async () => {
|
|
@@ -40,7 +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 {
|
|
43
|
+
import { clearStartupCommand, markStartupCommand } from "../../lib/startup.js";
|
|
44
44
|
import {
|
|
45
45
|
AUTOSTART_CONFIG_PATH,
|
|
46
46
|
AutostartParseError,
|
|
@@ -214,12 +214,12 @@ export function registerAutostart(
|
|
|
214
214
|
}
|
|
215
215
|
if (dispatched.has(command)) continue;
|
|
216
216
|
dispatched.add(command);
|
|
217
|
-
// Our own
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
const
|
|
222
|
-
if (
|
|
217
|
+
// Our own command answers a startup notice with a session entry
|
|
218
|
+
// instead of a status line: at startup several commands emit status
|
|
219
|
+
// lines and a later UI invalidation would paint our text on all of
|
|
220
|
+
// them (see lib/startup.ts).
|
|
221
|
+
const startup = name === "ask-perm";
|
|
222
|
+
if (startup) markStartupCommand(name);
|
|
223
223
|
// One failing command must not cancel the rest of the list.
|
|
224
224
|
try {
|
|
225
225
|
await pi.sendUserMessage(command, { expandPromptTemplates: true });
|
|
@@ -229,7 +229,7 @@ export function registerAutostart(
|
|
|
229
229
|
"error",
|
|
230
230
|
);
|
|
231
231
|
} finally {
|
|
232
|
-
if (
|
|
232
|
+
if (startup) clearStartupCommand(name);
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
}
|
|
@@ -15,7 +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 {
|
|
18
|
+
import { markStartupCommand } from "../../lib/startup.js";
|
|
19
19
|
import { registerPermission } from "./index.js";
|
|
20
20
|
|
|
21
21
|
function bashEvent(command: string): ToolCallEvent {
|
|
@@ -51,6 +51,7 @@ interface Harness {
|
|
|
51
51
|
readonly gate: ToolGate;
|
|
52
52
|
readonly statuses: Array<[string, string | undefined]>;
|
|
53
53
|
readonly notes: Array<{ message: string; kind?: string }>;
|
|
54
|
+
readonly entries: Array<{ type: string; data: unknown }>;
|
|
54
55
|
readonly branch: Array<Record<string, unknown>>;
|
|
55
56
|
setSessionId(id: string): void;
|
|
56
57
|
evaluate(event: ToolCallEvent): Promise<unknown>;
|
|
@@ -63,6 +64,7 @@ function makeHarness(): Harness {
|
|
|
63
64
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "pi-perm-test-")));
|
|
64
65
|
const statuses: Harness["statuses"] = [];
|
|
65
66
|
const notes: Harness["notes"] = [];
|
|
67
|
+
const entries: Harness["entries"] = [];
|
|
66
68
|
const branch: Harness["branch"] = [];
|
|
67
69
|
let sessionId = "session-1";
|
|
68
70
|
const ctx = {
|
|
@@ -103,6 +105,10 @@ function makeHarness(): Harness {
|
|
|
103
105
|
) {
|
|
104
106
|
if (name === "ask-perm") commandHandler = options.handler;
|
|
105
107
|
},
|
|
108
|
+
appendEntry(type: string, data: unknown) {
|
|
109
|
+
entries.push({ type, data });
|
|
110
|
+
},
|
|
111
|
+
registerEntryRenderer() {},
|
|
106
112
|
} as unknown as ExtensionAPI;
|
|
107
113
|
|
|
108
114
|
registerPermission(pi, hub);
|
|
@@ -115,6 +121,7 @@ function makeHarness(): Harness {
|
|
|
115
121
|
gate,
|
|
116
122
|
statuses,
|
|
117
123
|
notes,
|
|
124
|
+
entries,
|
|
118
125
|
branch,
|
|
119
126
|
setSessionId(id: string) {
|
|
120
127
|
sessionId = id;
|
|
@@ -209,6 +216,29 @@ describe("/ask-perm", () => {
|
|
|
209
216
|
expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
|
|
210
217
|
});
|
|
211
218
|
|
|
219
|
+
it("writes a startup notice as an entry instead of a status line", async () => {
|
|
220
|
+
const h = makeHarness();
|
|
221
|
+
markStartupCommand("ask-perm");
|
|
222
|
+
await h.command("allow allow allow ask");
|
|
223
|
+
expect(h.notes).toHaveLength(0);
|
|
224
|
+
expect(h.entries).toHaveLength(1);
|
|
225
|
+
expect((h.entries[0]?.data as { text: string }).text).toContain(
|
|
226
|
+
"Permission ON",
|
|
227
|
+
);
|
|
228
|
+
expect(h.statuses.at(-1)?.[1]).toContain("perm");
|
|
229
|
+
expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).kind).toBe(
|
|
230
|
+
"ask",
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("still reports an invalid argument from autostart", async () => {
|
|
235
|
+
const h = makeHarness();
|
|
236
|
+
markStartupCommand("ask-perm");
|
|
237
|
+
await h.command("deny");
|
|
238
|
+
expect(h.notes.at(-1)?.kind).toBe("error");
|
|
239
|
+
expect(h.entries).toHaveLength(0);
|
|
240
|
+
});
|
|
241
|
+
|
|
212
242
|
it("keeps the mode off when the panel is cancelled", async () => {
|
|
213
243
|
const h = makeHarness();
|
|
214
244
|
await h.command("");
|
|
@@ -297,25 +327,6 @@ describe("/ask-perm", () => {
|
|
|
297
327
|
expect(await h.evaluate(customEvent("mcp"))).toBeUndefined();
|
|
298
328
|
});
|
|
299
329
|
|
|
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
|
-
|
|
319
330
|
it("is idempotent: repeating the same command notifies once and keeps grants", async () => {
|
|
320
331
|
const h = makeHarness();
|
|
321
332
|
await h.command("allow allow allow ask");
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Design notes:
|
|
5
5
|
* - Off by default and never persisted. The mode, the modes, and every session
|
|
6
|
-
* grant live in this closure; `session_start` resets them
|
|
7
|
-
*
|
|
8
|
-
* operator into the next session.
|
|
6
|
+
* grant live in this closure; `session_start` resets them for a new session
|
|
7
|
+
* and keeps them across a rebind of the same session. A forgotten toggle
|
|
8
|
+
* cannot follow the operator into the next session.
|
|
9
9
|
* - Toggling: no arguments flips on→off, off→panel. Arguments configure and
|
|
10
10
|
* enable in one step (`/ask-perm allow allow allow ask`), which is also the
|
|
11
11
|
* autostart path and never opens a dialog.
|
|
@@ -21,8 +21,9 @@ import type {
|
|
|
21
21
|
ExtensionContext,
|
|
22
22
|
ToolCallEvent,
|
|
23
23
|
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
24
25
|
import type { AskRequest, GateHub } from "../../lib/gates.js";
|
|
25
|
-
import {
|
|
26
|
+
import { consumeStartupCommand } from "../../lib/startup.js";
|
|
26
27
|
import {
|
|
27
28
|
DEFAULT_MODES,
|
|
28
29
|
type PermissionModes,
|
|
@@ -97,7 +98,37 @@ function toolUnit(
|
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Chat notices are custom session entries, not `ctx.ui.notify("info")`.
|
|
103
|
+
*
|
|
104
|
+
* The host renders every info notice as a `ThemedText` whose builder reads one
|
|
105
|
+
* shared `lastStatusMessage` field, and `ui.invalidate()` (theme switch, theme
|
|
106
|
+
* file edit, syntax-grammar load at startup) rebuilds *every* status line from
|
|
107
|
+
* that field. Any earlier status line — for example `/add-dir`'s — then shows
|
|
108
|
+
* our text too, so a single command looks like it ran twice. The invalidate is
|
|
109
|
+
* also why the duplicate depends on startup timing and terminal.
|
|
110
|
+
*
|
|
111
|
+
* A custom entry carries its own text and is rendered from that data, so it is
|
|
112
|
+
* immune to invalidation. Entries stay out of the model context (same pattern
|
|
113
|
+
* as pi-chrome) and survive the startup chat rebuild instead of being wiped.
|
|
114
|
+
*
|
|
115
|
+
* Only startup notices (dispatched by autostart, `lib/startup.ts`) use an
|
|
116
|
+
* entry. Interactive toggles keep `ctx.ui.notify`: consecutive info notices
|
|
117
|
+
* update the previous line in place, so ON/OFF/ON does not scroll the screen.
|
|
118
|
+
*/
|
|
119
|
+
const NOTICE_ENTRY = "ask-perm:notice";
|
|
120
|
+
|
|
121
|
+
interface NoticeData {
|
|
122
|
+
text: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
100
125
|
export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
126
|
+
pi.registerEntryRenderer?.(NOTICE_ENTRY, (entry, _options, theme) => {
|
|
127
|
+
const text = (entry.data as NoticeData | undefined)?.text;
|
|
128
|
+
return text === undefined
|
|
129
|
+
? undefined
|
|
130
|
+
: new Text(theme.fg("dim", text), 1, 0);
|
|
131
|
+
});
|
|
101
132
|
let enabled = false;
|
|
102
133
|
let modes: PermissionModes = { ...DEFAULT_MODES };
|
|
103
134
|
// Session this state belongs to. A runtime rebind can emit `session_start`
|
|
@@ -264,21 +295,18 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
264
295
|
description:
|
|
265
296
|
"Toggle permission checks; with arguments: /ask-perm [in-read] [in-write] [out-read] [out-write]",
|
|
266
297
|
handler: async (args, ctx) => {
|
|
267
|
-
//
|
|
268
|
-
const
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
): void => {
|
|
273
|
-
if (type === "info" && quietNotice) return;
|
|
274
|
-
ctx.ui.notify(message, type);
|
|
298
|
+
// Startup notices are entries; interactive ones stay in-place toasts.
|
|
299
|
+
const startup = consumeStartupCommand("ask-perm");
|
|
300
|
+
const notice = (text: string): void => {
|
|
301
|
+
if (startup) pi.appendEntry<NoticeData>(NOTICE_ENTRY, { text });
|
|
302
|
+
else ctx.ui.notify(text, "info");
|
|
275
303
|
};
|
|
276
304
|
const raw = args.trim();
|
|
277
305
|
|
|
278
306
|
if (raw !== "") {
|
|
279
307
|
const parsed = parsePermissionArgs(raw);
|
|
280
308
|
if ("error" in parsed) {
|
|
281
|
-
notify(`ask-perm: ${parsed.error}`, "error");
|
|
309
|
+
ctx.ui.notify(`ask-perm: ${parsed.error}`, "error");
|
|
282
310
|
return;
|
|
283
311
|
}
|
|
284
312
|
// Idempotent: the same command may arrive twice in one session start
|
|
@@ -293,10 +321,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
293
321
|
if (changed) rules.clear();
|
|
294
322
|
enabled = true;
|
|
295
323
|
syncStatus(ctx);
|
|
296
|
-
|
|
297
|
-
`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
|
|
298
|
-
"info",
|
|
299
|
-
);
|
|
324
|
+
notice(`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`);
|
|
300
325
|
return;
|
|
301
326
|
}
|
|
302
327
|
|
|
@@ -304,18 +329,14 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
304
329
|
enabled = false;
|
|
305
330
|
rules.clear();
|
|
306
331
|
syncStatus(ctx);
|
|
307
|
-
|
|
308
|
-
"Permission OFF — no boundary checks in this session.",
|
|
309
|
-
"info",
|
|
310
|
-
);
|
|
332
|
+
notice("Permission OFF — no boundary checks in this session.");
|
|
311
333
|
return;
|
|
312
334
|
}
|
|
313
335
|
|
|
314
336
|
const chosen = await openPermissionPanel(ctx, modes);
|
|
315
337
|
if (!chosen) {
|
|
316
|
-
|
|
338
|
+
notice(
|
|
317
339
|
"ask-perm: cancelled. Use /ask-perm allow allow allow ask to enable from the argument form.",
|
|
318
|
-
"info",
|
|
319
340
|
);
|
|
320
341
|
return;
|
|
321
342
|
}
|
|
@@ -324,10 +345,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
324
345
|
rules.clear();
|
|
325
346
|
enabled = true;
|
|
326
347
|
syncStatus(ctx);
|
|
327
|
-
|
|
328
|
-
`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
|
|
329
|
-
"info",
|
|
330
|
-
);
|
|
348
|
+
notice(`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`);
|
|
331
349
|
},
|
|
332
350
|
});
|
|
333
351
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
clearStartupCommand,
|
|
4
|
+
consumeStartupCommand,
|
|
5
|
+
markStartupCommand,
|
|
6
|
+
} from "./startup.js";
|
|
7
|
+
|
|
8
|
+
describe("startup handoff", () => {
|
|
9
|
+
it("consumes a mark exactly once", () => {
|
|
10
|
+
markStartupCommand("ask-perm");
|
|
11
|
+
expect(consumeStartupCommand("ask-perm")).toBe(true);
|
|
12
|
+
expect(consumeStartupCommand("ask-perm")).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("ignores other commands", () => {
|
|
16
|
+
markStartupCommand("ask-perm");
|
|
17
|
+
expect(consumeStartupCommand("third-review")).toBe(false);
|
|
18
|
+
expect(consumeStartupCommand("ask-perm")).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("can be cleared without a consumer", () => {
|
|
22
|
+
markStartupCommand("ask-perm");
|
|
23
|
+
clearStartupCommand("ask-perm");
|
|
24
|
+
expect(consumeStartupCommand("ask-perm")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup handoff for commands dispatched by autostart.
|
|
3
|
+
*
|
|
4
|
+
* Why the distinction: the host renders an info notice (`ctx.ui.notify`) as a
|
|
5
|
+
* `ThemedText` whose builder reads one shared `lastStatusMessage` field, and a
|
|
6
|
+
* UI invalidation (theme switch, theme-file edit, syntax-grammar load at
|
|
7
|
+
* startup) rebuilds *every* status line from that field. At startup several
|
|
8
|
+
* commands emit status lines (/add-dir, …), so our notice makes the earlier
|
|
9
|
+
* lines display our text too — one command looks like it ran twice.
|
|
10
|
+
*
|
|
11
|
+
* Interactive toggles have the opposite preference: consecutive info notices
|
|
12
|
+
* update the previous line in place, so ON/OFF/ON does not scroll the screen.
|
|
13
|
+
*
|
|
14
|
+
* Autostart therefore marks the commands it starts, and our command answers
|
|
15
|
+
* differently: a startup notice is a custom session entry (own text, immune to
|
|
16
|
+
* invalidation), while an interactive one stays a transient status line.
|
|
17
|
+
*
|
|
18
|
+
* The mark is keyed by command name and is always cleared by the dispatcher
|
|
19
|
+
* (`finally`), so it cannot leak into a later manual invocation.
|
|
20
|
+
*/
|
|
21
|
+
const startupCommands = new Set<string>();
|
|
22
|
+
|
|
23
|
+
/** Mark `commandName` as dispatched by autostart. */
|
|
24
|
+
export function markStartupCommand(commandName: string): void {
|
|
25
|
+
startupCommands.add(commandName);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Drop a mark whether or not the command consumed it. */
|
|
29
|
+
export function clearStartupCommand(commandName: string): void {
|
|
30
|
+
startupCommands.delete(commandName);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `true` once, when the command was started by autostart; consumes the mark. */
|
|
34
|
+
export function consumeStartupCommand(commandName: string): boolean {
|
|
35
|
+
return startupCommands.delete(commandName);
|
|
36
|
+
}
|
package/src/lib/quiet.test.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
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
|
-
}
|