pim-agent 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +1 -2
  2. package/packages/core/src/extensions/bash/index.ts +2 -1
  3. package/packages/core/src/extensions/bash/run.ts +8 -2
  4. package/packages/core/src/extensions/system-prompt/index.ts +16 -1
  5. package/packages/core/src/extensions/system-prompt/prompt.ts +1 -2
  6. package/packages/core/src/session/SessionHost.ts +35 -9
  7. package/packages/core/src/session/SessionRegistry.ts +22 -1
  8. package/packages/core/src/session/SessionUi.ts +127 -0
  9. package/packages/core/src/shared/Directories.ts +39 -5
  10. package/packages/core/src/shared/ExtensionToggles.ts +8 -0
  11. package/packages/core/src/shared/PiExtensions.ts +228 -0
  12. package/packages/protocol/src/Command.ts +22 -1
  13. package/packages/protocol/src/ServerEvent.ts +34 -0
  14. package/packages/server/src/ProbeClient.ts +21 -0
  15. package/packages/server/src/SessionStream.ts +228 -2
  16. package/packages/server/src/WsGateway.ts +153 -16
  17. package/packages/server/src/probe.ts +16 -0
  18. package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
  19. package/packages/web/dist/client/assets/{core-CA0aSzPu.js → core-D3tyj0PP.js} +1 -1
  20. package/packages/web/dist/client/assets/index-CTrlTM53.js +45 -0
  21. package/packages/web/dist/client/assets/index-JnduS4Cd.css +1 -0
  22. package/packages/web/dist/client/index.html +2 -2
  23. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
  24. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
  25. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
  26. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
  27. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
  28. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
  29. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
  30. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
  31. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
  32. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
  33. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
  34. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
  35. package/packages/web/dist/client/assets/index-B9be-i9d.css +0 -1
  36. package/packages/web/dist/client/assets/index-CpinM5NK.js +0 -45
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pim-agent",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "A batteries-included distro of Pi, accessible in your terminal, browser and Telegram.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -56,7 +56,6 @@
56
56
  "web:dev": "cd packages/web && vite"
57
57
  },
