67e5e456f0f164c9e313cea50b1201e74701ae6e
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/.chezmoiignore b/.chezmoiignore
2index c5daf737a7d988b3733c5e6584203994a0fd4fa7..804a18de318406560eee6894bfbd5cf9335e3c3f 100644
3--- a/.chezmoiignore
4+++ b/.chezmoiignore
5@@ -6,6 +6,10 @@ us_colemak.patch
6 config_template.yaml
7 .config/nvim/lazy-lock.json
8
9+**/node_modules/
10+
11+.pi/agent/extensions/pi-policy-engine
12+
13 {{ if not (and (eq .chezmoi.os "linux") (hasKey .chezmoi.osRelease "id") (eq .chezmoi.osRelease.id "arch")) }}
14 .local/bin/pkgbuild-review
15 .config/llama-server/
16diff --git a/dot_pi/agent/extensions/continue-after-compaction.ts b/dot_pi/agent/extensions/continue-after-compaction.ts
17new file mode 100644
18index 0000000000000000000000000000000000000000..1e42059fe49c7e8aec044a2bf67d43df96107e65
19--- /dev/null
20+++ b/dot_pi/agent/extensions/continue-after-compaction.ts
21@@ -0,0 +1,55 @@
22+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23+
24+const buildContinuationPrompt = (sessionFile: string | undefined, compactionEntryId: string): string => {
25+ const sessionSource =
26+ sessionFile === undefined
27+ ? "This session is ephemeral, so no persisted session file is available."
28+ : [
29+ `The persisted session JSONL is ${JSON.stringify(sessionFile)}.`,
30+ "Inspect it directly with the read and bash tools.",
31+ "Do not launch a nested Pi process or open the session with `pi --session`.",
32+ ].join(" ");
33+
34+ return `Compaction has just completed. Resume the existing task rather than waiting for another user prompt.
35+
36+${sessionSource}
37+The new compaction entry ID is ${JSON.stringify(compactionEntryId)}.
38+
39+Before continuing:
40+
41diff --git a/dot_pi/agent/extensions/dot_chezmoiignore b/dot_pi/agent/extensions/dot_chezmoiignore
42new file mode 100644
43index 0000000000000000000000000000000000000000..e84c3fbaf386e0bf2aeeb183c7b5352fe3d76895
44--- /dev/null
45+++ b/dot_pi/agent/extensions/dot_chezmoiignore
46@@ -0,0 +1 @@
47+*/node_modules
48diff --git a/dot_pi/agent/extensions/git-interceptor.ts b/dot_pi/agent/extensions/git-interceptor.ts
49new file mode 100644
50index 0000000000000000000000000000000000000000..4acb3fdeed27b8c01b125793854f89012f333b44
51--- /dev/null
52+++ b/dot_pi/agent/extensions/git-interceptor.ts
53@@ -0,0 +1,39 @@
54+/**
55+ * Git Interceptor
56+ *
57+ * Two guards for agent-driven git commands:
58+ *
59+ * 1. Editor hang prevention — Sets GIT_EDITOR, GIT_SEQUENCE_EDITOR to `true`
60+ * (no-op) and GIT_MERGE_AUTOEDIT to `no` so git never spawns an interactive
61+ * editor (nvim, vim, etc.) that would hang the bash process.
62+ *
63+ * 2. Hook bypass prevention — Blocks any command containing `--no-verify` so
64+ * the agent cannot circumvent git hooks (pre-commit, commit-msg, etc.).
65+ * The agent should fix hook failures or ask the human for help instead.
66+ */
67+
68+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
69+import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
70+
71+const GIT_ENV_PREFIX =
72+ "export GIT_EDITOR=true GIT_SEQUENCE_EDITOR=true GIT_MERGE_AUTOEDIT=no\n";
73+
74+const NO_VERIFY_RE = /--no-verify\b/;
75+
76+const BLOCK_REASON =
77+ "BLOCKED: --no-verify is not allowed. Git hooks exist for a reason. " +
78+ "Do not attempt to bypass them. Instead: fix the underlying issue that " +
79+ "is causing the hook to fail, or ask the user for help.";
80+
81+export default function (pi: ExtensionAPI) {
82+ pi.on("tool_call", (event) => {
83+ if (!isToolCallEventType("bash", event)) return;
84+ if (!event.input.command.includes("git")) return;
85+
86+ if (NO_VERIFY_RE.test(event.input.command)) {
87+ return { block: true, reason: BLOCK_REASON };
88+ }
89+
90+ event.input.command = GIT_ENV_PREFIX + event.input.command;
91+ });
92+}
93diff --git a/dot_pi/agent/extensions/handoff/index.ts b/dot_pi/agent/extensions/handoff/index.ts
94new file mode 100644
95index 0000000000000000000000000000000000000000..00bc368ec171a327191b097e457cc859c4df76d6
96--- /dev/null
97+++ b/dot_pi/agent/extensions/handoff/index.ts
98@@ -0,0 +1,133 @@
99+import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
100+
101+const HANDOFF_AGENT_START_TIMEOUT_MS = 30_000;
102+function buildContinueFromHandoffPrompt(
103+ sessionFile: string | undefined,
104+ sourceLeafEntryId: string,
105+): string {
106+ const sourceBranchInstructions =
107+ sessionFile === undefined
108+ ? `The source branch ends at session tree entry ${JSON.stringify(sourceLeafEntryId)}. If the handoff leaves a blocking ambiguity, use available session or tree inspection capabilities to recover the needed context from that branch, then resume.`
109diff --git a/dot_pi/agent/extensions/handoff/package.json b/dot_pi/agent/extensions/handoff/package.json
110new file mode 100644
111index 0000000000000000000000000000000000000000..8e9126e6e0b286c43ee054f08faae323cfc58d42
112--- /dev/null
113+++ b/dot_pi/agent/extensions/handoff/package.json
114@@ -0,0 +1,24 @@
115+{
116+ "name": "pi-handoff-extension",
117+ "private": true,
118+ "version": "0.1.0",
119+ "type": "module",
120+ "scripts": {
121+ "check": "npm run typecheck && npm test",
122+ "test": "node --import tsx --test \"test/**/*.test.ts\"",
123+ "typecheck": "tsc --noEmit"
124+ },
125+ "pi": {
126+ "extensions": [
127+ "./index.ts"
128+ ]
129+ },
130+ "peerDependencies": {
131+ "@earendil-works/pi-coding-agent": "*"
132+ },
133+ "devDependencies": {
134+ "@types/node": "^24.5.2",
135+ "tsx": "^4.20.5",
136+ "typescript": "^5.9.2"
137+ }
138+}
139diff --git a/dot_pi/agent/extensions/handoff/test/handoff.test.ts b/dot_pi/agent/extensions/handoff/test/handoff.test.ts
140new file mode 100644
141index 0000000000000000000000000000000000000000..c3d45bcc852a4608baf204cc622105341b6f11e5
142--- /dev/null
143+++ b/dot_pi/agent/extensions/handoff/test/handoff.test.ts
144@@ -0,0 +1,202 @@
145+import assert from "node:assert/strict";
146+import { mkdtemp, rm } from "node:fs/promises";
147+import { tmpdir } from "node:os";
148+import { dirname, join } from "node:path";
149+import { fileURLToPath } from "node:url";
150+import test from "node:test";
151+
152+import { InMemoryCredentialStore, type UserMessage } from "@earendil-works/pi-ai";
153+import {
154+ discoverAndLoadExtensions,
155+ ExtensionRunner,
156+ ModelRegistry,
157+ ModelRuntime,
158+ SessionManager,
159+ type ExtensionCommandContextActions,
160+} from "@earendil-works/pi-coding-agent";
161+
162+const extensionPath = join(dirname(fileURLToPath(import.meta.url)), "..", "index.ts");
163+
164+function userMessage(text: string): UserMessage {
165+ return {
166+ role: "user",
167+ content: [{ type: "text", text }],
168+ timestamp: Date.now(),
169+ };
170+}
171+
172+async function createHandoffHarness(cwd: string) {
173+ const credentials = new InMemoryCredentialStore();
174+ const modelRuntime = await ModelRuntime.create({
175+ credentials,
176+ modelsPath: null,
177+ allowModelNetwork: false,
178+ });
179+ const model = modelRuntime.getModels()[0];
180+ assert.ok(model, "the bundled model catalog should not be empty");
181+ await credentials.modify(model.provider, async () => ({ type: "api_key", key: "test-key" }));
182+
183+ const sessionManager = SessionManager.create(cwd, join(cwd, ".sessions"));
184+ const loaded = await discoverAndLoadExtensions([extensionPath], cwd, join(cwd, ".agent"));
185+ assert.deepEqual(loaded.errors, []);
186+
187+ const modelRegistry = new ModelRegistry(modelRuntime);
188+ const runner = new ExtensionRunner(
189+ loaded.extensions,
190+ loaded.runtime,
191+ cwd,
192+ sessionManager,
193+ modelRegistry,
194+ );
195+ const sentUserMessages: Array<{
196+ readonly content: string;
197+ readonly options?: {
198+ readonly deliverAs?: "steer" | "followUp";
199+ readonly expandPromptTemplates?: boolean;
200+ };
201+ }> = [];
202+ loaded.runtime.sendUserMessage = (content, options) => {
203+ if (typeof content !== "string") {
204+ throw new Error("Handoff test received unexpected image content");
205+ }
206+ sentUserMessages.push({ content, ...(options ? { options } : {}) });
207+ if (content.startsWith("/skill:handoff")) {
208+ queueMicrotask(() => void runner.emit({ type: "agent_start" }));
209+ }
210+ };
211+
212+ runner.bindCore(loaded.runtime, {
213+ getModel: () => model,
214+ getScopedModels: () => [],
215+ isIdle: () => true,
216+ isProjectTrusted: () => true,
217+ getSignal: () => undefined,
218+ abort: () => undefined,
219+ hasPendingMessages: () => false,
220+ shutdown: () => undefined,
221+ getContextUsage: () => undefined,
222+ compact: () => undefined,
223+ getSystemPrompt: () => "",
224+ });
225+
226+ const navigations: Array<{
227+ readonly targetId: string;
228+ readonly options?: {
229+ readonly summarize?: boolean;
230+ readonly customInstructions?: string;
231+ readonly replaceInstructions?: boolean;
232+ readonly label?: string;
233+ };
234+ }> = [];
235+ let waitForIdleCalls = 0;
236+ const commandActions: ExtensionCommandContextActions = {
237+ waitForIdle: async () => {
238+ waitForIdleCalls += 1;
239+ },
240+ newSession: async () => ({ cancelled: false }),
241+ fork: async () => ({ cancelled: false }),
242+ navigateTree: async (targetId, options) => {
243+ navigations.push({ targetId, ...(options ? { options } : {}) });
244diff --git a/dot_pi/agent/extensions/handoff/tsconfig.json b/dot_pi/agent/extensions/handoff/tsconfig.json
245new file mode 100644
246index 0000000000000000000000000000000000000000..4a1c8070805e97432f99eb11246752e790f28738
247--- /dev/null
248+++ b/dot_pi/agent/extensions/handoff/tsconfig.json
249@@ -0,0 +1,18 @@
250+{
251+ "compilerOptions": {
252+ "target": "ESNext",
253+ "module": "NodeNext",
254+ "moduleResolution": "NodeNext",
255+ "lib": ["ESNext"],
256+ "allowImportingTsExtensions": true,
257+ "noEmit": true,
258+ "strict": true,
259+ "exactOptionalPropertyTypes": true,
260+ "noUncheckedIndexedAccess": true,
261+ "noImplicitOverride": true,
262+ "noFallthroughCasesInSwitch": true,
263+ "skipLibCheck": true,
264+ "types": ["node"]
265+ },
266+ "include": ["./index.ts", "./test/**/*.ts"]
267+}
268diff --git a/dot_pi/agent/extensions/herdr-agent-state.ts b/dot_pi/agent/extensions/herdr-agent-state.ts
269new file mode 100644
270index 0000000000000000000000000000000000000000..67bc41889abc2791d6dcdfe8279bdc24186cc6fe
271--- /dev/null
272+++ b/dot_pi/agent/extensions/herdr-agent-state.ts
273@@ -0,0 +1,257 @@
274+// installed by herdr
275+// managed by herdr; reinstalling or updating the integration overwrites this file.
276+// add custom hooks/plugins beside this file instead of editing it.
277+// HERDR_INTEGRATION_ID=pi
278+// HERDR_INTEGRATION_VERSION=8
279+// @ts-nocheck
280+
281+import net from "node:net";
282+
283+const HERDR_ENV = process.env.HERDR_ENV;
284+const socketPath = process.env.HERDR_SOCKET_PATH;
285+const socketEndpoint =
286+ process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath;
287+const paneId = process.env.HERDR_PANE_ID;
288+const source = "herdr:pi";
289+
290+function enabled() {
291+ return HERDR_ENV === "1" && !!socketPath && !!paneId;
292+}
293+
294+function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
295+ if (!enabled()) {
296+ return Promise.resolve(true);
297+ }
298+
299+ return new Promise((resolve) => {
300+ let done = false;
301+ let timeout: ReturnType<typeof setTimeout> | undefined;
302+ const finish = (delivered: boolean) => {
303+ if (done) return;
304+ done = true;
305+ if (timeout) {
306+ clearTimeout(timeout);
307+ }
308+ socket.destroy();
309+ resolve(delivered);
310+ };
311+
312+ const socket = net.createConnection(socketEndpoint!);
313+ socket.on("error", () => finish(false));
314+ socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
315+ socket.on("data", () => finish(true));
316+ socket.on("end", () => finish(false));
317+ timeout = setTimeout(() => finish(false), timeoutMs);
318+ timeout.unref?.();
319+ });
320+}
321+
322+async function sendRequest(request: unknown): Promise<void> {
323+ if (await sendRequestAttempt(request, 500)) {
324+ return;
325+ }
326+ await sendRequestAttempt(request, 1500);
327+}
328+
329+type AgentState = "working" | "blocked" | "idle";
330+
331+type QueuedState = {
332+ state: AgentState;
333+ message?: string;
334+ seq: number;
335+};
336+
337+let reportSeq = Date.now() * 1000;
338+let currentAgentSessionId: string | undefined;
339+let currentAgentSessionPath: string | undefined;
340+
341+function nextReportSeq(): number {
342+ reportSeq += 1;
343+ return reportSeq;
344+}
345+
346+function updateSessionRef(ctx: any): void {
347+ try {
348+ const file = ctx?.sessionManager?.getSessionFile?.();
349+ currentAgentSessionPath =
350+ typeof file === "string" && file.startsWith("/") ? file : undefined;
351+ } catch {
352+ currentAgentSessionPath = undefined;
353+ }
354+
355+ try {
356+ const id = ctx?.sessionManager?.getSessionId?.();
357+ currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
358+ } catch {
359+ currentAgentSessionId = undefined;
360+ }
361+}
362+
363+function withSessionRef(params: Record<string, unknown>): Record<string, unknown> {
364+ if (currentAgentSessionPath) {
365+ return { ...params, agent_session_path: currentAgentSessionPath };
366+ }
367+ if (currentAgentSessionId) {
368+ return { ...params, agent_session_id: currentAgentSessionId };
369+ }
370+ return params;
371+}
372+
373diff --git a/dot_pi/agent/extensions/pi-skill-toggle/package.json b/dot_pi/agent/extensions/pi-skill-toggle/package.json
374new file mode 100644
375index 0000000000000000000000000000000000000000..1ae907d1bb18e90cb96a9b59911f1523eec7183f
376--- /dev/null
377+++ b/dot_pi/agent/extensions/pi-skill-toggle/package.json
378@@ -0,0 +1,23 @@
379+{
380+ "name": "pi-skill-toggle",
381+ "private": true,
382+ "version": "0.1.0",
383+ "type": "module",
384+ "scripts": {
385+ "test": "node --import tsx --test \"src/**/*.test.ts\"",
386+ "typecheck": "tsc --noEmit"
387+ },
388+ "pi": {
389+ "extensions": [
390+ "./src/index.ts"
391+ ]
392+ },
393+ "peerDependencies": {
394+ "@earendil-works/pi-coding-agent": "*",
395+ "@earendil-works/pi-tui": "*"
396+ },
397+ "devDependencies": {
398+ "@types/node": "^24.5.2",
399+ "typescript": "^5.9.2"
400+ }
401+}
402diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/apply/planner.test.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/apply/planner.test.ts
403new file mode 100644
404index 0000000000000000000000000000000000000000..93d967a76a06691197f983866adcb8834f520729
405--- /dev/null
406+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/apply/planner.test.ts
407@@ -0,0 +1,78 @@
408+import { describe, it } from "node:test";
409+import assert from "node:assert/strict";
410+import { DefaultSkillTogglePlanner } from "./planner.ts";
411+import { SimpleFrontmatterCodec } from "../frontmatter/parser.ts";
412+import { MinimalFrontmatterPatcher } from "../frontmatter/patcher.ts";
413+import type { FileSystem } from "../ports/fs.ts";
414+import type { SkillRecord } from "../types.ts";
415+
416+const codec = new SimpleFrontmatterCodec();
417+const patcher = new MinimalFrontmatterPatcher();
418+
419+describe("DefaultSkillTogglePlanner", () => {
420+ it("plans a normalization change for duplicated disable-model-invocation keys even if the mode is unchanged", async () => {
421+ const filePath = "/skills/handoff/SKILL.md";
422+ const raw = [
423+ "---",
424+ "name: handoff",
425+ "description: Compact the conversation.",
426+ "disable-model-invocation: true",
427+ "argument-hint: What next?",
428+ "disable-model-invocation: true",
429+ "---",
430+ "",
431+ "# Handoff",
432+ "",
433+ ].join("\n");
434+ const fs = new MemoryFileSystem(new Map([[filePath, raw]]));
435+ const planner = new DefaultSkillTogglePlanner(fs, codec, patcher);
436+ const record = skillRecord(filePath, "manual-only");
437+
438+ const changes = await planner.plan([record], [{ skill: record, desiredMode: "manual-only" }]);
439+
440+ assert.equal(changes.length, 1);
441+ assert.equal(changes[0]?.from, "manual-only");
442+ assert.equal(changes[0]?.to, "manual-only");
443+ assert.equal((changes[0]?.patch.newText.match(/^disable-model-invocation\s*:/gm) ?? []).length, 1);
444+ });
445+});
446+
447+function skillRecord(filePath: string, mode: SkillRecord["mode"]): SkillRecord {
448+ return {
449+ id: filePath,
450+ name: "handoff",
451+ description: "Compact the conversation.",
452+ filePath,
453+ baseDir: "/skills/handoff",
454+ source: { kind: "user", root: "/skills" },
455+ editable: true,
456+ mode,
457+ diagnostics: [],
458+ };
459+}
460+
461+class MemoryFileSystem implements FileSystem {
462+ constructor(private readonly files: Map<string, string>) {}
463+
464+ async readFile(path: string): Promise<string> {
465+ const value = this.files.get(path);
466+ if (value === undefined) throw new Error(`missing file: ${path}`);
467+ return value;
468+ }
469+
470+ async writeFileAtomic(path: string, content: string): Promise<void> {
471+ this.files.set(path, content);
472+ }
473+
474+ async access(path: string): Promise<boolean> {
475+ return this.files.has(path);
476+ }
477+
478+ async readdir(): Promise<Array<{ name: string; isDirectory: boolean; isFile: boolean; isSymbolicLink: boolean }>> {
479+ return [];
480+ }
481+
482+ async stat(): Promise<{ isDirectory: boolean; isFile: boolean; mode: number }> {
483+ return { isDirectory: false, isFile: true, mode: 0o644 };
484+ }
485+}
486diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/apply/planner.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/apply/planner.ts
487new file mode 100644
488index 0000000000000000000000000000000000000000..64661d9ad208606cf8cdec694fd9c2730f7391b1
489--- /dev/null
490+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/apply/planner.ts
491@@ -0,0 +1,49 @@
492+import type { FileSystem } from "../ports/fs.ts";
493+import type { FrontmatterCodec } from "../frontmatter/parser.ts";
494+import type { FrontmatterPatcher } from "../frontmatter/patcher.ts";
495+import type { SkillChange, SkillDraft, SkillRecord } from "../types.ts";
496+import { hasDuplicateDisableModelInvocation } from "../frontmatter/validation.ts";
497+import { classifyInvocationMode } from "../inventory/classifier.ts";
498+
499+export interface SkillTogglePlanner {
500+ plan(records: SkillRecord[], drafts: SkillDraft[]): Promise<SkillChange[]>;
501+}
502+
503+export class DefaultSkillTogglePlanner implements SkillTogglePlanner {
504+ constructor(
505+ private readonly fs: FileSystem,
506+ private readonly codec: FrontmatterCodec,
507+ private readonly patcher: FrontmatterPatcher,
508+ ) {}
509+
510+ async plan(records: SkillRecord[], drafts: SkillDraft[]): Promise<SkillChange[]> {
511+ const recordById = new Map(records.map((record) => [record.id, record]));
512+ const changes: SkillChange[] = [];
513+
514+ for (const draft of drafts) {
515+ const record = recordById.get(draft.skill.id);
516+ if (!record || !record.editable) continue;
517+
518+ const raw = await this.fs.readFile(record.filePath);
519+ const doc = this.codec.parse(raw);
520+ if (!doc.hasFrontmatter) continue;
521+
522+ const currentMode = classifyInvocationMode(doc);
523+ const needsNormalization = hasDuplicateDisableModelInvocation(doc);
524+ if (currentMode === draft.desiredMode && !needsNormalization) continue;
525+
526+ const patch = this.patcher.patchInvocationMode(doc, draft.desiredMode);
527+ if (patch.oldText === patch.newText) continue;
528+
529+ changes.push({
530+ skill: { ...record, mode: currentMode },
531+ filePath: record.filePath,
532+ from: currentMode,
533+ to: draft.desiredMode,
534+ patch,
535+ });
536+ }
537+
538+ return changes;
539+ }
540+}
541diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/apply/writer.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/apply/writer.ts
542new file mode 100644
543index 0000000000000000000000000000000000000000..6b933e4225a7891213d6e94d02c05b1425f12f8b
544--- /dev/null
545+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/apply/writer.ts
546@@ -0,0 +1,36 @@
547+import type { FileSystem } from "../ports/fs.ts";
548+import type { ApplyResult, SkillChange } from "../types.ts";
549+
550+export interface SkillChangeWriter {
551+ apply(changes: SkillChange[]): Promise<ApplyResult>;
552+}
553+
554+export class AtomicSkillChangeWriter implements SkillChangeWriter {
555+ constructor(private readonly fs: FileSystem) {}
556+
557+ async apply(changes: SkillChange[]): Promise<ApplyResult> {
558+ const result: ApplyResult = { applied: [], skipped: [], errors: [] };
559+
560+ for (const change of changes) {
561+ try {
562+ const current = await this.fs.readFile(change.filePath);
563+ if (current !== change.patch.oldText) {
564+ result.errors.push({
565+ skill: change.skill,
566+ message: `${change.skill.name}: file changed while dialog was open; skipped`,
567+ });
568+ continue;
569+ }
570+ await this.fs.writeFileAtomic(change.filePath, change.patch.newText);
571+ result.applied.push(change);
572+ } catch (error) {
573+ result.errors.push({
574+ skill: change.skill,
575+ message: `${change.skill.name}: ${error instanceof Error ? error.message : String(error)}`,
576+ });
577+ }
578+ }
579+
580+ return result;
581+ }
582+}
583diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/command.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/command.ts
584new file mode 100644
585index 0000000000000000000000000000000000000000..e40a51b2bee1fb1bd842d95d264bf7495eecf41a
586--- /dev/null
587+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/command.ts
588@@ -0,0 +1,75 @@
589+import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
590+import type { SkillInventory } from "./inventory/loader.ts";
591+import type { SkillTogglePlanner } from "./apply/planner.ts";
592+import type { SkillChangeWriter } from "./apply/writer.ts";
593+import { showSkillToggleUi } from "./ui/overlay.ts";
594+import type { ApplyResult } from "./types.ts";
595+
596+export interface ToggleSkillsCommandDeps {
597+ inventory: SkillInventory;
598+ planner: SkillTogglePlanner;
599+ writer: SkillChangeWriter;
600+}
601+
602+export async function runToggleSkillsCommand(ctx: ExtensionCommandContext, deps: ToggleSkillsCommandDeps): Promise<void> {
603+ if (!ctx.hasUI) {
604+ ctx.ui.notify("/toggle-skills requires interactive mode", "error");
605+ return;
606+ }
607+
608+ let skills;
609+ try {
610+ skills = await deps.inventory.load(ctx.cwd);
611+ } catch (error) {
612+ ctx.ui.notify(`Pi Skill Toggle failed to scan skills: ${error instanceof Error ? error.message : String(error)}`, "error");
613+ return;
614+ }
615+
616+ if (skills.length === 0) {
617+ ctx.ui.notify("Pi Skill Toggle: no skills found in global, user, or project skill directories", "info");
618+ return;
619+ }
620+
621+ const result = await showSkillToggleUi(ctx, skills);
622+ if (result.action !== "apply") return;
623+
624+ let changes;
625+ try {
626+ changes = await deps.planner.plan(skills, result.drafts);
627+ } catch (error) {
628+ ctx.ui.notify(`Pi Skill Toggle failed to plan changes: ${error instanceof Error ? error.message : String(error)}`, "error");
629+ return;
630+ }
631+
632+ if (changes.length === 0) {
633+ ctx.ui.notify("Pi Skill Toggle: no changes to apply", "info");
634+ return;
635+ }
636+
637+ const applied = await deps.writer.apply(changes);
638+ ctx.ui.notify(formatApplyResult(applied), applied.errors.length > 0 ? "warning" : "info");
639+
640+ if (applied.applied.length > 0) {
641+ await ctx.reload();
642+ }
643+}
644+
645+function formatApplyResult(result: ApplyResult): string {
646+ const lines = [`Pi Skill Toggle applied ${result.applied.length} change${result.applied.length === 1 ? "" : "s"}.`];
647+ for (const change of result.applied.slice(0, 6)) {
648+ lines.push(`- ${change.skill.name}: ${change.from} → ${change.to}`);
649+ }
650+ if (result.applied.length > 6) {
651+ lines.push(`- … ${result.applied.length - 6} more`);
652+ }
653+ if (result.errors.length > 0) {
654+ lines.push(`Errors/skipped: ${result.errors.length}`);
655+ for (const error of result.errors.slice(0, 4)) {
656+ lines.push(`- ${error.message}`);
657+ }
658+ }
659+ if (result.applied.length > 0) {
660+ lines.push("Reloaded skills, prompts, extensions, and themes.");
661+ }
662+ return lines.join("\n");
663+}
664diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/pi-paths.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/pi-paths.ts
665new file mode 100644
666index 0000000000000000000000000000000000000000..558b89f0bb0de79028ee07390c01b1388821ede1
667--- /dev/null
668+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/pi-paths.ts
669@@ -0,0 +1,65 @@
670+import { homedir } from "node:os";
671+import { join, resolve } from "node:path";
672+import type { SkillSource } from "../types.ts";
673+
674+export interface SkillRoot {
675+ path: string;
676+ source: SkillSource;
677+ includeRootMarkdownFiles: boolean;
678+}
679+
680+export function getAgentDir(): string {
681+ const configured = process.env.PI_CODING_AGENT_DIR?.trim();
682+ if (configured) return expandHome(configured);
683+ return join(homedir(), ".pi", "agent");
684+}
685+
686+export function getGlobalAgentsSkillDir(): string {
687+ return join(homedir(), ".agents", "skills");
688+}
689+
690+export function getSkillRoots(cwd: string): SkillRoot[] {
691+ const resolvedCwd = resolve(cwd);
692+ const userSkillRoot = join(getAgentDir(), "skills");
693+ const globalSkillRoot = getGlobalAgentsSkillDir();
694+ const projectSkillRoot = resolve(resolvedCwd, ".pi", "skills");
695+ const projectLegacySkillRoot = resolve(resolvedCwd, ".agents", "skills");
696+ const roots: SkillRoot[] = [
697+ {
698+ path: userSkillRoot,
699+ source: { kind: "user", root: userSkillRoot },
700+ includeRootMarkdownFiles: true,
701+ },
702+ {
703+ path: globalSkillRoot,
704+ source: { kind: "global", root: globalSkillRoot },
705+ includeRootMarkdownFiles: false,
706+ },
707+ {
708+ path: projectSkillRoot,
709+ source: { kind: "project", root: projectSkillRoot },
710+ includeRootMarkdownFiles: true,
711+ },
712+ // Pi also loads .agents/skills as a project skill directory. Root markdown
713+ // files are ignored there; directories containing SKILL.md are discovered.
714+ {
715+ path: projectLegacySkillRoot,
716+ source: { kind: "project-legacy", root: projectLegacySkillRoot },
717+ includeRootMarkdownFiles: false,
718+ },
719+ ];
720+
721+ const seen = new Set<string>();
722+ return roots.filter((root) => {
723+ const key = resolve(root.path);
724+ if (seen.has(key)) return false;
725+ seen.add(key);
726+ return true;
727+ });
728+}
729+
730+function expandHome(input: string): string {
731+ if (input === "~") return homedir();
732+ if (input.startsWith("~/")) return join(homedir(), input.slice(2));
733+ return input;
734+}
735diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/skill-locator.test.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/skill-locator.test.ts
736new file mode 100644
737index 0000000000000000000000000000000000000000..b9b91dbb1c62e26c24b55113da5fd6c241aeb49d
738--- /dev/null
739+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/skill-locator.test.ts
740@@ -0,0 +1,110 @@
741+import { describe, it, beforeEach, afterEach } from "node:test";
742+import assert from "node:assert/strict";
743+import type { FileSystem } from "../ports/fs.ts";
744+import { DefaultSkillLocator } from "./skill-locator.ts";
745+
746+const ORIGINAL_HOME = process.env.HOME;
747+const ORIGINAL_PI_CODING_AGENT_DIR = process.env.PI_CODING_AGENT_DIR;
748+
749+describe("DefaultSkillLocator", () => {
750+ beforeEach(() => {
751+ process.env.HOME = "/home/tester";
752+ process.env.PI_CODING_AGENT_DIR = "/home/tester/.pi/agent";
753+ });
754+
755+ afterEach(() => {
756+ restoreEnv("HOME", ORIGINAL_HOME);
757+ restoreEnv("PI_CODING_AGENT_DIR", ORIGINAL_PI_CODING_AGENT_DIR);
758+ });
759+
760+ it("finds global, user, and project skills with Pi root markdown discovery rules", async () => {
761+ const fs = new MemoryTreeFileSystem([
762+ "/home/tester/.pi/agent/skills/user-root.md",
763+ "/home/tester/.agents/skills/ignored-global-root.md",
764+ "/home/tester/.agents/skills/global-skill/SKILL.md",
765+ "/repo/.pi/skills/project-root.md",
766+ "/repo/.agents/skills/ignored-project-legacy-root.md",
767+ "/repo/.agents/skills/project-legacy-skill/SKILL.md",
768+ ]);
769+ const locator = new DefaultSkillLocator(fs);
770+
771+ const files = await locator.findSkillFiles("/repo");
772+ const byPath = new Map(files.map((file) => [file.filePath, file]));
773+
774+ assert.equal(byPath.get("/home/tester/.pi/agent/skills/user-root.md")?.source.kind, "user");
775+ assert.equal(byPath.get("/home/tester/.agents/skills/global-skill/SKILL.md")?.source.kind, "global");
776+ assert.equal(byPath.get("/repo/.pi/skills/project-root.md")?.source.kind, "project");
777+ assert.equal(byPath.get("/repo/.agents/skills/project-legacy-skill/SKILL.md")?.source.kind, "project-legacy");
778+ assert.equal(byPath.has("/home/tester/.agents/skills/ignored-global-root.md"), false);
779+ assert.equal(byPath.has("/repo/.agents/skills/ignored-project-legacy-root.md"), false);
780+ });
781+});
782+
783+function restoreEnv(name: string, value: string | undefined): void {
784+ if (value === undefined) {
785+ delete process.env[name];
786+ } else {
787+ process.env[name] = value;
788+ }
789+}
790+
791+class MemoryTreeFileSystem implements FileSystem {
792+ private readonly files = new Set<string>();
793+ private readonly dirs = new Set<string>(["/"]);
794+
795+ constructor(paths: string[]) {
796+ for (const path of paths) this.addFile(path);
797+ }
798+
799+ async readFile(path: string): Promise<string> {
800+ if (!this.files.has(path)) throw new Error(`missing file: ${path}`);
801+ return "---\nname: test\ndescription: Test skill.\n---\n";
802+ }
803+
804+ async writeFileAtomic(): Promise<void> {}
805+
806+ async access(path: string): Promise<boolean> {
807+ return this.files.has(path) || this.dirs.has(path);
808+ }
809+
810+ async readdir(path: string): Promise<Array<{ name: string; isDirectory: boolean; isFile: boolean; isSymbolicLink: boolean }>> {
811+ if (!this.dirs.has(path)) throw new Error(`missing dir: ${path}`);
812+ const prefix = path === "/" ? "/" : `${path}/`;
813+ const names = new Set<string>();
814+ for (const dir of this.dirs) {
815+ if (dir === path || !dir.startsWith(prefix)) continue;
816+ const rest = dir.slice(prefix.length);
817+ const [name] = rest.split("/");
818+ if (name) names.add(name);
819+ }
820+ for (const file of this.files) {
821+ if (!file.startsWith(prefix)) continue;
822+ const rest = file.slice(prefix.length);
823+ const [name] = rest.split("/");
824+ if (name) names.add(name);
825+ }
826+ return [...names].sort().map((name) => {
827+ const fullPath = path === "/" ? `/${name}` : `${path}/${name}`;
828+ return {
829+ name,
830+ isDirectory: this.dirs.has(fullPath),
831+ isFile: this.files.has(fullPath),
832+ isSymbolicLink: false,
833+ };
834+ });
835+ }
836+
837+ async stat(path: string): Promise<{ isDirectory: boolean; isFile: boolean; mode: number }> {
838+ return { isDirectory: this.dirs.has(path), isFile: this.files.has(path), mode: 0o644 };
839+ }
840diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/skill-locator.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/skill-locator.ts
841new file mode 100644
842index 0000000000000000000000000000000000000000..56adfd8b8e9012b9ce0a2f3678e2d2a12d998555
843--- /dev/null
844+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/discovery/skill-locator.ts
845@@ -0,0 +1,96 @@
846+import { constants } from "node:fs";
847+import { join } from "node:path";
848+import type { FileSystem } from "../ports/fs.ts";
849+import type { LocatedSkillFile, SkillSource } from "../types.ts";
850+import { getSkillRoots } from "./pi-paths.ts";
851+
852+export interface SkillLocator {
853+ findSkillFiles(cwd: string): Promise<LocatedSkillFile[]>;
854+}
855+
856+export class DefaultSkillLocator implements SkillLocator {
857+ constructor(private readonly fs: FileSystem) {}
858+
859+ async findSkillFiles(cwd: string): Promise<LocatedSkillFile[]> {
860+ const roots = getSkillRoots(cwd);
861+ const files: LocatedSkillFile[] = [];
862+ const seen = new Set<string>();
863+
864+ for (const root of roots) {
865+ if (!(await this.fs.access(root.path))) continue;
866+ const found = await this.scanSkillDir(root.path, root.path, root.source, root.includeRootMarkdownFiles);
867+ for (const file of found) {
868+ if (seen.has(file.filePath)) continue;
869+ seen.add(file.filePath);
870+ files.push(file);
871+ }
872+ }
873+
874+ return files.sort((a, b) => a.filePath.localeCompare(b.filePath));
875+ }
876+
877+ private async scanSkillDir(
878+ dir: string,
879+ root: string,
880+ source: SkillSource,
881+ includeRootMarkdownFiles: boolean,
882+ ): Promise<LocatedSkillFile[]> {
883+ const out: LocatedSkillFile[] = [];
884+ let entries: Awaited<ReturnType<FileSystem["readdir"]>>;
885+ try {
886+ entries = await this.fs.readdir(dir);
887+ } catch {
888+ return out;
889+ }
890+
891+ const skillEntry = entries.find((entry) => entry.name === "SKILL.md");
892+ if (skillEntry) {
893+ const filePath = join(dir, "SKILL.md");
894+ if (await this.isFile(filePath, skillEntry)) {
895+ out.push({ filePath, source, editable: await this.isWritable(filePath) });
896+ }
897+ return out;
898+ }
899+
900+ for (const entry of entries) {
901+ if (entry.name.startsWith(".")) continue;
902+ if (entry.name === "node_modules") continue;
903+
904+ const fullPath = join(dir, entry.name);
905+ if (await this.isDirectory(fullPath, entry)) {
906+ out.push(...(await this.scanSkillDir(fullPath, root, source, false)));
907+ continue;
908+ }
909+
910+ if (includeRootMarkdownFiles && entry.name.endsWith(".md") && (await this.isFile(fullPath, entry))) {
911+ out.push({ filePath: fullPath, source, editable: await this.isWritable(fullPath) });
912+ }
913+ }
914+
915+ return out;
916+ }
917+
918+ private async isDirectory(path: string, entry: { isDirectory: boolean; isSymbolicLink: boolean }): Promise<boolean> {
919+ if (entry.isDirectory) return true;
920+ if (!entry.isSymbolicLink) return false;
921+ try {
922+ return (await this.fs.stat(path)).isDirectory;
923+ } catch {
924+ return false;
925+ }
926+ }
927+
928+ private async isFile(path: string, entry: { isFile: boolean; isSymbolicLink: boolean }): Promise<boolean> {
929+ if (entry.isFile) return true;
930+ if (!entry.isSymbolicLink) return false;
931+ try {
932+ return (await this.fs.stat(path)).isFile;
933+ } catch {
934+ return false;
935+ }
936+ }
937+
938+ private async isWritable(path: string): Promise<boolean> {
939+ return this.fs.access(path, constants.R_OK | constants.W_OK);
940+ }
941+}
942diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/parser.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/parser.ts
943new file mode 100644
944index 0000000000000000000000000000000000000000..d77d4af24c8323524810bdf4f6a74eee2f17b926
945--- /dev/null
946+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/parser.ts
947@@ -0,0 +1,83 @@
948+import type { FrontmatterDocument } from "../types.ts";
949+
950+export interface FrontmatterCodec {
951+ parse(raw: string): FrontmatterDocument;
952+}
953+
954+export class SimpleFrontmatterCodec implements FrontmatterCodec {
955+ parse(raw: string): FrontmatterDocument {
956+ const lineEnding: "\n" | "\r\n" = raw.includes("\r\n") ? "\r\n" : "\n";
957+ const opening = raw.match(/^---[ \t]*(\r?\n)/);
958+ if (!opening) {
959+ return {
960+ raw,
961+ hasFrontmatter: false,
962+ frontmatterStart: 0,
963+ frontmatterEnd: 0,
964+ contentStart: 0,
965+ frontmatterText: "",
966+ bodyText: raw,
967+ fields: {},
968+ lineEnding,
969+ };
970+ }
971+
972+ const frontmatterStart = opening[0].length;
973+ const rest = raw.slice(frontmatterStart);
974+ const closing = /^---[ \t]*(?:\r?\n|$)/m.exec(rest);
975+ if (!closing || closing.index === undefined) {
976+ return {
977+ raw,
978+ hasFrontmatter: false,
979+ frontmatterStart: 0,
980+ frontmatterEnd: 0,
981+ contentStart: 0,
982+ frontmatterText: "",
983+ bodyText: raw,
984+ fields: {},
985+ lineEnding,
986+ };
987+ }
988+
989+ const frontmatterEnd = frontmatterStart + closing.index;
990+ const contentStart = frontmatterEnd + closing[0].length;
991+ const frontmatterText = raw.slice(frontmatterStart, frontmatterEnd);
992+
993+ return {
994+ raw,
995+ hasFrontmatter: true,
996+ frontmatterStart,
997+ frontmatterEnd,
998+ contentStart,
999+ frontmatterText,
1000+ bodyText: raw.slice(contentStart),
1001+ fields: parseYamlLikeFields(frontmatterText),
1002+ lineEnding,
1003+ };
1004+ }
1005+}
1006+
1007+function parseYamlLikeFields(frontmatterText: string): Record<string, unknown> {
1008+ const fields: Record<string, unknown> = {};
1009+ for (const rawLine of frontmatterText.split(/\r?\n/)) {
1010+ const line = rawLine.trim();
1011+ if (!line || line.startsWith("#")) continue;
1012+ const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(rawLine);
1013+ if (!match) continue;
1014+ const key = match[1];
1015+ if (!key) continue;
1016+ fields[key] = parseScalar(match[2] ?? "");
1017+ }
1018+ return fields;
1019+}
1020+
1021+function parseScalar(raw: string): unknown {
1022+ const value = raw.trim();
1023+ if (value === "true") return true;
1024+ if (value === "false") return false;
1025+ if (value === "null" || value === "~") return null;
1026+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
1027+ return value.slice(1, -1);
1028+ }
1029+ return value;
1030+}
1031diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/patcher.test.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/patcher.test.ts
1032new file mode 100644
1033index 0000000000000000000000000000000000000000..ff451111d74a1d1853d2b9ace3ebd0d4b337903a
1034--- /dev/null
1035+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/patcher.test.ts
1036@@ -0,0 +1,75 @@
1037+import { describe, it } from "node:test";
1038+import assert from "node:assert/strict";
1039+import { SimpleFrontmatterCodec } from "./parser.ts";
1040+import { MinimalFrontmatterPatcher } from "./patcher.ts";
1041+import { getDuplicateFrontmatterKeys } from "./validation.ts";
1042+
1043+const codec = new SimpleFrontmatterCodec();
1044+const patcher = new MinimalFrontmatterPatcher();
1045+
1046+describe("MinimalFrontmatterPatcher", () => {
1047+ it("does not add a second disable-model-invocation key when a skill is already manual-only", () => {
1048+ const raw = [
1049+ "---",
1050+ "name: handoff",
1051+ "description: Compact the conversation.",
1052+ "disable-model-invocation: true",
1053+ "argument-hint: What next?",
1054+ "---",
1055+ "",
1056+ "# Handoff",
1057+ "",
1058+ ].join("\n");
1059+
1060+ const patch = patcher.patchInvocationMode(codec.parse(raw), "manual-only");
1061+
1062+ assert.equal(patch.newText, raw);
1063+ assert.equal(countDisableKeys(patch.newText), 1);
1064+ });
1065+
1066+ it("collapses duplicated disable-model-invocation keys when setting manual-only", () => {
1067+ const raw = [
1068+ "---",
1069+ "name: handoff",
1070+ "description: Compact the conversation.",
1071+ "disable-model-invocation: true",
1072+ "argument-hint: What next?",
1073+ "disable-model-invocation: true",
1074+ "---",
1075+ "",
1076+ "# Handoff",
1077+ "",
1078+ ].join("\n");
1079+
1080+ const patch = patcher.patchInvocationMode(codec.parse(raw), "manual-only");
1081+
1082+ assert.equal(countDisableKeys(patch.newText), 1);
1083+ assert.deepEqual(getDuplicateFrontmatterKeys(codec.parse(patch.newText)), []);
1084+ assert.ok(patch.newText.includes("disable-model-invocation: true\nargument-hint: What next?\n---"));
1085+ });
1086+
1087+ it("removes all disable-model-invocation keys when setting agent-invocable", () => {
1088+ const raw = [
1089+ "---",
1090+ "name: handoff",
1091+ "description: Compact the conversation.",
1092+ "disable-model-invocation: true",
1093+ "argument-hint: What next?",
1094+ "disable-model-invocation: true",
1095+ "---",
1096+ "",
1097+ "# Handoff",
1098+ "",
1099+ ].join("\n");
1100+
1101+ const patch = patcher.patchInvocationMode(codec.parse(raw), "agent-invocable");
1102+
1103+ assert.equal(countDisableKeys(patch.newText), 0);
1104+ assert.deepEqual(getDuplicateFrontmatterKeys(codec.parse(patch.newText)), []);
1105+ assert.ok(patch.newText.includes("argument-hint: What next?\n---"));
1106+ });
1107+});
1108+
1109+function countDisableKeys(raw: string): number {
1110+ return (raw.match(/^disable-model-invocation\s*:/gm) ?? []).length;
1111+}
1112diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/patcher.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/patcher.ts
1113new file mode 100644
1114index 0000000000000000000000000000000000000000..b759e0d100a9f8fe24d9257e94c6e96bcd0ca437
1115--- /dev/null
1116+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/patcher.ts
1117@@ -0,0 +1,79 @@
1118+import type { FrontmatterDocument, FrontmatterPatch, SkillInvocationMode } from "../types.ts";
1119+
1120+const DISABLE_KEY = "disable-model-invocation";
1121+const DISABLE_KEY_RE = /^\s*disable-model-invocation\s*:/;
1122+
1123+export interface FrontmatterPatcher {
1124+ patchInvocationMode(doc: FrontmatterDocument, desiredMode: SkillInvocationMode): FrontmatterPatch;
1125+}
1126+
1127+export class MinimalFrontmatterPatcher implements FrontmatterPatcher {
1128+ patchInvocationMode(doc: FrontmatterDocument, desiredMode: SkillInvocationMode): FrontmatterPatch {
1129+ if (!doc.hasFrontmatter) {
1130+ const newFrontmatter = desiredMode === "manual-only" ? `${DISABLE_KEY}: true${doc.lineEnding}` : "";
1131+ const newText = newFrontmatter
1132+ ? `---${doc.lineEnding}${newFrontmatter}---${doc.lineEnding}${doc.raw}`
1133+ : doc.raw;
1134+ return { oldText: doc.raw, newText };
1135+ }
1136+
1137+ const newFrontmatter = desiredMode === "manual-only"
1138+ ? ensureManualOnly(doc.frontmatterText, doc.lineEnding)
1139+ : ensureAgentInvocable(doc.frontmatterText);
1140+
1141+ const newText = doc.raw.slice(0, doc.frontmatterStart) + newFrontmatter + doc.raw.slice(doc.frontmatterEnd);
1142+ return { oldText: doc.raw, newText };
1143+ }
1144+}
1145+
1146+function ensureManualOnly(frontmatterText: string, lineEnding: "\n" | "\r\n"): string {
1147+ const lines = splitLinesPreserve(frontmatterText, lineEnding);
1148+ let replaced = false;
1149+ const next: string[] = [];
1150+
1151+ for (const line of lines) {
1152+ if (DISABLE_KEY_RE.test(stripEol(line))) {
1153+ if (!replaced) {
1154+ next.push(`${DISABLE_KEY}: true${getEol(line) || lineEnding}`);
1155+ replaced = true;
1156+ }
1157+ continue;
1158+ }
1159+ next.push(line);
1160+ }
1161+
1162+ if (!replaced) {
1163+ if (next.length > 0 && !endsWithEol(next[next.length - 1]!)) {
1164+ next[next.length - 1] = `${next[next.length - 1]}${lineEnding}`;
1165+ }
1166+ next.push(`${DISABLE_KEY}: true${lineEnding}`);
1167+ }
1168+
1169+ return next.join("");
1170+}
1171+
1172+function ensureAgentInvocable(frontmatterText: string): string {
1173+ return splitLinesPreserve(frontmatterText, frontmatterText.includes("\r\n") ? "\r\n" : "\n")
1174+ .filter((line) => !DISABLE_KEY_RE.test(stripEol(line)))
1175+ .join("");
1176+}
1177+
1178+function splitLinesPreserve(text: string, fallbackEol: "\n" | "\r\n"): string[] {
1179+ if (text.length === 0) return [];
1180+ const lines = text.match(/.*(?:\r?\n|$)/g)?.filter((line) => line.length > 0) ?? [text];
1181+ return lines.length > 0 ? lines : [fallbackEol];
1182+}
1183+
1184+function getEol(line: string): string {
1185+ if (line.endsWith("\r\n")) return "\r\n";
1186+ if (line.endsWith("\n")) return "\n";
1187+ return "";
1188+}
1189+
1190+function stripEol(line: string): string {
1191+ return line.replace(/\r?\n$/, "");
1192+}
1193+
1194+function endsWithEol(line: string): boolean {
1195+ return line.endsWith("\n");
1196+}
1197diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/validation.test.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/validation.test.ts
1198new file mode 100644
1199index 0000000000000000000000000000000000000000..94f13c956448462b184e49d2b898f2b6188ab8c6
1200--- /dev/null
1201+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/validation.test.ts
1202@@ -0,0 +1,27 @@
1203+import { describe, it } from "node:test";
1204+import assert from "node:assert/strict";
1205+import { SimpleFrontmatterCodec } from "./parser.ts";
1206+import { deriveSkillMetadata, getDuplicateFrontmatterKeys, hasDuplicateDisableModelInvocation } from "./validation.ts";
1207+
1208+const codec = new SimpleFrontmatterCodec();
1209+
1210+describe("frontmatter validation", () => {
1211+ it("reports duplicate top-level frontmatter keys", () => {
1212+ const doc = codec.parse([
1213+ "---",
1214+ "name: handoff",
1215+ "description: Compact the conversation.",
1216+ "disable-model-invocation: true",
1217+ "argument-hint: What next?",
1218+ "disable-model-invocation: true",
1219+ "---",
1220+ "",
1221+ ].join("\n"));
1222+
1223+ assert.deepEqual(getDuplicateFrontmatterKeys(doc), ["disable-model-invocation"]);
1224+ assert.equal(hasDuplicateDisableModelInvocation(doc), true);
1225+ assert.deepEqual(deriveSkillMetadata("/skills/handoff/SKILL.md", doc).diagnostics, [
1226+ { severity: "warning", message: "Duplicate frontmatter key: disable-model-invocation" },
1227+ ]);
1228+ });
1229+});
1230diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/validation.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/validation.ts
1231new file mode 100644
1232index 0000000000000000000000000000000000000000..11fa2fe5dbbbadb955932ec59de2af04d64e34ab
1233--- /dev/null
1234+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/frontmatter/validation.ts
1235@@ -0,0 +1,70 @@
1236+import { basename, dirname } from "node:path";
1237+import type { FrontmatterDocument, SkillDiagnostic } from "../types.ts";
1238+
1239+const FRONTMATTER_KEY_RE = /^([A-Za-z0-9_-]+)\s*:/;
1240+
1241+export function deriveSkillMetadata(filePath: string, doc: FrontmatterDocument): {
1242+ name: string;
1243+ description: string;
1244+ diagnostics: SkillDiagnostic[];
1245+} {
1246+ const diagnostics: SkillDiagnostic[] = [];
1247+ const parentDirName = basename(dirname(filePath));
1248+ const name = stringField(doc.fields.name) || parentDirName;
1249+ const description = stringField(doc.fields.description);
1250+
1251+ if (!doc.hasFrontmatter) {
1252+ diagnostics.push({ severity: "warning", message: "Missing YAML front matter" });
1253+ }
1254+
1255+ const duplicateKeys = getDuplicateFrontmatterKeys(doc);
1256+ if (duplicateKeys.length > 0) {
1257+ diagnostics.push({
1258+ severity: "warning",
1259+ message: `Duplicate frontmatter key${duplicateKeys.length === 1 ? "" : "s"}: ${duplicateKeys.join(", ")}`,
1260+ });
1261+ }
1262+
1263+ if (!description) {
1264+ diagnostics.push({ severity: "error", message: "Missing required description; Pi will not load this skill" });
1265+ }
1266+ if (name !== parentDirName && basename(filePath) === "SKILL.md") {
1267+ diagnostics.push({ severity: "warning", message: `Name does not match parent directory (${parentDirName})` });
1268+ }
1269+
1270+ return { name, description: description || "", diagnostics };
1271+}
1272+
1273+export function getDisableModelInvocation(doc: FrontmatterDocument): boolean {
1274+ return doc.fields["disable-model-invocation"] === true;
1275+}
1276+
1277+export function hasDuplicateDisableModelInvocation(doc: FrontmatterDocument): boolean {
1278+ return (getTopLevelFrontmatterKeyCounts(doc).get("disable-model-invocation") ?? 0) > 1;
1279+}
1280+
1281+export function getDuplicateFrontmatterKeys(doc: FrontmatterDocument): string[] {
1282+ return [...getTopLevelFrontmatterKeyCounts(doc)]
1283+ .filter(([, count]) => count > 1)
1284+ .map(([key]) => key)
1285+ .sort();
1286+}
1287+
1288+function getTopLevelFrontmatterKeyCounts(doc: FrontmatterDocument): Map<string, number> {
1289+ const counts = new Map<string, number>();
1290+ if (!doc.hasFrontmatter) return counts;
1291+
1292+ for (const rawLine of doc.frontmatterText.split(/\r?\n/)) {
1293+ const trimmed = rawLine.trim();
1294+ if (!trimmed || trimmed.startsWith("#")) continue;
1295+ const match = FRONTMATTER_KEY_RE.exec(rawLine);
1296+ if (!match?.[1]) continue;
1297+ counts.set(match[1], (counts.get(match[1]) ?? 0) + 1);
1298+ }
1299+
1300+ return counts;
1301+}
1302+
1303+function stringField(value: unknown): string {
1304+ return typeof value === "string" ? value.trim() : "";
1305+}
1306diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/index.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/index.ts
1307new file mode 100644
1308index 0000000000000000000000000000000000000000..eb7c46378235b433e709786061544dc72362f3be
1309--- /dev/null
1310+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/index.ts
1311@@ -0,0 +1,26 @@
1312+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1313+import { AtomicSkillChangeWriter } from "./apply/writer.ts";
1314+import { DefaultSkillTogglePlanner } from "./apply/planner.ts";
1315+import { DefaultSkillLocator } from "./discovery/skill-locator.ts";
1316+import { MinimalFrontmatterPatcher } from "./frontmatter/patcher.ts";
1317+import { SimpleFrontmatterCodec } from "./frontmatter/parser.ts";
1318+import { DefaultSkillInventory } from "./inventory/loader.ts";
1319+import { NodeFileSystem } from "./ports/fs.ts";
1320+import { runToggleSkillsCommand } from "./command.ts";
1321+
1322+export default function piSkillToggle(pi: ExtensionAPI) {
1323+ const fs = new NodeFileSystem();
1324+ const codec = new SimpleFrontmatterCodec();
1325+ const patcher = new MinimalFrontmatterPatcher();
1326+ const locator = new DefaultSkillLocator(fs);
1327+ const inventory = new DefaultSkillInventory(locator, fs, codec);
1328+ const planner = new DefaultSkillTogglePlanner(fs, codec, patcher);
1329+ const writer = new AtomicSkillChangeWriter(fs);
1330+
1331+ pi.registerCommand("toggle-skills", {
1332+ description: "Toggle whether skills are agent-invocable or manual-only",
1333+ handler: async (_args, ctx) => {
1334+ await runToggleSkillsCommand(ctx, { inventory, planner, writer });
1335+ },
1336+ });
1337+}
1338diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/inventory/classifier.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/inventory/classifier.ts
1339new file mode 100644
1340index 0000000000000000000000000000000000000000..12b100b50a072defa6d8a541c4e6c88f6acabb0a
1341--- /dev/null
1342+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/inventory/classifier.ts
1343@@ -0,0 +1,31 @@
1344+import type { FrontmatterDocument, SkillInvocationMode, SkillSource } from "../types.ts";
1345+import { getDisableModelInvocation } from "../frontmatter/validation.ts";
1346+
1347+export function classifyInvocationMode(doc: FrontmatterDocument): SkillInvocationMode {
1348+ return getDisableModelInvocation(doc) ? "manual-only" : "agent-invocable";
1349+}
1350+
1351+export function formatSourceKind(kind: SkillSource["kind"] | string): string {
1352+ switch (kind) {
1353+ case "global":
1354+ return "Global";
1355+ case "user":
1356+ return "User";
1357+ case "project":
1358+ return "Project";
1359+ case "project-legacy":
1360+ return "Project (.agents)";
1361+ default:
1362+ return "Unknown";
1363+ }
1364+}
1365+
1366+export function sourceCategory(source: SkillSource): "global" | "user" | "project" | "unknown" {
1367+ if (source.kind === "project-legacy") return "project";
1368+ if (source.kind === "global" || source.kind === "user" || source.kind === "project") return source.kind;
1369+ return "unknown";
1370+}
1371+
1372+export function sourceBadge(source: SkillSource): string {
1373+ return sourceCategory(source);
1374+}
1375diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/inventory/loader.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/inventory/loader.ts
1376new file mode 100644
1377index 0000000000000000000000000000000000000000..2a8f0aace794e8019be2b564311fd1fb0f4948d8
1378--- /dev/null
1379+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/inventory/loader.ts
1380@@ -0,0 +1,57 @@
1381+import { dirname } from "node:path";
1382+import type { FileSystem } from "../ports/fs.ts";
1383+import type { FrontmatterCodec } from "../frontmatter/parser.ts";
1384+import type { SkillLocator } from "../discovery/skill-locator.ts";
1385+import type { SkillRecord } from "../types.ts";
1386+import { deriveSkillMetadata } from "../frontmatter/validation.ts";
1387+import { classifyInvocationMode } from "./classifier.ts";
1388+
1389+export interface SkillInventory {
1390+ load(cwd: string): Promise<SkillRecord[]>;
1391+}
1392+
1393+export class DefaultSkillInventory implements SkillInventory {
1394+ constructor(
1395+ private readonly locator: SkillLocator,
1396+ private readonly fs: FileSystem,
1397+ private readonly codec: FrontmatterCodec,
1398+ ) {}
1399+
1400+ async load(cwd: string): Promise<SkillRecord[]> {
1401+ const located = await this.locator.findSkillFiles(cwd);
1402+ const records: SkillRecord[] = [];
1403+
1404+ for (const file of located) {
1405+ try {
1406+ const raw = await this.fs.readFile(file.filePath);
1407+ const doc = this.codec.parse(raw);
1408+ const metadata = deriveSkillMetadata(file.filePath, doc);
1409+ records.push({
1410+ id: file.filePath,
1411+ name: metadata.name,
1412+ description: metadata.description,
1413+ filePath: file.filePath,
1414+ baseDir: dirname(file.filePath),
1415+ source: file.source,
1416+ editable: file.editable && doc.hasFrontmatter && !metadata.diagnostics.some((d) => d.severity === "error"),
1417+ mode: classifyInvocationMode(doc),
1418+ diagnostics: metadata.diagnostics,
1419+ });
1420+ } catch (error) {
1421+ records.push({
1422+ id: file.filePath,
1423+ name: file.filePath.split("/").at(-2) ?? file.filePath,
1424+ description: "",
1425+ filePath: file.filePath,
1426+ baseDir: dirname(file.filePath),
1427+ source: file.source,
1428+ editable: false,
1429+ mode: "agent-invocable",
1430+ diagnostics: [{ severity: "error", message: error instanceof Error ? error.message : String(error) }],
1431+ });
1432+ }
1433+ }
1434+
1435+ return records.sort((a, b) => a.name.localeCompare(b.name) || a.filePath.localeCompare(b.filePath));
1436+ }
1437+}
1438diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/ports/fs.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/ports/fs.ts
1439new file mode 100644
1440index 0000000000000000000000000000000000000000..b2d0d1a22ba7a7fbdb48b1c3d193a21f7bfc6dcf
1441--- /dev/null
1442+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/ports/fs.ts
1443@@ -0,0 +1,58 @@
1444+import { constants } from "node:fs";
1445+import fs from "node:fs/promises";
1446+import { dirname, join } from "node:path";
1447+
1448+export interface FileSystem {
1449+ readFile(path: string): Promise<string>;
1450+ writeFileAtomic(path: string, content: string): Promise<void>;
1451+ access(path: string, mode?: number): Promise<boolean>;
1452+ readdir(path: string): Promise<Array<{ name: string; isDirectory: boolean; isFile: boolean; isSymbolicLink: boolean }>>;
1453+ stat(path: string): Promise<{ isDirectory: boolean; isFile: boolean; mode: number }>;
1454+}
1455+
1456+export class NodeFileSystem implements FileSystem {
1457+ async readFile(path: string): Promise<string> {
1458+ return fs.readFile(path, "utf8");
1459+ }
1460+
1461+ async writeFileAtomic(path: string, content: string): Promise<void> {
1462+ const dir = dirname(path);
1463+ const tmp = join(dir, `.pi-skill-toggle-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`);
1464+ let mode: number | undefined;
1465+ try {
1466+ mode = (await fs.stat(path)).mode;
1467+ } catch {
1468+ mode = undefined;
1469+ }
1470+
1471+ await fs.writeFile(tmp, content, "utf8");
1472+ if (mode !== undefined) {
1473+ await fs.chmod(tmp, mode);
1474+ }
1475+ await fs.rename(tmp, path);
1476+ }
1477+
1478+ async access(path: string, mode = constants.F_OK): Promise<boolean> {
1479+ try {
1480+ await fs.access(path, mode);
1481+ return true;
1482+ } catch {
1483+ return false;
1484+ }
1485+ }
1486+
1487+ async readdir(path: string): Promise<Array<{ name: string; isDirectory: boolean; isFile: boolean; isSymbolicLink: boolean }>> {
1488+ const entries = await fs.readdir(path, { withFileTypes: true });
1489+ return entries.map((entry) => ({
1490+ name: entry.name,
1491+ isDirectory: entry.isDirectory(),
1492+ isFile: entry.isFile(),
1493+ isSymbolicLink: entry.isSymbolicLink(),
1494+ }));
1495+ }
1496+
1497+ async stat(path: string): Promise<{ isDirectory: boolean; isFile: boolean; mode: number }> {
1498+ const stats = await fs.stat(path);
1499+ return { isDirectory: stats.isDirectory(), isFile: stats.isFile(), mode: stats.mode };
1500+ }
1501+}
1502diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/types.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/types.ts
1503new file mode 100644
1504index 0000000000000000000000000000000000000000..09e91ba91d1bcf336dbf819175b0735406eaa968
1505--- /dev/null
1506+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/types.ts
1507@@ -0,0 +1,74 @@
1508+export type SkillInvocationMode = "agent-invocable" | "manual-only";
1509+
1510+export type SkillSource =
1511+ | { kind: "global"; root: string }
1512+ | { kind: "user"; root: string }
1513+ | { kind: "project"; root: string }
1514+ | { kind: "project-legacy"; root: string }
1515+ | { kind: "unknown"; root: string };
1516+
1517+export interface LocatedSkillFile {
1518+ filePath: string;
1519+ source: SkillSource;
1520+ editable: boolean;
1521+}
1522+
1523+export type SkillDiagnosticSeverity = "info" | "warning" | "error";
1524+
1525+export interface SkillDiagnostic {
1526+ severity: SkillDiagnosticSeverity;
1527+ message: string;
1528+}
1529+
1530+export interface SkillRecord {
1531+ id: string;
1532+ name: string;
1533+ description: string;
1534+ filePath: string;
1535+ baseDir: string;
1536+ source: SkillSource;
1537+ editable: boolean;
1538+ mode: SkillInvocationMode;
1539+ diagnostics: SkillDiagnostic[];
1540+}
1541+
1542+export interface SkillDraft {
1543+ skill: SkillRecord;
1544+ desiredMode: SkillInvocationMode;
1545+}
1546+
1547+export interface FrontmatterDocument {
1548+ raw: string;
1549+ hasFrontmatter: boolean;
1550+ frontmatterStart: number;
1551+ frontmatterEnd: number;
1552+ contentStart: number;
1553+ frontmatterText: string;
1554+ bodyText: string;
1555+ fields: Record<string, unknown>;
1556+ lineEnding: "\n" | "\r\n";
1557+}
1558+
1559+export interface FrontmatterPatch {
1560+ oldText: string;
1561+ newText: string;
1562+}
1563+
1564+export interface SkillChange {
1565+ skill: SkillRecord;
1566+ filePath: string;
1567+ from: SkillInvocationMode;
1568+ to: SkillInvocationMode;
1569+ patch: FrontmatterPatch;
1570+}
1571+
1572+export interface ApplyResult {
1573+ applied: SkillChange[];
1574+ skipped: Array<{ skill: SkillRecord; reason: string }>;
1575+ errors: Array<{ skill?: SkillRecord; message: string }>;
1576+}
1577+
1578+export interface SkillToggleUiResult {
1579+ action: "apply" | "cancel";
1580+ drafts: SkillDraft[];
1581+}
1582diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/ui/overlay.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/ui/overlay.ts
1583new file mode 100644
1584index 0000000000000000000000000000000000000000..2f980c6a9c083d93906dc08466a5ff7c6b6d2cc3
1585--- /dev/null
1586+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/ui/overlay.ts
1587@@ -0,0 +1,262 @@
1588+import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
1589+import { Key, matchesKey, type TUI } from "@earendil-works/pi-tui";
1590+import type { SkillInvocationMode, SkillRecord, SkillToggleUiResult } from "../types.ts";
1591+import { formatSourceKind } from "../inventory/classifier.ts";
1592+import { bottomBorder, combineColumns, divider, fit, frameLine, topBorder } from "./render.ts";
1593+import { filterSkills, modeLabel, toggleMode } from "./view-model.ts";
1594+
1595+export async function showSkillToggleUi(ctx: ExtensionContext, skills: SkillRecord[]): Promise<SkillToggleUiResult> {
1596+ return ctx.ui.custom<SkillToggleUiResult>(
1597+ (tui, theme, _keybindings, done) => new SkillToggleOverlay(tui, theme, skills, done),
1598+ {
1599+ overlay: true,
1600+ overlayOptions: {
1601+ anchor: "center",
1602+ width: "92%",
1603+ maxHeight: "88%",
1604+ minWidth: 86,
1605+ },
1606+ },
1607+ );
1608+}
1609+
1610+class SkillToggleOverlay {
1611+ private readonly desired = new Map<string, SkillInvocationMode>();
1612+ private search = "";
1613+ private selectedIndex = 0;
1614+
1615+ constructor(
1616+ private readonly tui: TUI,
1617+ private readonly theme: Theme,
1618+ private readonly skills: SkillRecord[],
1619+ private readonly done: (result: SkillToggleUiResult) => void,
1620+ ) {
1621+ for (const skill of skills) this.desired.set(skill.id, skill.mode);
1622+ }
1623+
1624+ handleInput(data: string): void {
1625+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
1626+ this.done({ action: "cancel", drafts: this.getDrafts() });
1627+ return;
1628+ }
1629+
1630+ if (matchesKey(data, Key.ctrl("s"))) {
1631+ this.done({ action: "apply", drafts: this.getDrafts() });
1632+ return;
1633+ }
1634+
1635+ if (matchesKey(data, Key.up)) {
1636+ this.moveSelection(-1);
1637+ return;
1638+ }
1639+
1640+ if (matchesKey(data, Key.down)) {
1641+ this.moveSelection(1);
1642+ return;
1643+ }
1644+
1645+ if (matchesKey(data, Key.space)) {
1646+ const selected = this.getSelectedSkill();
1647+ if (selected?.editable) {
1648+ this.desired.set(selected.id, toggleMode(this.desired.get(selected.id) ?? selected.mode));
1649+ this.tui.requestRender();
1650+ }
1651+ return;
1652+ }
1653+
1654+ if (matchesKey(data, Key.backspace)) {
1655+ if (this.search.length > 0) {
1656+ this.search = Array.from(this.search).slice(0, -1).join("");
1657+ this.selectedIndex = 0;
1658+ this.tui.requestRender();
1659+ }
1660+ return;
1661+ }
1662+
1663+ if (isPrintableInput(data)) {
1664+ this.search += data;
1665+ this.selectedIndex = 0;
1666+ this.tui.requestRender();
1667+ }
1668+ }
1669+
1670+ render(width: number): string[] {
1671+ const innerWidth = Math.max(20, width - 2);
1672+ const panelHeight = this.getPanelHeight();
1673+ const bodyHeight = Math.max(10, panelHeight - 8);
1674+ const leftWidth = Math.max(32, Math.floor((innerWidth - 1) * 0.48));
1675+ const rightWidth = Math.max(28, innerWidth - leftWidth - 1);
1676+
1677+ const header = this.renderHeader(innerWidth);
1678+ const search = frameLine(this.theme, this.theme.fg("muted", `Search: ${this.search || "(type to filter)"}`), innerWidth);
1679+ const body = combineColumns(
1680+ this.renderList(leftWidth, bodyHeight),
1681+ this.renderDetails(rightWidth, bodyHeight),
1682+ leftWidth,
1683+ rightWidth,
1684+ this.theme.fg("borderMuted", "│"),
1685+ ).map((line) => frameLine(this.theme, line, innerWidth));
1686+
1687diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/ui/render.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/ui/render.ts
1688new file mode 100644
1689index 0000000000000000000000000000000000000000..afe7823eb6130867a3054b0a52a4728c59049806
1690--- /dev/null
1691+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/ui/render.ts
1692@@ -0,0 +1,33 @@
1693+import type { Theme } from "@earendil-works/pi-coding-agent";
1694+import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1695+
1696+export function fit(text: string, width: number): string {
1697+ const truncated = truncateToWidth(text, Math.max(0, width));
1698+ const padding = Math.max(0, width - visibleWidth(truncated));
1699+ return `${truncated}${" ".repeat(padding)}`;
1700+}
1701+
1702+export function frameLine(theme: Theme, content: string, innerWidth: number): string {
1703+ return `${theme.fg("borderAccent", "│")}${fit(content, innerWidth)}${theme.fg("borderAccent", "│")}`;
1704+}
1705+
1706+export function divider(theme: Theme, innerWidth: number): string {
1707+ return theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
1708+}
1709+
1710+export function topBorder(theme: Theme, innerWidth: number): string {
1711+ return theme.fg("borderAccent", `┌${"─".repeat(innerWidth)}┐`);
1712+}
1713+
1714+export function bottomBorder(theme: Theme, innerWidth: number): string {
1715+ return theme.fg("borderAccent", `└${"─".repeat(innerWidth)}┘`);
1716+}
1717+
1718+export function combineColumns(left: string[], right: string[], leftWidth: number, rightWidth: number, sep: string): string[] {
1719+ const rows = Math.max(left.length, right.length);
1720+ const lines: string[] = [];
1721+ for (let i = 0; i < rows; i += 1) {
1722+ lines.push(`${fit(left[i] ?? "", leftWidth)}${sep}${fit(right[i] ?? "", rightWidth)}`);
1723+ }
1724+ return lines;
1725+}
1726diff --git a/dot_pi/agent/extensions/pi-skill-toggle/src/ui/view-model.ts b/dot_pi/agent/extensions/pi-skill-toggle/src/ui/view-model.ts
1727new file mode 100644
1728index 0000000000000000000000000000000000000000..8210d53012199728d1ad0862f0ea791ae6613f44
1729--- /dev/null
1730+++ b/dot_pi/agent/extensions/pi-skill-toggle/src/ui/view-model.ts
1731@@ -0,0 +1,33 @@
1732+import { formatSourceKind, sourceBadge } from "../inventory/classifier.ts";
1733+import type { SkillInvocationMode, SkillRecord } from "../types.ts";
1734+
1735+export function modeLabel(mode: SkillInvocationMode): string {
1736+ return mode === "manual-only" ? "Manual-only" : "Agent-invocable";
1737+}
1738+
1739+export function toggleMode(mode: SkillInvocationMode): SkillInvocationMode {
1740+ return mode === "manual-only" ? "agent-invocable" : "manual-only";
1741+}
1742+
1743+export function skillSearchText(skill: SkillRecord): string {
1744+ return [
1745+ skill.name,
1746+ skill.description,
1747+ skill.filePath,
1748+ skill.source.kind,
1749+ sourceBadge(skill.source),
1750+ formatSourceKind(skill.source.kind),
1751+ modeLabel(skill.mode),
1752+ ]
1753+ .join(" ")
1754+ .toLowerCase();
1755+}
1756+
1757+export function filterSkills(skills: SkillRecord[], query: string): SkillRecord[] {
1758+ const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
1759+ if (tokens.length === 0) return skills;
1760+ return skills.filter((skill) => {
1761+ const haystack = skillSearchText(skill);
1762+ return tokens.every((token) => haystack.includes(token));
1763+ });
1764+}
1765diff --git a/dot_pi/agent/extensions/pi-skill-toggle/tsconfig.json b/dot_pi/agent/extensions/pi-skill-toggle/tsconfig.json
1766new file mode 100644
1767index 0000000000000000000000000000000000000000..b6a89d12238110f9ee68f67b9f15eee3b4f05c6c
1768--- /dev/null
1769+++ b/dot_pi/agent/extensions/pi-skill-toggle/tsconfig.json
1770@@ -0,0 +1,14 @@
1771+{
1772+ "compilerOptions": {
1773+ "target": "ESNext",
1774+ "module": "NodeNext",
1775+ "moduleResolution": "NodeNext",
1776+ "lib": ["ESNext"],
1777+ "allowImportingTsExtensions": true,
1778+ "noEmit": true,
1779+ "strict": true,
1780+ "skipLibCheck": true,
1781+ "types": ["node"]
1782+ },
1783+ "include": ["./src/**/*.ts"]
1784+}
1785diff --git a/dot_pi/agent/extensions/pi-tree-pruning/index.ts b/dot_pi/agent/extensions/pi-tree-pruning/index.ts
1786new file mode 100644
1787index 0000000000000000000000000000000000000000..4a95e8325e9b1634fb628d27e4e8b7734d5d1f85
1788--- /dev/null
1789+++ b/dot_pi/agent/extensions/pi-tree-pruning/index.ts
1790@@ -0,0 +1,181 @@
1791+import { basename } from "node:path";
1792+import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1793+import {
1794+ captureSessionFile,
1795+ createBackupPath,
1796+ listSessionBackups,
1797+ pruneSessionFile,
1798+ restoreSessionBackup,
1799+} from "./session-file.ts";
1800+import { selectPruneCandidate } from "./prune-selector.ts";
1801+import { findInactiveBranchCandidates } from "./tree-model.ts";
1802+import { buildPruneTreeItems, formatEntryCount } from "./tree-view.ts";
1803+
1804+const COMMAND_NAME = "tree-prune";
1805+
1806+export default function registerTreePruning(pi: ExtensionAPI): void {
1807+ pi.registerCommand(COMMAND_NAME, {
1808+ description: "Delete an inactive session-tree branch with a backup; use /tree-prune undo to restore",
1809+ handler: async (args, ctx) => {
1810+ await runTreePruneCommand(args, ctx);
1811+ },
1812+ });
1813+}
1814+
1815+export async function runTreePruneCommand(
1816+ args: string,
1817+ ctx: ExtensionCommandContext,
1818+): Promise<void> {
1819+ const mode = args.trim();
1820+
1821+ if (!ctx.hasUI) {
1822+ ctx.ui.notify("/tree-prune requires a user interface", "error");
1823+ return;
1824+ }
1825+
1826+ if (mode === "") {
1827+ await runPrune(ctx);
1828+ return;
1829+ }
1830+
1831+ if (mode === "undo") {
1832+ await runUndo(ctx);
1833+ return;
1834+ }
1835+
1836+ ctx.ui.notify("Usage: /tree-prune [undo]", "error");
1837+}
1838+
1839+async function runPrune(ctx: ExtensionCommandContext): Promise<void> {
1840+ try {
1841+ await ctx.waitForIdle();
1842+
1843+ const sessionFile = requireSessionFile(ctx);
1844+ const activeLeafId = ctx.sessionManager.getLeafId();
1845+ if (!activeLeafId) {
1846+ ctx.ui.notify("Tree Prune needs a current active leaf", "warning");
1847+ return;
1848+ }
1849+
1850+ const entries = ctx.sessionManager.getEntries();
1851+ if (!entries.some((entry) => entry.id === activeLeafId)) {
1852+ ctx.ui.notify("Tree Prune cannot find the active leaf in this session", "error");
1853+ return;
1854+ }
1855+
1856+ const snapshot = await captureSessionFile(sessionFile);
1857+ const candidates = findInactiveBranchCandidates(entries, activeLeafId);
1858+ if (candidates.length === 0) {
1859+ ctx.ui.notify("Tree Prune found no inactive branches", "info");
1860+ return;
1861+ }
1862+
1863+ const treeItems = buildPruneTreeItems(candidates, (entryId) => ctx.sessionManager.getLabel(entryId));
1864+ const candidate = await selectPruneCandidate(ctx, treeItems);
1865+ if (!candidate) return;
1866+
1867+ const backupPath = createBackupPath(sessionFile);
1868+ const confirmed = await ctx.ui.confirm(
1869+ "Delete inactive branch?",
1870+ [
1871+ `Branch: ${candidate.preview}`,
1872+ `Entries to delete: ${candidate.entryCount}`,
1873+ `Backup: ${backupPath}`,
1874+ "The branch will be removed from this session file.",
1875+ ].join("\n"),
1876+ );
1877+ if (!confirmed) return;
1878+
1879+ const result = await pruneSessionFile(snapshot, candidate, backupPath);
1880+ const switchResult = await ctx.switchSession(sessionFile, {
1881+ withSession: async (replacement) => {
1882+ replacement.ui.notify(
1883+ `Tree Prune removed ${formatEntryCount(result.removedEntryCount)}. Backup: ${result.backupPath}`,
1884+ "info",
1885+ );
1886+ },
1887+ });
1888+
1889+ if (switchResult.cancelled) {
1890diff --git a/dot_pi/agent/extensions/pi-tree-pruning/package.json b/dot_pi/agent/extensions/pi-tree-pruning/package.json
1891new file mode 100644
1892index 0000000000000000000000000000000000000000..bbcf59847315555fa7bb05f4482ed9c159ff9815
1893--- /dev/null
1894+++ b/dot_pi/agent/extensions/pi-tree-pruning/package.json
1895@@ -0,0 +1,24 @@
1896+{
1897+ "name": "pi-tree-pruning",
1898+ "private": true,
1899+ "version": "0.1.0",
1900+ "type": "module",
1901+ "scripts": {
1902+ "test": "node --test test/*.test.ts",
1903+ "typecheck": "tsc --noEmit",
1904+ "check": "npm run typecheck && npm test"
1905+ },
1906+ "pi": {
1907+ "extensions": [
1908+ "./index.ts"
1909+ ]
1910+ },
1911+ "peerDependencies": {
1912+ "@earendil-works/pi-coding-agent": "*",
1913+ "@earendil-works/pi-tui": "*"
1914+ },
1915+ "devDependencies": {
1916+ "@types/node": "^24.5.2",
1917+ "typescript": "^5.9.2"
1918+ }
1919+}
1920diff --git a/dot_pi/agent/extensions/pi-tree-pruning/prune-selector.ts b/dot_pi/agent/extensions/pi-tree-pruning/prune-selector.ts
1921new file mode 100644
1922index 0000000000000000000000000000000000000000..abdacaf07d2e58f793c1d5973796c403177357d4
1923--- /dev/null
1924+++ b/dot_pi/agent/extensions/pi-tree-pruning/prune-selector.ts
1925@@ -0,0 +1,103 @@
1926+import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1927+import { type BranchCandidate } from "./tree-model.ts";
1928+import { formatPruneTreeItem, type PruneTreeItem } from "./tree-view.ts";
1929+
1930+export async function selectPruneCandidate(
1931+ ctx: ExtensionCommandContext,
1932+ items: readonly PruneTreeItem[],
1933+): Promise<BranchCandidate | undefined> {
1934+ if (ctx.mode === "tui") {
1935+ return selectWithTreeUi(ctx, items);
1936+ }
1937+
1938+ const choices = makeUniqueChoices(items);
1939+ const selected = await ctx.ui.select("Prune inactive branch:", choices.map((choice) => choice.label));
1940+ return choices.find((choice) => choice.label === selected)?.item.candidate;
1941+}
1942+
1943+async function selectWithTreeUi(
1944+ ctx: ExtensionCommandContext,
1945+ items: readonly PruneTreeItem[],
1946+): Promise<BranchCandidate | undefined> {
1947+ const { Key, matchesKey, truncateToWidth } = await import("@earendil-works/pi-tui");
1948+ const selectedRootId = await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
1949+ let selectedIndex = 0;
1950+ let scrollOffset = 0;
1951+
1952+ const visibleItemCount = (): number => Math.max(1, (tui.terminal.rows ?? 24) - 5);
1953+ const keepSelectionVisible = (): void => {
1954+ const visibleCount = visibleItemCount();
1955+ if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
1956+ if (selectedIndex >= scrollOffset + visibleCount) {
1957+ scrollOffset = selectedIndex - visibleCount + 1;
1958+ }
1959+ };
1960+ const moveSelection = (offset: number): void => {
1961+ selectedIndex = Math.max(0, Math.min(items.length - 1, selectedIndex + offset));
1962+ keepSelectionVisible();
1963+ tui.requestRender();
1964+ };
1965+
1966+ return {
1967+ handleInput(data: string): void {
1968+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
1969+ done(undefined);
1970+ return;
1971+ }
1972+ if (matchesKey(data, Key.up)) {
1973+ moveSelection(-1);
1974+ return;
1975+ }
1976+ if (matchesKey(data, Key.down)) {
1977+ moveSelection(1);
1978+ return;
1979+ }
1980+ if (matchesKey(data, Key.enter)) {
1981+ done(items[selectedIndex]?.candidate.rootId);
1982+ }
1983+ },
1984+ render(width: number): string[] {
1985+ const visibleCount = visibleItemCount();
1986+ const visibleItems = items.slice(scrollOffset, scrollOffset + visibleCount);
1987+ const lines = [
1988+ theme.fg("accent", theme.bold("Prune inactive branch:")),
1989+ theme.fg("dim", "↑↓ move • enter select • esc cancel"),
1990+ ];
1991+
1992+ if (scrollOffset > 0) lines.push(theme.fg("dim", `… ${scrollOffset} earlier branches`));
1993+
1994+ for (let index = 0; index < visibleItems.length; index += 1) {
1995+ const item = visibleItems[index];
1996+ if (!item) continue;
1997+
1998+ const itemIndex = scrollOffset + index;
1999+ const row = `${itemIndex === selectedIndex ? "→" : " "} ${item.treePrefix}${formatPruneTreeItem(item)}`;
2000+ const rendered = itemIndex === selectedIndex ? theme.fg("accent", theme.bold(row)) : row;
2001+ lines.push(rendered);
2002+ }
2003+
2004+ const hiddenAfter = items.length - scrollOffset - visibleItems.length;
2005+ if (hiddenAfter > 0) lines.push(theme.fg("dim", `… ${hiddenAfter} later branches`));
2006+
2007+ return lines.map((line) => truncateToWidth(line, width));
2008+ },
2009+ invalidate(): void {},
2010+ };
2011+ });
2012+
2013+ return items.find((item) => item.candidate.rootId === selectedRootId)?.candidate;
2014+}
2015+
2016+function makeUniqueChoices(items: readonly PruneTreeItem[]): Array<{ label: string; item: PruneTreeItem }> {
2017+ const counts = new Map<string, number>();
2018+
2019+ return items.map((item) => {
2020+ const baseLabel = `${item.treePrefix}${formatPruneTreeItem(item)}`;
2021+ const count = (counts.get(baseLabel) ?? 0) + 1;
2022+ counts.set(baseLabel, count);
2023+ return {
2024+ label: count === 1 ? baseLabel : `${baseLabel} (${count})`,
2025diff --git a/dot_pi/agent/extensions/pi-tree-pruning/session-file.ts b/dot_pi/agent/extensions/pi-tree-pruning/session-file.ts
2026new file mode 100644
2027index 0000000000000000000000000000000000000000..6017175b3d145c0ced784043f1884abe4466dc4b
2028--- /dev/null
2029+++ b/dot_pi/agent/extensions/pi-tree-pruning/session-file.ts
2030@@ -0,0 +1,268 @@
2031+import { randomUUID } from "node:crypto";
2032+import { chmod, link, lstat, open, readdir, readFile, rename, unlink } from "node:fs/promises";
2033+import { basename, dirname, join, resolve } from "node:path";
2034+import type { BranchCandidate, TreeEntry } from "./tree-model.ts";
2035+
2036+export interface FileIdentity {
2037+ readonly dev: number;
2038+ readonly ino: number;
2039+ readonly mode: number;
2040+ readonly mtimeMs: number;
2041+ readonly size: number;
2042+}
2043+
2044+export interface SessionFileSnapshot {
2045+ readonly path: string;
2046+ readonly content: string;
2047+ readonly identity: FileIdentity;
2048+}
2049+
2050+interface RawLine {
2051+ readonly content: string;
2052+ readonly record?: Record<string, unknown>;
2053+}
2054+
2055+export async function captureSessionFile(sessionFile: string): Promise<SessionFileSnapshot> {
2056+ const path = resolve(sessionFile);
2057+
2058+ for (let attempt = 0; attempt < 2; attempt += 1) {
2059+ const before = await getFileIdentity(path);
2060+ const content = await readFile(path, "utf8");
2061+ const after = await getFileIdentity(path);
2062+
2063+ if (sameIdentity(before, after) && Buffer.byteLength(content) === after.size) {
2064+ return { path, content, identity: after };
2065+ }
2066+ }
2067+
2068+ throw new Error("Session file changed while it was read");
2069+}
2070+
2071+export async function assertSessionFileUnchanged(snapshot: SessionFileSnapshot): Promise<void> {
2072+ const current = await captureSessionFile(snapshot.path);
2073+ if (!sameIdentity(snapshot.identity, current.identity) || snapshot.content !== current.content) {
2074+ throw new Error("Session file changed while the dialog was open");
2075+ }
2076+}
2077+
2078+export function buildPrunedSessionContent(
2079+ snapshot: SessionFileSnapshot,
2080+ candidate: BranchCandidate,
2081+): string {
2082+ const expectedEntries = new Map<string, TreeEntry>();
2083+ for (const entry of candidate.entries) {
2084+ if (expectedEntries.has(entry.id)) {
2085+ throw new Error(`Branch has duplicate entry ID ${entry.id}`);
2086+ }
2087+ expectedEntries.set(entry.id, entry);
2088+ }
2089+
2090+ if (expectedEntries.size === 0) throw new Error("Branch has no entries");
2091+
2092+ const rawLines = splitRawLines(snapshot.content);
2093+ const linesByEntryId = new Map<string, number[]>();
2094+
2095+ for (let index = 0; index < rawLines.length; index += 1) {
2096+ const rawLine = rawLines[index];
2097+ if (!rawLine?.record) continue;
2098+
2099+ const id = rawLine.record.id;
2100+ if (typeof id !== "string" || !expectedEntries.has(id)) continue;
2101+
2102+ const indexes = linesByEntryId.get(id) ?? [];
2103+ indexes.push(index);
2104+ linesByEntryId.set(id, indexes);
2105+ }
2106+
2107+ const lineIndexesToRemove = new Set<number>();
2108+ for (const [id, entry] of expectedEntries) {
2109+ const indexes = linesByEntryId.get(id) ?? [];
2110+ if (indexes.length !== 1) {
2111+ throw new Error(`Could not safely locate exactly one JSONL line for entry ${id}`);
2112+ }
2113+
2114+ const lineIndex = indexes[0];
2115+ if (lineIndex === undefined) {
2116+ throw new Error(`Could not safely locate JSONL line for entry ${id}`);
2117+ }
2118+
2119+ const rawLine = rawLines[lineIndex];
2120+ if (!rawLine?.record || !matchesEntry(rawLine.record, entry)) {
2121+ throw new Error(`JSONL line for entry ${id} does not match the loaded session`);
2122+ }
2123+
2124+ lineIndexesToRemove.add(lineIndex);
2125+ }
2126+
2127+ return rawLines
2128+ .filter((_line, index) => !lineIndexesToRemove.has(index))
2129+ .map((line) => line.content)
2130diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/command.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/command.test.ts
2131new file mode 100644
2132index 0000000000000000000000000000000000000000..60c6f31aafed54975c0e0acbc474b869f7d96988
2133--- /dev/null
2134+++ b/dot_pi/agent/extensions/pi-tree-pruning/test/command.test.ts
2135@@ -0,0 +1,75 @@
2136+import assert from "node:assert/strict";
2137+import { mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
2138+import { tmpdir } from "node:os";
2139+import { join } from "node:path";
2140+import test from "node:test";
2141+import type {
2142+ ExtensionAPI,
2143+ ExtensionCommandContext,
2144+ RegisteredCommand,
2145+} from "@earendil-works/pi-coding-agent";
2146+import registerTreePruning from "../index.ts";
2147+
2148+const originalContent = [
2149+ '{"type":"session","version":3,"id":"session-id","timestamp":"now","cwd":"/tmp"}\n',
2150+ '{"type":"message","id":"root","parentId":null,"timestamp":"now","message":{"role":"user","content":"root"}}\n',
2151+ '{"type":"message","id":"active","parentId":"root","timestamp":"now","message":{"role":"user","content":"active"}}\n',
2152+ '{"type":"message","id":"inactive","parentId":"root","timestamp":"now","message":{"role":"user","content":"inactive"}}\n',
2153+].join("");
2154+
2155+const entries = [
2156+ { id: "root", parentId: null, type: "message", message: { role: "user", content: "root" } },
2157+ { id: "active", parentId: "root", type: "message", message: { role: "user", content: "active" } },
2158+ { id: "inactive", parentId: "root", type: "message", message: { role: "user", content: "inactive" } },
2159+];
2160+
2161+test("prunes a selected branch, creates a backup, and reloads the session", async () => {
2162+ const directory = await mkdtemp(join(tmpdir(), "pi-tree-pruning-command-"));
2163+ const sessionPath = join(directory, "session.jsonl");
2164+ await writeFile(sessionPath, originalContent, "utf8");
2165+
2166+ let handler: ((args: string, ctx: ExtensionCommandContext) => Promise<void>) | undefined;
2167+ registerTreePruning({
2168+ registerCommand(
2169+ _name: string,
2170+ command: Omit<RegisteredCommand, "name" | "sourceInfo">,
2171+ ) {
2172+ handler = command.handler;
2173+ },
2174+ } as unknown as ExtensionAPI);
2175+
2176+ const notifications: string[] = [];
2177+ let reloadedSession: string | undefined;
2178+ const context = {
2179+ hasUI: true,
2180+ waitForIdle: async () => {},
2181+ sessionManager: {
2182+ getSessionFile: () => sessionPath,
2183+ getLeafId: () => "active",
2184+ getEntries: () => entries,
2185+ getLabel: () => undefined,
2186+ },
2187+ ui: {
2188+ select: async (_title: string, options: string[]) => options[0],
2189+ confirm: async () => true,
2190+ notify: (message: string) => notifications.push(message),
2191+ },
2192+ switchSession: async (path: string, options?: { withSession?: (ctx: { ui: { notify: (message: string) => void } }) => Promise<void> }) => {
2193+ reloadedSession = path;
2194+ await options?.withSession?.({ ui: { notify: (message) => notifications.push(message) } });
2195+ return { cancelled: false };
2196+ },
2197+ } as unknown as ExtensionCommandContext;
2198+
2199+ assert.ok(handler);
2200+ await handler("", context);
2201+
2202+ assert.equal(reloadedSession, sessionPath);
2203+ assert.doesNotMatch(await readFile(sessionPath, "utf8"), /"inactive"/);
2204+ assert.match(notifications.join("\n"), /Tree Prune removed 1 entry/);
2205+
2206+ const files = await readdir(directory);
2207+ const backupName = files.find((file) => file.startsWith("session.jsonl.tree-prune-backup-"));
2208+ assert.ok(backupName);
2209+ assert.equal(await readFile(join(directory, backupName), "utf8"), originalContent);
2210+});
2211diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/session-file.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/session-file.test.ts
2212new file mode 100644
2213index 0000000000000000000000000000000000000000..3cf76bfcdc7e98b8c525f78c17fa90d41d4a5b0b
2214--- /dev/null
2215+++ b/dot_pi/agent/extensions/pi-tree-pruning/test/session-file.test.ts
2216@@ -0,0 +1,110 @@
2217+import assert from "node:assert/strict";
2218+import { mkdtemp, readFile, writeFile } from "node:fs/promises";
2219+import { tmpdir } from "node:os";
2220+import { join } from "node:path";
2221+import test from "node:test";
2222+import {
2223+ assertSessionFileUnchanged,
2224+ buildPrunedSessionContent,
2225+ captureSessionFile,
2226+ createBackupPath,
2227+ createSessionBackup,
2228+ pruneSessionFile,
2229+ restoreSessionBackup,
2230+} from "../session-file.ts";
2231+import { findInactiveBranchCandidates, type TreeEntry } from "../tree-model.ts";
2232+
2233+const sessionContent = [
2234+ '{"type":"session","version":3,"id":"session-id","timestamp":"now","cwd":"/tmp"}\n',
2235+ '{"type":"message","id":"root","parentId":null,"timestamp":"now","message":{"role":"user","content":"root"}}\n',
2236+ 'this is not json\n',
2237+ '{"type":"message","id":"active","parentId":"root","timestamp":"now","message":{"role":"user","content":"active"}}\n',
2238+ '{"type":"message","id":"inactive","parentId":"root","timestamp":"now","message":{"role":"user","content":"inactive"}}\n',
2239+ '{"type":"custom","id":"metadata","parentId":"inactive","timestamp":"now","customType":"example"}\n',
2240+].join("");
2241+
2242+const prunedContent = [
2243+ '{"type":"session","version":3,"id":"session-id","timestamp":"now","cwd":"/tmp"}\n',
2244+ '{"type":"message","id":"root","parentId":null,"timestamp":"now","message":{"role":"user","content":"root"}}\n',
2245+ 'this is not json\n',
2246+ '{"type":"message","id":"active","parentId":"root","timestamp":"now","message":{"role":"user","content":"active"}}\n',
2247+].join("");
2248+
2249+function treeEntries(): TreeEntry[] {
2250+ return [
2251+ { id: "root", parentId: null, type: "message", message: { role: "user", content: "root" } },
2252+ { id: "active", parentId: "root", type: "message", message: { role: "user", content: "active" } },
2253+ { id: "inactive", parentId: "root", type: "message", message: { role: "user", content: "inactive" } },
2254+ { id: "metadata", parentId: "inactive", type: "custom" },
2255+ ];
2256+}
2257+
2258+async function createFixture(): Promise<string> {
2259+ const directory = await mkdtemp(join(tmpdir(), "pi-tree-pruning-"));
2260+ const sessionPath = join(directory, "session.jsonl");
2261+ await writeFile(sessionPath, sessionContent, "utf8");
2262+ return sessionPath;
2263+}
2264+
2265+function inactiveCandidate() {
2266+ const candidate = findInactiveBranchCandidates(treeEntries(), "active")[0];
2267+ assert.ok(candidate);
2268+ return candidate;
2269+}
2270+
2271+test("removes only the selected JSONL lines and retains unrelated raw lines", async () => {
2272+ const sessionPath = await createFixture();
2273+ const snapshot = await captureSessionFile(sessionPath);
2274+
2275+ assert.equal(buildPrunedSessionContent(snapshot, inactiveCandidate()), prunedContent);
2276+});
2277+
2278+test("creates a same-directory backup without changing the session", async () => {
2279+ const sessionPath = await createFixture();
2280+ const snapshot = await captureSessionFile(sessionPath);
2281+ const backupPath = createBackupPath(sessionPath);
2282+
2283+ const createdBackupPath = await createSessionBackup(snapshot, backupPath);
2284+
2285+ assert.equal(createdBackupPath, backupPath);
2286+ assert.match(backupPath, /session\.jsonl\.tree-prune-backup-/);
2287+ assert.equal(await readFile(backupPath, "utf8"), sessionContent);
2288+ assert.equal(await readFile(sessionPath, "utf8"), sessionContent);
2289+});
2290+
2291+test("detects a changed session file before mutation", async () => {
2292+ const sessionPath = await createFixture();
2293+ const snapshot = await captureSessionFile(sessionPath);
2294+ await writeFile(sessionPath, `${sessionContent}changed\n`, "utf8");
2295+
2296+ await assert.rejects(assertSessionFileUnchanged(snapshot), /changed while the dialog was open/);
2297+});
2298+
2299+test("prunes with a backup and can restore that backup with a backup of the pruned session", async () => {
2300+ const sessionPath = await createFixture();
2301+ const originalSnapshot = await captureSessionFile(sessionPath);
2302+ const originalBackupPath = createBackupPath(sessionPath);
2303+
2304+ const pruneResult = await pruneSessionFile(
2305+ originalSnapshot,
2306+ inactiveCandidate(),
2307+ originalBackupPath,
2308+ );
2309+
2310+ assert.equal(pruneResult.removedEntryCount, 2);
2311+ assert.equal(await readFile(sessionPath, "utf8"), prunedContent);
2312+ assert.equal(await readFile(originalBackupPath, "utf8"), sessionContent);
2313+
2314+ const prunedSnapshot = await captureSessionFile(sessionPath);
2315+ const originalBackupSnapshot = await captureSessionFile(originalBackupPath);
2316diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/tree-model.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/tree-model.test.ts
2317new file mode 100644
2318index 0000000000000000000000000000000000000000..f81e4524a301d64febd9c3da6181fd27c787fb92
2319--- /dev/null
2320+++ b/dot_pi/agent/extensions/pi-tree-pruning/test/tree-model.test.ts
2321@@ -0,0 +1,83 @@
2322+import assert from "node:assert/strict";
2323+import test from "node:test";
2324+import { findInactiveBranchCandidates, type TreeEntry } from "../tree-model.ts";
2325+
2326+function entry(id: string, parentId: string | null, type = "message"): TreeEntry {
2327+ if (type !== "message") return { id, parentId, type };
2328+ return { id, parentId, type, message: { role: "user", content: `message ${id}` } };
2329+}
2330+
2331+test("lists complete inactive subtrees and protects the active subtree", () => {
2332+ const entries = [
2333+ entry("root", null),
2334+ entry("active", "root"),
2335+ entry("active-leaf", "active"),
2336+ entry("inactive", "root"),
2337+ entry("inactive-child", "inactive"),
2338+ ];
2339+
2340+ const candidates = findInactiveBranchCandidates(entries, "active-leaf");
2341+
2342+ assert.equal(candidates.length, 1);
2343+ assert.equal(candidates[0]?.rootId, "inactive");
2344+ assert.deepEqual(candidates[0]?.entryIds, ["inactive", "inactive-child"]);
2345+ assert.equal(candidates[0]?.entryCount, 2);
2346+ assert.match(candidates[0]?.preview ?? "", /^user: message inactive$/);
2347+});
2348+
2349+test("keeps metadata in a removable subtree and supports multiple roots", () => {
2350+ const entries = [
2351+ entry("root-a", null),
2352+ entry("active", "root-a"),
2353+ entry("inactive", "root-a"),
2354+ entry("label", "inactive", "label"),
2355+ entry("root-b", null),
2356+ entry("branch-b1", "root-b"),
2357+ entry("branch-b2", "root-b"),
2358+ ];
2359+
2360+ const candidates = findInactiveBranchCandidates(entries, "active");
2361+
2362+ assert.deepEqual(
2363+ candidates.map((candidate) => [candidate.rootId, candidate.entryIds]),
2364+ [
2365+ ["inactive", ["inactive", "label"]],
2366+ ["branch-b1", ["branch-b1"]],
2367+ ["branch-b2", ["branch-b2"]],
2368+ ],
2369+ );
2370+});
2371+
2372+test("rejects ambiguous children, cycles, duplicates, and a missing active leaf", () => {
2373+ const ambiguousChild = { id: "broken", parentId: "root", type: 42 };
2374+ const entries = [
2375+ entry("root", null),
2376+ entry("active", "root"),
2377+ entry("inactive", "root"),
2378+ ambiguousChild,
2379+ entry("duplicate", "inactive"),
2380+ entry("duplicate", "inactive"),
2381+ entry("cycle-a", "cycle-b"),
2382+ entry("cycle-b", "cycle-a"),
2383+ ] as unknown as TreeEntry[];
2384+
2385+ assert.deepEqual(findInactiveBranchCandidates(entries, "active"), []);
2386+ assert.deepEqual(findInactiveBranchCandidates(entries, "missing"), []);
2387+});
2388+
2389+test("does not offer a nested candidate that reaches a cycle", () => {
2390+ const entries = [
2391+ entry("root", null),
2392+ entry("active", "root"),
2393+ entry("inactive", "root"),
2394+ entry("fork", "inactive"),
2395+ entry("safe", "fork"),
2396+ entry("cycle-a", "fork"),
2397+ entry("cycle-b", "cycle-a"),
2398+ entry("cycle-a", "cycle-b"),
2399+ ] as unknown as TreeEntry[];
2400+
2401+ const candidates = findInactiveBranchCandidates(entries, "active");
2402+
2403+ assert.deepEqual(candidates, []);
2404+});
2405diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/tree-view.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/tree-view.test.ts
2406new file mode 100644
2407index 0000000000000000000000000000000000000000..67fe657929ac3f9c83c9529e3e878bfae6a4af8e
2408--- /dev/null
2409+++ b/dot_pi/agent/extensions/pi-tree-pruning/test/tree-view.test.ts
2410@@ -0,0 +1,42 @@
2411+import assert from "node:assert/strict";
2412+import test from "node:test";
2413+import type { BranchCandidate } from "../tree-model.ts";
2414+import {
2415+ buildPruneTreeItems,
2416+ formatEntryCount,
2417+ formatPruneTreeItem,
2418+} from "../tree-view.ts";
2419+
2420+function candidate(
2421+ rootId: string,
2422+ forkPointId: string,
2423+ entryIds: string[],
2424+ preview: string,
2425+): BranchCandidate {
2426+ return {
2427+ rootId,
2428+ forkPointId,
2429+ entries: entryIds.map((id) => ({ id, parentId: null, type: "message" })),
2430+ entryIds,
2431+ entryCount: entryIds.length,
2432+ preview,
2433+ };
2434+}
2435+
2436+test("renders nested branches with labels and correct entry counts", () => {
2437+ const main = candidate("main", "active-fork", ["main", "nested-fork", "quick"], "user: main work");
2438+ const quick = candidate("quick", "nested-fork", ["quick", "quick-reply"], "user: check implementation");
2439+
2440+ const items = buildPruneTreeItems([main, quick], (entryId) =>
2441+ entryId === "quick" ? "quick check" : undefined,
2442+ );
2443+
2444+ assert.deepEqual(
2445+ items.map((item) => item.treePrefix),
2446+ ["", " └─ "],
2447+ );
2448+ assert.equal(formatPruneTreeItem(items[0]!), "user: main work · 3 entries");
2449+ assert.equal(formatPruneTreeItem(items[1]!), "[quick check] user: check implementation · 2 entries");
2450+ assert.equal(formatEntryCount(1), "1 entry");
2451+ assert.equal(formatEntryCount(2), "2 entries");
2452+});
2453diff --git a/dot_pi/agent/extensions/pi-tree-pruning/tree-model.ts b/dot_pi/agent/extensions/pi-tree-pruning/tree-model.ts
2454new file mode 100644
2455index 0000000000000000000000000000000000000000..a73d083dd3080d2846508ab78445db2f75ab6c0b
2456--- /dev/null
2457+++ b/dot_pi/agent/extensions/pi-tree-pruning/tree-model.ts
2458@@ -0,0 +1,196 @@
2459+export interface TreeEntry {
2460+ readonly id: string;
2461+ readonly parentId: string | null;
2462+ readonly type: string;
2463+ readonly message?: unknown;
2464+ readonly summary?: unknown;
2465+}
2466+
2467+export interface BranchCandidate {
2468+ readonly rootId: string;
2469+ readonly forkPointId: string;
2470+ readonly entries: readonly TreeEntry[];
2471+ readonly entryIds: readonly string[];
2472+ readonly entryCount: number;
2473+ readonly preview: string;
2474+}
2475+
2476+interface TreeIndex {
2477+ readonly entriesById: ReadonlyMap<string, TreeEntry>;
2478+ readonly childrenByParentId: ReadonlyMap<string, readonly string[]>;
2479+ readonly uncertainChildrenByParentId: ReadonlySet<string>;
2480+}
2481+
2482+const PREVIEW_LIMIT = 72;
2483+
2484+export function findInactiveBranchCandidates(
2485+ entries: readonly TreeEntry[],
2486+ activeLeafId: string,
2487+): BranchCandidate[] {
2488+ const tree = buildTreeIndex(entries);
2489+ if (!tree.entriesById.has(activeLeafId)) return [];
2490+
2491+ const candidates: BranchCandidate[] = [];
2492+ for (const [forkPointId, childIds] of tree.childrenByParentId) {
2493+ if (childIds.length < 2 || tree.uncertainChildrenByParentId.has(forkPointId)) continue;
2494+
2495+ for (const rootId of childIds) {
2496+ const branchEntries = collectSubtree(rootId, tree);
2497+ if (!branchEntries) continue;
2498+ if (branchEntries.some((entry) => entry.id === activeLeafId)) continue;
2499+
2500+ const root = tree.entriesById.get(rootId);
2501+ if (!root) continue;
2502+
2503+ candidates.push({
2504+ rootId,
2505+ forkPointId,
2506+ entries: branchEntries,
2507+ entryIds: branchEntries.map((entry) => entry.id),
2508+ entryCount: branchEntries.length,
2509+ preview: previewEntry(root),
2510+ });
2511+ }
2512+ }
2513+
2514+ return candidates;
2515+}
2516+
2517+function buildTreeIndex(entries: readonly TreeEntry[]): TreeIndex {
2518+ const validEntries = entries.filter(isTreeEntry);
2519+ const occurrences = new Map<string, number>();
2520+ const uncertainChildrenByParentId = new Set<string>();
2521+
2522+ for (const entry of validEntries) {
2523+ occurrences.set(entry.id, (occurrences.get(entry.id) ?? 0) + 1);
2524+ }
2525+
2526+ for (const entry of entries) {
2527+ if (isTreeEntry(entry) && occurrences.get(entry.id) === 1) continue;
2528+ if (hasStringParentId(entry)) uncertainChildrenByParentId.add(entry.parentId);
2529+ }
2530+
2531+ const entriesById = new Map<string, TreeEntry>();
2532+ for (const entry of validEntries) {
2533+ if (occurrences.get(entry.id) === 1) entriesById.set(entry.id, entry);
2534+ }
2535+
2536+ const childrenByParentId = new Map<string, string[]>();
2537+ for (const entry of entriesById.values()) {
2538+ if (entry.parentId === null || !entriesById.has(entry.parentId)) continue;
2539+ const children = childrenByParentId.get(entry.parentId) ?? [];
2540+ children.push(entry.id);
2541+ childrenByParentId.set(entry.parentId, children);
2542+ }
2543+
2544+ return { entriesById, childrenByParentId, uncertainChildrenByParentId };
2545+}
2546+
2547+function collectSubtree(rootId: string, tree: TreeIndex): TreeEntry[] | undefined {
2548+ const entries: TreeEntry[] = [];
2549+ const visitedIds = new Set<string>();
2550+ const pendingIds = [rootId];
2551+
2552+ while (pendingIds.length > 0) {
2553+ const entryId = pendingIds.pop();
2554+ if (!entryId || visitedIds.has(entryId)) return undefined;
2555+ if (tree.uncertainChildrenByParentId.has(entryId)) return undefined;
2556+
2557+ const entry = tree.entriesById.get(entryId);
2558diff --git a/dot_pi/agent/extensions/pi-tree-pruning/tree-view.ts b/dot_pi/agent/extensions/pi-tree-pruning/tree-view.ts
2559new file mode 100644
2560index 0000000000000000000000000000000000000000..acf6db3e7c8dc38338135e8db22a0fb6e20d3ddb
2561--- /dev/null
2562+++ b/dot_pi/agent/extensions/pi-tree-pruning/tree-view.ts
2563@@ -0,0 +1,76 @@
2564+import type { BranchCandidate } from "./tree-model.ts";
2565+
2566+export interface PruneTreeItem {
2567+ readonly candidate: BranchCandidate;
2568+ readonly label: string | undefined;
2569+ readonly treePrefix: string;
2570+}
2571+
2572+export function buildPruneTreeItems(
2573+ candidates: readonly BranchCandidate[],
2574+ getLabel: (entryId: string) => string | undefined,
2575+): PruneTreeItem[] {
2576+ const parentByRootId = new Map<string, string>();
2577+
2578+ for (const candidate of candidates) {
2579+ const parent = findParentCandidate(candidate, candidates);
2580+ if (parent) parentByRootId.set(candidate.rootId, parent.rootId);
2581+ }
2582+
2583+ const childrenByRootId = new Map<string | undefined, BranchCandidate[]>();
2584+ for (const candidate of candidates) {
2585+ const parentRootId = parentByRootId.get(candidate.rootId);
2586+ const children = childrenByRootId.get(parentRootId) ?? [];
2587+ children.push(candidate);
2588+ childrenByRootId.set(parentRootId, children);
2589+ }
2590+
2591+ const items: PruneTreeItem[] = [];
2592+ const visit = (candidate: BranchCandidate, ancestorIsLast: readonly boolean[], isLast: boolean): void => {
2593+ const parentRootId = parentByRootId.get(candidate.rootId);
2594+ const treePrefix = parentRootId
2595+ ? `${ancestorIsLast.map((last) => (last ? " " : "│ ")).join("")}${isLast ? "└─ " : "├─ "}`
2596+ : "";
2597+
2598+ items.push({ candidate, label: getLabel(candidate.rootId), treePrefix });
2599+
2600+ const children = childrenByRootId.get(candidate.rootId) ?? [];
2601+ for (let index = 0; index < children.length; index += 1) {
2602+ const child = children[index];
2603+ if (!child) continue;
2604+ visit(child, [...ancestorIsLast, isLast], index === children.length - 1);
2605+ }
2606+ };
2607+
2608+ const roots = childrenByRootId.get(undefined) ?? [];
2609+ for (let index = 0; index < roots.length; index += 1) {
2610+ const root = roots[index];
2611+ if (!root) continue;
2612+ visit(root, [], index === roots.length - 1);
2613+ }
2614+
2615+ return items;
2616+}
2617+
2618+export function formatPruneTreeItem(item: PruneTreeItem): string {
2619+ const label = item.label?.replace(/\s+/g, " ").trim();
2620+ const preview = label ? `[${label}] ${item.candidate.preview}` : item.candidate.preview;
2621+ return `${preview} · ${formatEntryCount(item.candidate.entryCount)}`;
2622+}
2623+
2624+export function formatEntryCount(count: number): string {
2625+ return `${count} ${count === 1 ? "entry" : "entries"}`;
2626+}
2627+
2628+function findParentCandidate(
2629+ candidate: BranchCandidate,
2630+ candidates: readonly BranchCandidate[],
2631+): BranchCandidate | undefined {
2632+ return candidates
2633+ .filter(
2634+ (possibleParent) =>
2635+ possibleParent.rootId !== candidate.rootId &&
2636+ possibleParent.entryIds.includes(candidate.forkPointId),
2637+ )
2638+ .sort((left, right) => left.entryCount - right.entryCount)[0];
2639+}
2640diff --git a/dot_pi/agent/extensions/pi-tree-pruning/tsconfig.json b/dot_pi/agent/extensions/pi-tree-pruning/tsconfig.json
2641new file mode 100644
2642index 0000000000000000000000000000000000000000..86e5d35b67366531cab4135639e3eef377e6945a
2643--- /dev/null
2644+++ b/dot_pi/agent/extensions/pi-tree-pruning/tsconfig.json
2645@@ -0,0 +1,18 @@
2646+{
2647+ "compilerOptions": {
2648+ "target": "ESNext",
2649+ "module": "NodeNext",
2650+ "moduleResolution": "NodeNext",
2651+ "lib": ["ESNext"],
2652+ "allowImportingTsExtensions": true,
2653+ "noEmit": true,
2654+ "strict": true,
2655+ "exactOptionalPropertyTypes": true,
2656+ "noUncheckedIndexedAccess": true,
2657+ "noImplicitOverride": true,
2658+ "noFallthroughCasesInSwitch": true,
2659+ "skipLibCheck": true,
2660+ "types": ["node"]
2661+ },
2662+ "include": ["./*.ts", "./test/**/*.ts"]
2663+}
2664diff --git a/dot_pi/agent/policy-engine.json b/dot_pi/agent/policy-engine.json
2665index ed92c53b436019c1c9b4a625408925af79fc1d2f..194b402f907a20c562e88c02ce92ec9c1757857b 100644
2666--- a/dot_pi/agent/policy-engine.json
2667+++ b/dot_pi/agent/policy-engine.json
2668@@ -5,7 +5,8 @@
2669 "model": "reviewer"
2670 },
2671 "permission": {
2672- "edit": "allow",
2673+ "read": "allow",
2674+ "write": "allow",
2675 "bash": {
2676 "*sudo*": "deny",
2677 "terraform apply*": "deny"