pim-agent 0.10.0 → 0.12.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 (41) hide show
  1. package/package.json +3 -4
  2. package/packages/core/src/extensions/apply-patch/model.ts +1 -12
  3. package/packages/core/src/extensions/bash/index.ts +2 -1
  4. package/packages/core/src/extensions/bash/run.ts +8 -2
  5. package/packages/core/src/extensions/edit/index.ts +1 -1
  6. package/packages/core/src/extensions/subagent/index.ts +2 -1
  7. package/packages/core/src/extensions/subagent/subagent.ts +10 -1
  8. package/packages/core/src/extensions/system-prompt/index.ts +33 -24
  9. package/packages/core/src/extensions/system-prompt/prompt.ts +43 -67
  10. package/packages/core/src/session/SessionHost.ts +35 -9
  11. package/packages/core/src/session/SessionRegistry.ts +22 -1
  12. package/packages/core/src/session/SessionUi.ts +127 -0
  13. package/packages/core/src/shared/Directories.ts +39 -5
  14. package/packages/core/src/shared/ExtensionToggles.ts +8 -0
  15. package/packages/core/src/shared/PiExtensions.ts +228 -0
  16. package/packages/core/src/shared/Tools.ts +3 -3
  17. package/packages/protocol/src/Command.ts +22 -1
  18. package/packages/protocol/src/ServerEvent.ts +34 -0
  19. package/packages/server/src/ProbeClient.ts +21 -0
  20. package/packages/server/src/SessionStream.ts +228 -2
  21. package/packages/server/src/WsGateway.ts +153 -16
  22. package/packages/server/src/probe.ts +16 -0
  23. package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
  24. package/packages/web/dist/client/assets/{core-CA0aSzPu.js → core-DKI-ZG3V.js} +1 -1
  25. package/packages/web/dist/client/assets/index-BCSESkyh.css +1 -0
  26. package/packages/web/dist/client/assets/index-BWUZzdeu.js +45 -0
  27. package/packages/web/dist/client/index.html +2 -2
  28. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
  29. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
  30. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
  31. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
  32. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
  33. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
  34. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
  35. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
  36. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
  37. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
  38. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
  39. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
  40. package/packages/web/dist/client/assets/index-B9be-i9d.css +0 -1
  41. package/packages/web/dist/client/assets/index-CpinM5NK.js +0 -45
@@ -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 };
@@ -3,7 +3,7 @@ import type {
3
3
  ExtensionAPI,
4
4
  ToolDefinition,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { validateToolArguments } from "@earendil-works/pi-ai";
6
+ import { validateToolArguments, type JsonObject } from "@earendil-works/pi-ai";
7
7
  import type { Static, TSchema } from "typebox";
8
8
  import { Levenshtein } from "./Levenshtein";
9
9
  import { Renderer } from "./Renderer";
@@ -169,7 +169,7 @@ function wrap<TParams extends TSchema, TDetails = unknown, TState = unknown>(
169
169
  type: "toolCall",
170
170
  id: "",
171
171
  name: def.name,
172
- arguments: cleaned as Record<string, unknown>,
172
+ arguments: cleaned as JsonObject,
173
173
  }
174
174
  ) as Static<TParams>;
175
175
  } catch (err) {
@@ -461,7 +461,7 @@ function revalidateBranch(branch: JsonSchema, value: unknown): Issue[] {
461
461
  type: "toolCall",
462
462
  id: "",
463
463
  name: "_branch",
464
- arguments: (value ?? {}) as Record<string, unknown>,
464
+ arguments: (value ?? {}) as JsonObject,
465
465
  }
466
466
  );
467
467
  return [];
@@ -118,8 +118,19 @@ export type Command =
118
118
  }
119
119
  /** The models this server can switch to, plus the current model's thinking levels; answers without a session. */
120
120
  | { readonly id: string; readonly type: "list_models" }
