@andares/pi-toolkits 0.9.3 → 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 -2
- package/README.zh-CN.md +1 -1
- package/package.json +1 -1
- package/src/features/autostart/autostart.test.ts +26 -0
- package/src/features/autostart/index.ts +9 -0
- package/src/features/permission/index.test.ts +31 -0
- package/src/features/permission/index.ts +45 -17
- package/src/lib/startup.test.ts +26 -0
- package/src/lib/startup.ts +36 -0
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
|
|
|
@@ -141,7 +141,7 @@ Repeating the argument form with the modes already in force is a **no-op**: no s
|
|
|
141
141
|
run = ["/ask-perm allow allow allow ask"]
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
-
> **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.
|
|
145
145
|
|
|
146
146
|
---
|
|
147
147
|
|
package/README.zh-CN.md
CHANGED
|
@@ -141,7 +141,7 @@ pi -e ./src/index.ts
|
|
|
141
141
|
run = ["/ask-perm allow allow allow ask"]
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
-
>
|
|
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` 时只有绝对路径形状的读目标会被问——读本身不会写,四格模式的写侧不受影响。
|
|
145
145
|
|
|
146
146
|
---
|
|
147
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,6 +21,7 @@ import {
|
|
|
21
21
|
RUNNABLE_REASONS,
|
|
22
22
|
shouldReplay,
|
|
23
23
|
} from "./autostart.js";
|
|
24
|
+
import { consumeStartupCommand } from "../../lib/startup.js";
|
|
24
25
|
import { registerAutostart } from "./index.js";
|
|
25
26
|
|
|
26
27
|
describe("parseAutostartConfig", () => {
|
|
@@ -159,6 +160,7 @@ interface Harness {
|
|
|
159
160
|
notifications: Array<{ message: string; type?: string }>;
|
|
160
161
|
commands: Array<{ name: string; source: string }>;
|
|
161
162
|
failCommands: Set<string>;
|
|
163
|
+
startupAtDispatch: Array<{ command: string; startup: boolean }>;
|
|
162
164
|
setTrusted(trusted: boolean): void;
|
|
163
165
|
/** Simulate a session that already had a conversation (resumed process). */
|
|
164
166
|
setHistory(entries: Array<{ type: string }>): void;
|
|
@@ -201,7 +203,9 @@ async function makeHarness(
|
|
|
201
203
|
const commands: Harness["commands"] = [
|
|
202
204
|
{ name: "add-dir", source: "extension" },
|
|
203
205
|
{ name: "ask", source: "extension" },
|
|
206
|
+
{ name: "ask-perm", source: "extension" },
|
|
204
207
|
];
|
|
208
|
+
const startupAtDispatch: Harness["startupAtDispatch"] = [];
|
|
205
209
|
const failCommands = new Set<string>();
|
|
206
210
|
const pi = {
|
|
207
211
|
on: (event: string, h: SessionStartHandler) => {
|
|
@@ -213,6 +217,10 @@ async function makeHarness(
|
|
|
213
217
|
options?: { expandPromptTemplates?: boolean },
|
|
214
218
|
) => {
|
|
215
219
|
sent.push({ command, options }); // records attempts, including failures
|
|
220
|
+
startupAtDispatch.push({
|
|
221
|
+
command,
|
|
222
|
+
startup: consumeStartupCommand("ask-perm"),
|
|
223
|
+
});
|
|
216
224
|
if (failCommands.has(command)) {
|
|
217
225
|
return Promise.reject(new Error("boom"));
|
|
218
226
|
}
|
|
@@ -222,6 +230,7 @@ async function makeHarness(
|
|
|
222
230
|
return {
|
|
223
231
|
handler,
|
|
224
232
|
sent,
|
|
233
|
+
startupAtDispatch,
|
|
225
234
|
notifications,
|
|
226
235
|
commands,
|
|
227
236
|
failCommands,
|
|
@@ -315,6 +324,23 @@ describe("registerAutostart", () => {
|
|
|
315
324
|
expect(h.notifications).toEqual([]);
|
|
316
325
|
});
|
|
317
326
|
|
|
327
|
+
it("marks our own command as a startup notice and clears the mark", async () => {
|
|
328
|
+
const h = await harness('[command]\nrun = ["/ask-perm allow"]');
|
|
329
|
+
await h.start("startup");
|
|
330
|
+
expect(h.startupAtDispatch).toEqual([
|
|
331
|
+
{ command: "/ask-perm allow", startup: true },
|
|
332
|
+
]);
|
|
333
|
+
expect(consumeStartupCommand("ask-perm")).toBe(false);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("leaves other extensions' commands unmarked", async () => {
|
|
337
|
+
const h = await harness(fullConfig);
|
|
338
|
+
await h.start("startup");
|
|
339
|
+
expect(h.startupAtDispatch.every((entry) => entry.startup === false)).toBe(
|
|
340
|
+
true,
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
318
344
|
it("replays once per session even if session_start is emitted twice", async () => {
|
|
319
345
|
const h = await harness(fullConfig);
|
|
320
346
|
await h.start("startup");
|
|
@@ -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 { clearStartupCommand, markStartupCommand } from "../../lib/startup.js";
|
|
43
44
|
import {
|
|
44
45
|
AUTOSTART_CONFIG_PATH,
|
|
45
46
|
AutostartParseError,
|
|
@@ -213,6 +214,12 @@ export function registerAutostart(
|
|
|
213
214
|
}
|
|
214
215
|
if (dispatched.has(command)) continue;
|
|
215
216
|
dispatched.add(command);
|
|
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);
|
|
216
223
|
// One failing command must not cancel the rest of the list.
|
|
217
224
|
try {
|
|
218
225
|
await pi.sendUserMessage(command, { expandPromptTemplates: true });
|
|
@@ -221,6 +228,8 @@ export function registerAutostart(
|
|
|
221
228
|
`autostart: "${command}" failed — ${messageOf(err)}`,
|
|
222
229
|
"error",
|
|
223
230
|
);
|
|
231
|
+
} finally {
|
|
232
|
+
if (startup) clearStartupCommand(name);
|
|
224
233
|
}
|
|
225
234
|
}
|
|
226
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 { markStartupCommand } from "../../lib/startup.js";
|
|
18
19
|
import { registerPermission } from "./index.js";
|
|
19
20
|
|
|
20
21
|
function bashEvent(command: string): ToolCallEvent {
|
|
@@ -50,6 +51,7 @@ interface Harness {
|
|
|
50
51
|
readonly gate: ToolGate;
|
|
51
52
|
readonly statuses: Array<[string, string | undefined]>;
|
|
52
53
|
readonly notes: Array<{ message: string; kind?: string }>;
|
|
54
|
+
readonly entries: Array<{ type: string; data: unknown }>;
|
|
53
55
|
readonly branch: Array<Record<string, unknown>>;
|
|
54
56
|
setSessionId(id: string): void;
|
|
55
57
|
evaluate(event: ToolCallEvent): Promise<unknown>;
|
|
@@ -62,6 +64,7 @@ function makeHarness(): Harness {
|
|
|
62
64
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "pi-perm-test-")));
|
|
63
65
|
const statuses: Harness["statuses"] = [];
|
|
64
66
|
const notes: Harness["notes"] = [];
|
|
67
|
+
const entries: Harness["entries"] = [];
|
|
65
68
|
const branch: Harness["branch"] = [];
|
|
66
69
|
let sessionId = "session-1";
|
|
67
70
|
const ctx = {
|
|
@@ -102,6 +105,10 @@ function makeHarness(): Harness {
|
|
|
102
105
|
) {
|
|
103
106
|
if (name === "ask-perm") commandHandler = options.handler;
|
|
104
107
|
},
|
|
108
|
+
appendEntry(type: string, data: unknown) {
|
|
109
|
+
entries.push({ type, data });
|
|
110
|
+
},
|
|
111
|
+
registerEntryRenderer() {},
|
|
105
112
|
} as unknown as ExtensionAPI;
|
|
106
113
|
|
|
107
114
|
registerPermission(pi, hub);
|
|
@@ -114,6 +121,7 @@ function makeHarness(): Harness {
|
|
|
114
121
|
gate,
|
|
115
122
|
statuses,
|
|
116
123
|
notes,
|
|
124
|
+
entries,
|
|
117
125
|
branch,
|
|
118
126
|
setSessionId(id: string) {
|
|
119
127
|
sessionId = id;
|
|
@@ -208,6 +216,29 @@ describe("/ask-perm", () => {
|
|
|
208
216
|
expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
|
|
209
217
|
});
|
|
210
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
|
+
|
|
211
242
|
it("keeps the mode off when the panel is cancelled", async () => {
|
|
212
243
|
const h = makeHarness();
|
|
213
244
|
await h.command("");
|
|
@@ -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,7 +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";
|
|
26
|
+
import { consumeStartupCommand } from "../../lib/startup.js";
|
|
25
27
|
import {
|
|
26
28
|
DEFAULT_MODES,
|
|
27
29
|
type PermissionModes,
|
|
@@ -96,7 +98,37 @@ function toolUnit(
|
|
|
96
98
|
};
|
|
97
99
|
}
|
|
98
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
|
+
|
|
99
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
|
+
});
|
|
100
132
|
let enabled = false;
|
|
101
133
|
let modes: PermissionModes = { ...DEFAULT_MODES };
|
|
102
134
|
// Session this state belongs to. A runtime rebind can emit `session_start`
|
|
@@ -263,6 +295,12 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
263
295
|
description:
|
|
264
296
|
"Toggle permission checks; with arguments: /ask-perm [in-read] [in-write] [out-read] [out-write]",
|
|
265
297
|
handler: async (args, ctx) => {
|
|
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");
|
|
303
|
+
};
|
|
266
304
|
const raw = args.trim();
|
|
267
305
|
|
|
268
306
|
if (raw !== "") {
|
|
@@ -283,10 +321,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
283
321
|
if (changed) rules.clear();
|
|
284
322
|
enabled = true;
|
|
285
323
|
syncStatus(ctx);
|
|
286
|
-
|
|
287
|
-
`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
|
|
288
|
-
"info",
|
|
289
|
-
);
|
|
324
|
+
notice(`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`);
|
|
290
325
|
return;
|
|
291
326
|
}
|
|
292
327
|
|
|
@@ -294,18 +329,14 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
294
329
|
enabled = false;
|
|
295
330
|
rules.clear();
|
|
296
331
|
syncStatus(ctx);
|
|
297
|
-
|
|
298
|
-
"Permission OFF — no boundary checks in this session.",
|
|
299
|
-
"info",
|
|
300
|
-
);
|
|
332
|
+
notice("Permission OFF — no boundary checks in this session.");
|
|
301
333
|
return;
|
|
302
334
|
}
|
|
303
335
|
|
|
304
336
|
const chosen = await openPermissionPanel(ctx, modes);
|
|
305
337
|
if (!chosen) {
|
|
306
|
-
|
|
338
|
+
notice(
|
|
307
339
|
"ask-perm: cancelled. Use /ask-perm allow allow allow ask to enable from the argument form.",
|
|
308
|
-
"info",
|
|
309
340
|
);
|
|
310
341
|
return;
|
|
311
342
|
}
|
|
@@ -314,10 +345,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
314
345
|
rules.clear();
|
|
315
346
|
enabled = true;
|
|
316
347
|
syncStatus(ctx);
|
|
317
|
-
|
|
318
|
-
`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
|
|
319
|
-
"info",
|
|
320
|
-
);
|
|
348
|
+
notice(`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`);
|
|
321
349
|
},
|
|
322
350
|
});
|
|
323
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
|
+
}
|