58
58
  "devDependencies": {
59
- "@fontsource/commit-mono": "^5.3.0",
60
59
  "@happy-dom/global-registrator": "^20.14.0",
61
60
  "@iconify-json/griddy-icons": "^1.2.0",
62
61
  "@iconify-json/solar": "^1.2.10",
@@ -42,7 +42,8 @@ export default function (pi: ExtensionAPI): void {
42
42
  name: "bash",
43
43
  label: "bash",
44
44
  description:
45
- "Execute a bash command in the cwd. " +
45
+ "Execute a bash command in the cwd from <environment>. " +
46
+ "Never prefix `cd <cwd> &&`; pass paths relative to cwd or absolute. " +
46
47
  "Returns exit code, signal (if any), and stdout/stderr captured separately. " +
47
48
  "Stdout that is a png/jpeg/gif/webp is returned as a picture. " +
48
49
  "Prefer commands that emit only what you need; keep output as small as possible.",
@@ -14,6 +14,12 @@ type Reader = ReadableStreamDefaultReader<Uint8Array>;
14
14
 
15
15
  const activePids = new Set<number>();
16
16
 
17
+ const COMMAND_VAR = "PIM_BASH_COMMAND";
18
+ // argv is what `pkill -f`/`pgrep -f` match, so a command naming its own target would
19
+ // signal this shell before its target. The env hands it over out of argv's reach, and
20
+ // the unset keeps it out of every child's environment too.
21
+ const RUNNER = `__pim_command=$${COMMAND_VAR}; unset ${COMMAND_VAR}; eval "$__pim_command"`;
22
+
17
23
  export function killAllActiveBashGroups(sig: NodeJS.Signals = "SIGTERM"): void {
18
24
  for (const pid of activePids) {
19
25
  killGroup(pid, sig);
@@ -97,11 +103,11 @@ export async function runBashCommand(
97
103
 
98
104
  // setsid gives the tree its own process group (pgid == proc.pid) so the whole tree can be signalled.
99
105
  const proc = Bun.spawn({
100
- cmd: ["setsid", "bash", "-lc", command],
106
+ cmd: ["setsid", "bash", "-lc", RUNNER],
101
107
  cwd,
102
108
  stdout: "pipe",
103
109
  stderr: "pipe",
104
- env: { ...process.env },
110
+ env: { ...process.env, [COMMAND_VAR]: command },
105
111
  });
106
112
  if (proc.pid !== undefined) {
107
113
  activePids.add(proc.pid);
@@ -5,10 +5,25 @@ import type {
5
5
  import { formatSkillsForPrompt } from "@earendil-works/pi-coding-agent";
6
6
 
7
7
  import type { Surface } from "../../shared/Surface";
8
- import { buildSystemPrompt } from "./prompt";
8
+ import { buildSystemPrompt, formatDatetime } from "./prompt";
9
+
10
+ const DATETIME_MESSAGE_TYPE = "pim-datetime";
9
11
 
10
12
  export default function (surface?: Surface): ExtensionFactory {
11
13
  return (pi: ExtensionAPI): void => {
14
+ // On submit rather than in `before_agent_start`, which can only append
15
+ // behind the user's message.
16
+ pi.on("input", () => {
17
+ pi.sendMessage(
18
+ {
19
+ customType: DATETIME_MESSAGE_TYPE,
20
+ content: `<datetime>${formatDatetime(new Date())}</datetime>`,
21
+ display: false,
22
+ },
23
+ { triggerTurn: false }
24
+ );
25
+ return { action: "continue" };
26
+ });
12
27
  pi.on("before_agent_start", (event, ctx) => {
13
28
  const {
14
29
  cwd,
@@ -61,7 +61,6 @@ export function buildSystemPrompt(opts: BuildOptions): string {
61
61
  `- os: ${opts.os ?? describeOs()}`,
62
62
  `- model: ${model}`,
63
63
  ...(opts.surface ? [`- surface: ${opts.surface}`] : []),
64
- `- datetime: ${formatDatetime(new Date())}`,
65
64
  "</environment>",
66
65
  ].join("\n")
67
66
  );
@@ -210,7 +209,7 @@ function unquoteValue(value: string): string {
210
209
  return quote === "'" ? unquoted : unquoted.replace(/\\(["\\$`])/g, "$1");
211
210
  }
212
211
 
213
- function formatDatetime(d: Date): string {
212
+ export function formatDatetime(d: Date): string {
214
213
  const pad = (n: number): string => String(n).padStart(2, "0");
215
214
  const offsetMinutes = -d.getTimezoneOffset();
216
215
  const sign = offsetMinutes >= 0 ? "+" : "-";
@@ -15,6 +15,7 @@ import {
15
15
  SettingsManager,
16
16
  type AgentSessionEvent,
17
17
  type CompactionResult,
18
+ type ExtensionError,
18
19
  type ToolDefinition,
19
20
  } from "@earendil-works/pi-coding-agent";
20
21
  import { unlink } from "node:fs/promises";
@@ -30,6 +31,7 @@ import {
30
31
  type LeaseRecord,
31
32
  } from "./SessionLease";
32
33
  import { SessionName } from "./SessionName";
34
+ import { adaptSessionUi, type SessionUi } from "./SessionUi";
33
35
  import { WriteMark } from "./WriteMark";
34
36
 
35
37
  /** What the agent is doing right now. */
@@ -102,6 +104,13 @@ export type SessionHostDeps = {
102
104
  ) => readonly ToolDefinition[];
103
105
  /** Runs after the agent is disposed, before the session file is forgotten. */
104
106
  readonly onRetire?: (sessionPath: string) => Promise<void>;
107
+ /**
108
+ * Where this host's extensions say things, resolved per call so an agent
109
+ * rebuilt under the host follows its live target. Omit to leave pi's no-op
110
+ * UI in place: pi freezes the mode at `bindExtensions`, so this is a dep
111
+ * rather than something a caller can turn on once a session exists.
112
+ */
113
+ readonly ui?: () => SessionUi | undefined;
105
114
  };
106
115
 
107
116
  /** What a frontend's own tools are built against. */
@@ -770,16 +779,33 @@ export class SessionHost {
770
779
  ],
771
780
  });
772
781
 
773
- // Emits session_start; without it extension tools are registered but never usable.
774
- await agent.bindExtensions({
775
- mode: "print",
776
- onError: (err) => {
777
- console.warn(
778
- `[${this.label}] extension ${err.extensionPath} (${err.event}):`,
779
- err.error
782
+ const onError = (err: ExtensionError) => {
783
+ console.warn(
784
+ `[${this.label}] extension ${err.extensionPath} (${err.event}):`,
785
+ err.error
786
+ );
787
+ // Pi answers a command handler that threw as if it had succeeded, so
788
+ // without this the user's message vanishes and only stderr says why.
789
+ this.deps
790
+ .ui?.()
791
+ ?.notify(
792
+ `${err.extensionPath} (${err.event}) failed: ${err.error}`,
793
+ "error"
780
794
  );
781
- },
782
- });
795
+ };
796
+ const ui = this.deps.ui;
797
+ // Emits session_start; without it extension tools are registered but never usable.
798
+ await agent.bindExtensions(
799
+ ui
800
+ ? // Any context at all makes pi's `hasUI()` true (`uiContext !== noOpUIContext`,
801
+ // runner.js:318); "rpc" is its own name for dialog-capable but not a terminal.
802
+ {
803
+ uiContext: adaptSessionUi(ui),
804
+ mode: "rpc",
805
+ onError,
806
+ }
807
+ : { mode: "print", onError }
808
+ );
783
809
 
784
810
  return { agent, cwd };
785
811
  }
@@ -18,6 +18,7 @@ import {
18
18
  } from "./SessionHost";
19
19
  import { SessionLease } from "./SessionLease";
20
20
  import { SessionName } from "./SessionName";
21
+ import type { SessionUi } from "./SessionUi";
21
22
 
22
23
  /** A session as pi stores it: one JSONL file under a cwd-encoded directory. */
23
24
  export type SessionSummary = {
@@ -58,6 +59,7 @@ export class SessionRegistry {
58
59
  private readonly deps: SessionRegistryDeps;
59
60
  private readonly runtime: AgentRuntime;
60
61
  private readonly hosts: SessionCache<SessionHost>;
62
+ private ui: ((host: SessionHost) => SessionUi | undefined) | undefined;
61
63
 
62
64
  public constructor(deps: SessionRegistryDeps) {
63
65
  this.deps = deps;
@@ -65,10 +67,24 @@ export class SessionRegistry {
65
67
  this.hosts = new SessionCache(deps.capacity);
66
68
  }
67
69
 
70
+ /**
71
+ * Where the extensions of every host built from here speak. It belongs to the
72
+ * registry rather than each caller because `create` binds an agent eagerly,
73
+ * and pi freezes a session's UI mode at that bind. Resolved per call: a
74
+ * host outlives the stream that speaks for it.
75
+ */
76
+ public setUi(ui: (host: SessionHost) => SessionUi | undefined): void {
77
+ this.ui = ui;
78
+ }
79
+
68
80
  public get sessionsRoot(): string {
69
81
  return join(this.runtime.agentDir, "sessions");
70
82
  }
71
83
 
84
+ public get agentDir(): string {
85
+ return this.runtime.agentDir;
86
+ }
87
+
72
88
  public async init(): Promise<void> {
73
89
  await this.runtime.init();
74
90
  }
@@ -207,7 +223,10 @@ export class SessionRegistry {
207
223
  }
208
224
 
209
225
  private buildHost(label: string, settings: HostSettings): SessionHost {
210
- return new SessionHost({
226
+ const ui = this.ui;
227
+ // Named so the sink can reach back for the host it belongs to; the closure
228
+ // only ever runs once construction has returned.
229
+ const host: SessionHost = new SessionHost({
211
230
  label: `session ${label}`,
212
231
  settings,
213
232
  defaults: this.deps.defaults,
@@ -225,7 +244,9 @@ export class SessionRegistry {
225
244
  ...(this.deps.surface === undefined
226
245
  ? {}
227
246
  : { surface: this.deps.surface }),
247
+ ...(ui === undefined ? {} : { ui: () => ui(host) }),
228
248
  });
249
+ return host;
229
250
  }
230
251
  }
231
252
 
@@ -0,0 +1,127 @@
1
+ import type {
2
+ ExtensionUIContext,
3
+ Theme,
4
+ } from "@earendil-works/pi-coding-agent";
5
+
6
+ import type { NoticeSeverity } from "../view/ViewBlock";
7
+
8
+ /** The slice of pi's dialog options a surface without a terminal can honour; the rest is countdown chrome. */
9
+ export type UiAsk = {
10
+ readonly timeout?: number;
11
+ readonly signal?: AbortSignal;
12
+ };
13
+
14
+ /** Where an extension's own words go. Four of pi's thirty methods: the ones that address a human rather than a terminal. */
15
+ export type SessionUi = {
16
+ readonly notify: (text: string, severity: NoticeSeverity) => void;
17
+ readonly select: (
18
+ title: string,
19
+ options: readonly string[],
20
+ opts?: UiAsk
21
+ ) => Promise<string | undefined>;
22
+ readonly confirm: (
23
+ title: string,
24
+ message: string,
25
+ opts?: UiAsk
26
+ ) => Promise<boolean>;
27
+ readonly input: (
28
+ title: string,
29
+ placeholder?: string,
30
+ opts?: UiAsk
31
+ ) => Promise<string | undefined>;
32
+ };
33
+
34
+ /** Everything off the terminal paints with; also what pi's border getters hand back. */
35
+ const IDENTITY = (text: string): string => text;
36
+
37
+ /**
38
+ * pi keeps its own `theme` instance to itself, and nothing off the terminal has
39
+ * colours to paint with. `satisfies` covers the whole public surface — `keyof`
40
+ * skips the private fields the cast is for — so a method pi adds is a type
41
+ * error here rather than a throw inside somebody's event handler.
42
+ */
43
+ const PLAIN_THEME = {
44
+ fg: (_color: string, text: string) => text,
45
+ bg: (_color: string, text: string) => text,
46
+ bold: IDENTITY,
47
+ italic: IDENTITY,
48
+ underline: IDENTITY,
49
+ inverse: IDENTITY,
50
+ strikethrough: IDENTITY,
51
+ getFgAnsi: () => "",
52
+ getBgAnsi: () => "",
53
+ getColorMode: () => "truecolor",
54
+ getThinkingBorderColor: () => IDENTITY,
55
+ getBashModeBorderColor: () => IDENTITY,
56
+ } satisfies { [K in keyof Theme]: Theme[K] } as unknown as Theme;
57
+
58
+ function severityOf(
59
+ kind: Parameters<ExtensionUIContext["notify"]>[1]
60
+ ): NoticeSeverity {
61
+ return kind === "warning" ? "warn" : (kind ?? "info");
62
+ }
63
+
64
+ /** A dialog an extension awaits goes unanswered rather than ever throwing at it. */
65
+ async function settle<T>(
66
+ ask: () => Promise<T> | undefined
67
+ ): Promise<T | undefined> {
68
+ try {
69
+ return await ask();
70
+ } catch (err) {
71
+ console.warn("[SessionUi] dialog sink failed:", err);
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Widens the sink onto pi's `ExtensionUIContext`, no-opping everything a
78
+ * terminal owns exactly as pi's own RPC mode does. `sink` is read per call, so
79
+ * an agent rebuilt under a host keeps whatever its host was last handed.
80
+ *
81
+ * Every method has to be an *own* property: pi re-wraps what it is given with
82
+ * `{ ...ui }` (`runner.js:274`), which copies own enumerable keys only. A class
83
+ * instance would arrive with `notify` and the rest of the prototype missing.
84
+ */
85
+ export function adaptSessionUi(
86
+ sink: () => SessionUi | undefined
87
+ ): ExtensionUIContext {
88
+ return {
89
+ select: (title, options, opts) =>
90
+ settle(() => sink()?.select(title, options, opts)),
91
+ confirm: async (title, message, opts) =>
92
+ (await settle(() => sink()?.confirm(title, message, opts))) === true,
93
+ input: (title, placeholder, opts) =>
94
+ settle(() => sink()?.input(title, placeholder, opts)),
95
+ notify: (message, kind) => {
96
+ try {
97
+ sink()?.notify(message, severityOf(kind));
98
+ } catch (err) {
99
+ console.warn("[SessionUi] notify sink failed:", err);
100
+ }
101
+ },
102
+ onTerminalInput: () => () => {},
103
+ setStatus: () => {},
104
+ setWorkingMessage: () => {},
105
+ setWorkingVisible: () => {},
106
+ setWorkingIndicator: () => {},
107
+ setHiddenThinkingLabel: () => {},
108
+ setWidget: () => {},
109
+ setFooter: () => {},
110
+ setHeader: () => {},
111
+ setTitle: () => {},
112
+ custom: async <T>() => undefined as T,
113
+ pasteToEditor: () => {},
114
+ setEditorText: () => {},
115
+ getEditorText: () => "",
116
+ editor: async () => undefined,
117
+ addAutocompleteProvider: () => {},
118
+ setEditorComponent: () => {},
119
+ getEditorComponent: () => undefined,
120
+ theme: PLAIN_THEME,
121
+ getAllThemes: () => [],
122
+ getTheme: () => undefined,
123
+ setTheme: () => ({ success: false, error: "UI not available" }),
124
+ getToolsExpanded: () => false,
125
+ setToolsExpanded: () => {},
126
+ };
127
+ }
@@ -1,5 +1,5 @@
1
- import { readdir, stat } from "node:fs/promises";
2
- import { dirname, isAbsolute, join, resolve } from "node:path";
1
+ import { mkdir, readdir, stat } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
3
 
4
4
  import { FsErrors } from "./FsErrors";
5
5
  import { Paths } from "./Paths";
@@ -29,12 +29,16 @@ async function check(path: string): Promise<string | undefined> {
29
29
  }
30
30
  }
31
31
 
32
- async function list(path: string): Promise<DirectoryListing> {
32
+ function rooted(path: string): string {
33
33
  const expanded = Paths.expandHome(path);
34
34
  if (!isAbsolute(expanded)) {
35
35
  throw new Error(`not an absolute path: ${path}`);
36
36
  }
37
- const resolved = resolve(expanded);
37
+ return resolve(expanded);
38
+ }
39
+
40
+ async function list(path: string): Promise<DirectoryListing> {
41
+ const resolved = rooted(path);
38
42
  const reason = await check(resolved);
39
43
  if (reason !== undefined) {
40
44
  throw new Error(reason);
@@ -58,4 +62,34 @@ async function list(path: string): Promise<DirectoryListing> {
58
62
  };
59
63
  }
60
64
 
61
- export const Directories = { check, list };
65
+ function refusal(error: unknown, path: string): string {
66
+ switch (FsErrors.code(error)) {
67
+ case "ENOENT":
68
+ return `path does not exist: ${dirname(path)}`;
69
+ case "EEXIST":
70
+ return `already exists: ${path}`;
71
+ case "ENOTDIR":
72
+ return `not a directory: ${dirname(path)}`;
73
+ case "EACCES":
74
+ case "EPERM":
75
+ return `permission denied: ${path}`;
76
+ default:
77
+ return `mkdir failed: ${(error as Error).message}`;
78
+ }
79
+ }
80
+
81
+ /** Makes one directory inside an existing one. Never recursive: a parent that is missing is said so, not invented. */
82
+ async function create(path: string): Promise<void> {
83
+ const name = basename(Paths.expandHome(path));
84
+ if (name === "" || name === "." || name === "..") {
85
+ throw new Error(`not a directory to create: ${path}`);
86
+ }
87
+ const resolved = rooted(path);
88
+ try {
89
+ await mkdir(resolved);
90
+ } catch (error) {
91
+ throw new Error(refusal(error, resolved));
92
+ }
93
+ }
94
+
95
+ export const Directories = { check, list, create };
@@ -34,6 +34,13 @@ const DEFAULT_DISABLED: readonly PimExtensionName[] = ["todo", "tps"];
34
34
 
35
35
  const REQUIRED: readonly PimExtensionName[] = ["_init", "pim", "session-lease"];
36
36
 
37
+ // Hardcoded, never derived: the browser switches these and nothing else, under
38
+ // the label it gives them — the terminal menu names them by key, this does not.
39
+ const WEB = [{ name: "todo", label: "Todo Tool" }] as const satisfies readonly {
40
+ name: PimExtensionName;
41
+ label: string;
42
+ }[];
43
+
37
44
  function isRequired(name: string): boolean {
38
45
  return (REQUIRED as readonly string[]).includes(name);
39
46
  }
@@ -119,6 +126,7 @@ function enabled(
119
126
  export const ExtensionToggles = {
120
127
  REQUIRED,
121
128
  NAMES,
129
+ WEB,
122
130
  isRequired,
123
131
  isKnown,
124
132
  describe,
@@ -0,0 +1,228 @@
1
+ import {
2
+ DefaultPackageManager,
3
+ type PackageSource,
4
+ type ResolvedResource,
5
+ SettingsManager,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { basename, dirname, extname, relative } from "node:path";
8
+
9
+ import { ExtensionToggles } from "./ExtensionToggles";
10
+ import { Fs } from "./Fs";
11
+ import { Paths } from "./Paths";
12
+
13
+ /**
14
+ * Every origin an entry comes from, in the order a reader meets them: the
15
+ * roster is sorted by it, and a pane groups what it is handed without
16
+ * restating it.
17
+ */
18
+ const EXTENSION_GROUPS = ["pim", "package", "user", "project"] as const;
19
+
20
+ export type ExtensionGroup = (typeof EXTENSION_GROUPS)[number];
21
+
22
+ export type ExtensionEntry = {
23
+ readonly id: string;
24
+ readonly label: string;
25
+ readonly group: ExtensionGroup;
26
+ readonly enabled: boolean;
27
+ /** Whether this process will write the toggle; a project-scoped entry is listed but not switched. */
28
+ readonly writable: boolean;
29
+ };
30
+
31
+ export type ExtensionScope = {
32
+ readonly cwd: string;
33
+ readonly agentDir: string;
34
+ };
35
+
36
+ type Resolution = {
37
+ readonly extensions: readonly ResolvedResource[];
38
+ readonly settingsManager: SettingsManager;
39
+ };
40
+
41
+ type PackageFilter = Exclude<PackageSource, string>;
42
+
43
+ const FILTER_KEYS = ["extensions", "skills", "prompts", "themes"] as const;
44
+
45
+ const writes = Fs.serialised();
46
+
47
+ async function list(scope: ExtensionScope): Promise<readonly ExtensionEntry[]> {
48
+ const [off, { extensions }] = await Promise.all([
49
+ ExtensionToggles.disabled(),
50
+ resolve(scope),
51
+ ]);
52
+ const disabled = new Set(off);
53
+ const pim = ExtensionToggles.WEB.map((allowed) =>
54
+ pimEntry(allowed, !disabled.has(allowed.name))
55
+ );
56
+ const pi = extensions
57
+ .filter((resource) => resource.metadata.scope !== "temporary")
58
+ .map(piEntry)
59
+ .sort(byGroupThenLabel);
60
+ return [...pim, ...pi];
61
+ }
62
+
63
+ async function setEnabled(
64
+ id: string,
65
+ enabled: boolean,
66
+ scope: ExtensionScope
67
+ ): Promise<void> {
68
+ const pim = ExtensionToggles.WEB.find((entry) => `pim:${entry.name}` === id);
69
+ if (pim) {
70
+ await ExtensionToggles.setDisabled(pim.name, !enabled);
71
+ return;
72
+ }
73
+ if (!id.startsWith("pi:")) {
74
+ throw new Error(`Unknown extension "${id}"`);
75
+ }
76
+ await writes.run(() => writePiToggle(id.slice("pi:".length), enabled, scope));
77
+ }
78
+
79
+ // A fresh SettingsManager per write: `setExtensionPaths` persists its in-memory
80
+ // copy, which another process may have superseded since we last read it.
81
+ async function writePiToggle(
82
+ path: string,
83
+ enabled: boolean,
84
+ scope: ExtensionScope
85
+ ): Promise<void> {
86
+ const { extensions, settingsManager } = await resolve(scope);
87
+ const resource = extensions.find((candidate) => candidate.path === path);
88
+ if (!resource) {
89
+ throw new Error(`Unknown extension "pi:${path}"`);
90
+ }
91
+ const { metadata } = resource;
92
+ if (metadata.scope !== "user") {
93
+ throw new Error(
94
+ `"${path}" is ${metadata.scope}-scoped and cannot be switched from here`
95
+ );
96
+ }
97
+ if (metadata.origin === "package") {
98
+ togglePackage(
99
+ settingsManager,
100
+ metadata.source,
101
+ relative(metadata.baseDir ?? dirname(path), path),
102
+ enabled
103
+ );
104
+ } else {
105
+ toggleTopLevel(
106
+ settingsManager,
107
+ relative(metadata.baseDir ?? scope.agentDir, path),
108
+ enabled
109
+ );
110
+ }
111
+ await settingsManager.flush();
112
+ const failure = settingsManager.drainErrors()[0];
113
+ if (failure) {
114
+ throw new Error(`Failed to write pi settings: ${failure.error.message}`);
115
+ }
116
+ }
117
+
118
+ function toggleTopLevel(
119
+ settingsManager: SettingsManager,
120
+ pattern: string,
121
+ enabled: boolean
122
+ ): void {
123
+ const current = settingsManager.getGlobalSettings().extensions ?? [];
124
+ settingsManager.setExtensionPaths(withPattern(current, pattern, enabled));
125
+ }
126
+
127
+ function togglePackage(
128
+ settingsManager: SettingsManager,
129
+ source: string,
130
+ pattern: string,
131
+ enabled: boolean
132
+ ): void {
133
+ const packages = [...(settingsManager.getGlobalSettings().packages ?? [])];
134
+ const index = packages.findIndex((entry) => sourceOf(entry) === source);
135
+ const entry = packages[index];
136
+ if (entry === undefined) {
137
+ throw new Error(`No configured package provides "${source}"`);
138
+ }
139
+ const pkg: PackageFilter =
140
+ typeof entry === "string" ? { source: entry } : { ...entry };
141
+ const updated = withPattern(pkg.extensions ?? [], pattern, enabled);
142
+ pkg.extensions = updated.length > 0 ? updated : undefined;
143
+ packages[index] = FILTER_KEYS.some((key) => pkg[key] !== undefined)
144
+ ? pkg
145
+ : pkg.source;
146
+ settingsManager.setPackages(packages);
147
+ }
148
+
149
+ function withPattern(
150
+ current: readonly string[],
151
+ pattern: string,
152
+ enabled: boolean
153
+ ): string[] {
154
+ const kept = current.filter((entry) => stripMarker(entry) !== pattern);
155
+ return [...kept, `${enabled ? "+" : "-"}${pattern}`];
156
+ }
157
+
158
+ function stripMarker(entry: string): string {
159
+ return /^[!+-]/.test(entry) ? entry.slice(1) : entry;
160
+ }
161
+
162
+ function sourceOf(entry: PackageSource): string {
163
+ return typeof entry === "string" ? entry : entry.source;
164
+ }
165
+
166
+ async function resolve(scope: ExtensionScope): Promise<Resolution> {
167
+ const { cwd, agentDir } = scope;
168
+ const settingsManager = SettingsManager.create(cwd, agentDir);
169
+ const manager = new DefaultPackageManager({
170
+ cwd,
171
+ agentDir,
172
+ settingsManager,
173
+ });
174
+ // Without an answer, pi npm-installs every missing or drifted package, so a
175
+ // bare listing request would fire off an install.
176
+ const resolved = await manager.resolve(async () => "skip");
177
+ return { extensions: resolved.extensions, settingsManager };
178
+ }
179
+
180
+ function pimEntry(
181
+ allowed: (typeof ExtensionToggles.WEB)[number],
182
+ enabled: boolean
183
+ ): ExtensionEntry {
184
+ return {
185
+ id: `pim:${allowed.name}`,
186
+ label: allowed.label,
187
+ group: "pim",
188
+ enabled,
189
+ writable: true,
190
+ };
191
+ }
192
+
193
+ function piEntry(resource: ResolvedResource): ExtensionEntry {
194
+ const { path, metadata } = resource;
195
+ const fromPackage = metadata.origin === "package";
196
+ const group = fromPackage
197
+ ? "package"
198
+ : metadata.scope === "project"
199
+ ? "project"
200
+ : "user";
201
+ return {
202
+ id: `pi:${path}`,
203
+ label: fromPackage
204
+ ? metadata.source.replace(/^(?:npm|git):/, "")
205
+ : fileLabel(path),
206
+ group,
207
+ enabled: resource.enabled,
208
+ // Scope, not group: a package a project configures is written to the repo's
209
+ // settings, which a browser toggle never touches.
210
+ writable: metadata.scope === "user",
211
+ };
212
+ }
213
+
214
+ function fileLabel(path: string): string {
215
+ const base = basename(path, extname(path));
216
+ return base === "index" ? basename(dirname(path)) : base;
217
+ }
218
+
219
+ function byGroupThenLabel(left: ExtensionEntry, right: ExtensionEntry): number {
220
+ const byGroup =
221
+ EXTENSION_GROUPS.indexOf(left.group) -
222
+ EXTENSION_GROUPS.indexOf(right.group);
223
+ return byGroup === 0
224
+ ? Paths.compare(left.label.toLowerCase(), right.label.toLowerCase())
225
+ : byGroup;
226
+ }
227
+
228
+ export const PiExtensions = { list, setEnabled };