121
+ /** The extensions this server can switch on and off, read against the connection's cwd when it has one. */
122
+ | { readonly id: string; readonly type: "list_extensions" }
123
+ /** Switch one extension on or off; the sessions built from here pick it up as each rebuilds its agent. */
124
+ | {
125
+ readonly id: string;
126
+ readonly type: "set_extension";
127
+ readonly extensionId: string;
128
+ readonly value: boolean;
129
+ }
121
130
  /** Subdirectories of `path` on the server's filesystem; errors rather than answering empty when it is not a readable directory. */
122
131
  | { readonly id: string; readonly type: "list_dirs"; readonly path: string }
132
+ /** Makes the directory `path` names, one level inside an existing one; answers empty, and the caller re-lists. */
133
+ | { readonly id: string; readonly type: "create_dir"; readonly path: string }
123
134
  /** Re-read the cwd's git state now; `fetch` asks the remote first, which is the only thing that moves ahead and behind. */
124
135
  | {
125
136
  readonly id: string;
@@ -195,7 +206,17 @@ export type Command =
195
206
  readonly callId: string;
196
207
  }
197
208
  /** Update this install and restart it unconditionally; refused while any session is mid-turn unless `force`. */
198
- | { readonly id: string; readonly type: "reload"; readonly force?: boolean };
209
+ | { readonly id: string; readonly type: "reload"; readonly force?: boolean }
210
+ /** Answers one `ui_request`; dismissal is `cancelled`, and a late answer to a settled request is refused. */
211
+ | {
212
+ readonly id: string;
213
+ readonly type: "ui_response";
214
+ readonly sessionId: string;
215
+ readonly requestId: string;
216
+ readonly value?: string;
217
+ readonly confirmed?: boolean;
218
+ readonly cancelled?: boolean;
219
+ };
199
220
 
200
221
  export type CommandType = Command["type"];
201
222
 
@@ -1,6 +1,7 @@
1
1
  import type { DirectoryListing } from "#core/shared/Directories";
2
2
  import type { CommitResult, GitBranch } from "#core/shared/Git";
3
3
  import type { PickerItem } from "#core/picker/PickerItem";
4
+ import type { ExtensionEntry } from "#core/shared/PiExtensions";
4
5
  import type { SearchRange, SearchSnippet } from "#core/session/SearchIndex";
5
6
  import type { LeaseFrontend } from "#core/session/SessionLease";
6
7
  import type { UpdateSkip } from "#core/shared/Updater";
@@ -154,6 +155,29 @@ export type EphemeralEvent =
154
155
  readonly events: readonly StreamEvent[];
155
156
  }
156
157
  | { readonly type: "turn_end"; readonly stats: TurnStats }
158
+ /** Something an extension said, in Markdown; shown and then forgotten, never written to the session. */
159
+ | {
160
+ readonly type: "ui_notice";
161
+ readonly id: string;
162
+ readonly severity: NoticeSeverity;
163
+ readonly text: string;
164
+ /** The command being dispatched when it was said, like `/login`; absent means nobody asked for it. */
165
+ readonly command?: string;
166
+ }
167
+ /** An extension is waiting on a human; the first `ui_response` wins and the rest are refused. */
168
+ | {
169
+ readonly type: "ui_request";
170
+ readonly requestId: string;
171
+ readonly method: "select" | "confirm" | "input";
172
+ readonly title: string;
173
+ readonly message?: string;
174
+ readonly options?: readonly string[];
175
+ readonly placeholder?: string;
176
+ /** The command that asked, like `/login`; absent where an extension asked unprompted. */
177
+ readonly command?: string;
178
+ }
179
+ /** The request is settled, by whoever answered it or by the server answering for them; drop its control. */
180
+ | { readonly type: "ui_request_done"; readonly requestId: string }
157
181
  /** Sent to every connection, not just those attached; only sessions this server holds open are reported. */
158
182
  | {
159
183
  readonly type: "session_activity";
@@ -188,6 +212,8 @@ export type EphemeralEvent =
188
212
  | { readonly type: "pins_changed"; readonly order: readonly string[] }
189
213
  /** Sent to every connection: the sessions on disk changed, so any listing a client holds is stale. */
190
214
  | { readonly type: "sessions_changed" }
215
+ /** Sent to every connection: an extension was switched, so any roster a client holds is stale. */
216
+ | { readonly type: "extensions_changed" }
191
217
  /** Sent to every connection; the restart it ends in drops every socket. */
192
218
  | UpdateStateEvent
193
219
  | {
@@ -327,6 +353,8 @@ export type ResponseEvent = {
327
353
  readonly models?: readonly ModelView[];
328
354
  /** What the *current* model supports, on the same answer. */
329
355
  readonly thinkingLevels?: readonly string[];
356
+ /** The extension roster, for `list_extensions`. */
357
+ readonly extensions?: readonly ExtensionEntry[];
330
358
  /** One directory's subdirectories, for `list_dirs`. */
331
359
  readonly directory?: DirectoryListing;
332
360
  /** The cwd's local branches, for `list_branches`. */
@@ -341,6 +369,8 @@ export type ResponseEvent = {
341
369
  readonly fileLines?: FileLines;
342
370
  /** For `cancel` and `dequeue`: queued messages pi gave back, now owned by the client that asked. */
343
371
  readonly restored?: readonly string[];
372
+ /** For `user_message`: it named an extension command, so no turn started and no entry was written. */
373
+ readonly dispatched?: boolean;
344
374
  };
345
375
 
346
376
  export type ServerEvent = DurableEvent | EphemeralEvent | ResponseEvent;
@@ -382,6 +412,9 @@ export function isAttachScoped(event: ServerEvent): boolean {
382
412
  case "subagent_events":
383
413
  case "turn_end":
384
414
  case "session_state":
415
+ case "ui_notice":
416
+ case "ui_request":
417
+ case "ui_request_done":
385
418
  // Names a cwd, but only ever reaches a client down the session stream it
386
419
  // is attached to, so the old session's is the only one that can arrive
387
420
  // mid-attach — and re-querying for it would warm the wrong cache.
@@ -393,6 +426,7 @@ export function isAttachScoped(event: ServerEvent): boolean {
393
426
  case "project_meta":
394
427
  case "pins_changed":
395
428
  case "sessions_changed":
429
+ case "extensions_changed":
396
430
  case "update_state":
397
431
  // Sent for a frame the server could not read at all, which is likeliest
398
432
  // before an attach has settled: gating it swallows the diagnostic.
@@ -2,6 +2,7 @@ import { basename } from "node:path";
2
2
 
3
3
  import type { PickerItem } from "#core/picker/PickerItem";
4
4
  import { RemoteFilePickerSuggestionEngine } from "#core/picker/RemoteFilePickerSuggestionEngine";
5
+ import type { ExtensionEntry } from "#core/shared/PiExtensions";
5
6
  import type {
6
7
  AttachmentRef,
7
8
  CommandDraft,
@@ -265,6 +266,26 @@ export class ProbeClient {
265
266
  };
266
267
  }
267
268
 
269
+ /** Every extension this server can switch, scoped to the attached session's cwd. */
270
+ public async listExtensions(): Promise<readonly ExtensionEntry[]> {
271
+ const response = await this.send({ type: "list_extensions" });
272
+ if (!response.success) {
273
+ throw new Error(response.error ?? "list_extensions failed");
274
+ }
275
+ return response.extensions ?? [];
276
+ }
277
+
278
+ public async setExtension(id: string, value: boolean): Promise<void> {
279
+ const response = await this.send({
280
+ type: "set_extension",
281
+ extensionId: id,
282
+ value,
283
+ });
284
+ if (!response.success) {
285
+ throw new Error(response.error ?? "set_extension failed");
286
+ }
287
+ }
288
+
268
289
  /** Transfers a client-local file into the server's world; only the bytes and bare filename are sent. */
269
290
  public async upload(localPath: string): Promise<UploadedFile> {
270
291
  const file = Bun.file(localPath);