@dsh-electron/dsh-plugin-multi-root-workspace 0.1.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.
@@ -0,0 +1,727 @@
1
+ import { f as resolveRootRef, i as canonicalRoot, o as entryRootRef, r as availableRoots, s as expandRootInput, t as RootValidationError } from "./roots-DyT2jgEP.js";
2
+ import { canonicalPath } from "@deepseek-ai/dsh-sandbox";
3
+ import { statSync } from "node:fs";
4
+ import "@deepseek-ai/cordis";
5
+ import { z } from "zod";
6
+ //#region src/contract.ts
7
+ /**
8
+ * The wire contract between the browser panel and this plugin's host half.
9
+ *
10
+ * It is deliberately transport-agnostic plain data: the host registers one
11
+ * Connection RPC channel (see `command.ts`) and the client bundle inlines this
12
+ * module, so both sides agree on endpoint names and payload shapes without
13
+ * sharing any runtime identity. Error codes are the stable half of the
14
+ * contract; `message` is only a fallback the panel does not have to show.
15
+ *
16
+ * Two properties this module owns:
17
+ *
18
+ * - **One shape per endpoint.** {@link PanelResponseMap} is the single mapping
19
+ * from endpoint to response type, so the panel cannot silently decode one
20
+ * endpoint's answer as another's. `reveal` answers with
21
+ * {@link RevealedView}; every other endpoint answers with a whole
22
+ * {@link RootsView}. The host builds those answers in `command.ts` and the
23
+ * panel parses them here — one definition, two consumers.
24
+ * - **Runtime validation at the boundary.** The payload arrives from a browser
25
+ * and the answer arrives from the host; neither is trusted to match the
26
+ * TypeScript types. {@link parsePanelCall} and the `parse*View` functions
27
+ * check the shapes and report structural failures as a message, so a
28
+ * mismatch becomes a visible, localized error instead of a crash or a
29
+ * silently wrong render.
30
+ *
31
+ * Nothing here may import `node:*` or `@deepseek-ai/*`: this module is inlined
32
+ * into the browser bundle.
33
+ *
34
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/contract
35
+ */
36
+ /** The absolute logical channel both halves speak over. */
37
+ const PANEL_CHANNEL = "/multi-root-workspace";
38
+ /** Every endpoint name, for exhaustive iteration in tests and callers. */
39
+ const PANEL_ENDPOINTS = [
40
+ "list",
41
+ "add",
42
+ "remove",
43
+ "alias",
44
+ "move",
45
+ "reveal"
46
+ ];
47
+ /**
48
+ * The request body on the wire. The endpoint travels as the RPC method, not as
49
+ * a field, so this schema covers the fields only; the host pairs it with the
50
+ * method the channel dispatched (and rejects the pair if the body contradicts
51
+ * it). Unknown keys are rejected rather than ignored: a client sending a field
52
+ * this host does not understand is out of contract, and silently dropping it
53
+ * would hide the drift.
54
+ */
55
+ const panelRequestSchema = z.object({
56
+ primaryRoot: z.string().optional(),
57
+ sessionId: z.string().optional(),
58
+ entry: z.object({
59
+ ordinal: z.number().int().positive(),
60
+ id: z.string(),
61
+ path: z.string(),
62
+ addedAt: z.string()
63
+ }).strict().optional(),
64
+ id: z.string().optional(),
65
+ path: z.string().optional(),
66
+ alias: z.string().optional(),
67
+ beforeEntry: z.object({
68
+ ordinal: z.number().int().positive(),
69
+ id: z.string(),
70
+ path: z.string(),
71
+ addedAt: z.string()
72
+ }).strict().optional(),
73
+ beforeId: z.string().optional()
74
+ }).strict();
75
+ /**
76
+ * One root as it travels over the channel. Deliberately NOT strict, in both
77
+ * directions: a newer host that adds a field must not brick an older panel, and
78
+ * the field that matters is the state it reports.
79
+ */
80
+ const rootViewSchema = z.object({
81
+ ordinal: z.number().int().positive(),
82
+ id: z.string(),
83
+ path: z.string(),
84
+ alias: z.string().optional(),
85
+ addedAt: z.string(),
86
+ state: z.enum([
87
+ "available",
88
+ "missing",
89
+ "redirected",
90
+ "invalid"
91
+ ]),
92
+ detail: z.string().optional()
93
+ });
94
+ const rootsViewSchema = z.object({
95
+ primaryRoot: z.string(),
96
+ primaryName: z.string().optional(),
97
+ roots: z.array(rootViewSchema),
98
+ unavailable: z.string().optional()
99
+ });
100
+ const revealedViewSchema = z.object({ revealed: z.string() });
101
+ z.object({
102
+ code: z.string(),
103
+ message: z.string(),
104
+ details: z.unknown().optional()
105
+ });
106
+ /**
107
+ * Validate the payload the host received for one endpoint.
108
+ * @param endpoint - the endpoint the channel dispatched (the RPC method).
109
+ * @param payload - the raw channel payload (the request body).
110
+ * @returns the call the host may act on, or the reason it is not one.
111
+ */
112
+ function parsePanelCall(endpoint, payload) {
113
+ if (!PANEL_ENDPOINTS.includes(endpoint)) return {
114
+ ok: false,
115
+ message: `unknown endpoint "${endpoint}"`
116
+ };
117
+ const parsed = panelRequestSchema.safeParse(payload ?? {});
118
+ if (!parsed.success) return {
119
+ ok: false,
120
+ message: `the request body is invalid: ${firstIssue(parsed.error)}`
121
+ };
122
+ return {
123
+ ok: true,
124
+ value: narrowCall(endpoint, parsed.data)
125
+ };
126
+ }
127
+ /**
128
+ * Validate a `RootsView` when it arrives.
129
+ * @param value - the raw endpoint value.
130
+ * @returns the view, or the reason it is not one.
131
+ */
132
+ function parseRootsView(value) {
133
+ const parsed = rootsViewSchema.safeParse(value);
134
+ if (!parsed.success) return {
135
+ ok: false,
136
+ message: `the answer is not a roots view: ${firstIssue(parsed.error)}`
137
+ };
138
+ const view = parsed.data;
139
+ return {
140
+ ok: true,
141
+ value: {
142
+ primaryRoot: view.primaryRoot,
143
+ ...view.primaryName === void 0 ? {} : { primaryName: view.primaryName },
144
+ roots: view.roots.map(narrowRootView),
145
+ ...view.unavailable === void 0 ? {} : { unavailable: view.unavailable }
146
+ }
147
+ };
148
+ }
149
+ /**
150
+ * Validate a `RevealedView` when it arrives.
151
+ * @param value - the raw endpoint value.
152
+ * @returns the view, or the reason it is not one.
153
+ */
154
+ function parseRevealedView(value) {
155
+ const parsed = revealedViewSchema.safeParse(value);
156
+ if (!parsed.success) return {
157
+ ok: false,
158
+ message: `the answer is not a reveal result: ${firstIssue(parsed.error)}`
159
+ };
160
+ return {
161
+ ok: true,
162
+ value: parsed.data
163
+ };
164
+ }
165
+ /**
166
+ * Rebuild a parsed call as the declared type, keeping absent optional fields
167
+ * ABSENT (`exactOptionalPropertyTypes` distinguishes "absent" from "undefined",
168
+ * and the host's own code reads the two the same way the panel sent them).
169
+ */
170
+ function narrowCall(endpoint, value) {
171
+ return {
172
+ endpoint,
173
+ ...value.primaryRoot === void 0 ? {} : { primaryRoot: value.primaryRoot },
174
+ ...value.sessionId === void 0 ? {} : { sessionId: value.sessionId },
175
+ ...value.entry === void 0 ? {} : { entry: value.entry },
176
+ ...value.id === void 0 ? {} : { id: value.id },
177
+ ...value.path === void 0 ? {} : { path: value.path },
178
+ ...value.alias === void 0 ? {} : { alias: value.alias },
179
+ ...value.beforeEntry === void 0 ? {} : { beforeEntry: value.beforeEntry },
180
+ ...value.beforeId === void 0 ? {} : { beforeId: value.beforeId }
181
+ };
182
+ }
183
+ /** Rebuild one parsed root as the declared `RootView`. */
184
+ function narrowRootView(value) {
185
+ return {
186
+ ordinal: value.ordinal,
187
+ id: value.id,
188
+ path: value.path,
189
+ addedAt: value.addedAt,
190
+ state: value.state,
191
+ ...value.alias === void 0 ? {} : { alias: value.alias },
192
+ ...value.detail === void 0 ? {} : { detail: value.detail }
193
+ };
194
+ }
195
+ /** Render the first zod issue as one short sentence. */
196
+ function firstIssue(error) {
197
+ const issue = error.issues[0];
198
+ if (issue === void 0) return "invalid";
199
+ return `${issue.path.length === 0 ? "" : `${issue.path.join(".")}: `}${issue.message}`;
200
+ }
201
+ //#endregion
202
+ //#region src/command.ts
203
+ /**
204
+ * The user-facing surface: the `/workspace-folders` command and the panel's
205
+ * RPC channel.
206
+ *
207
+ * They share one module because they are one thing — the operator-facing edge
208
+ * over `ctx.multiRootRegistry`. The command serves the text surfaces (the
209
+ * composer, headless diagnostics) and the channel serves the browser panel;
210
+ * both resolve the same canonical workspace root, call the same registry, and
211
+ * report the same `RootValidationCode` values.
212
+ *
213
+ * Both are also the two places a READ happens, so both go through the same
214
+ * revalidation entry point: `registry.refresh()` re-stats every registered
215
+ * directory, re-resolves it against the directory it was granted for, and
216
+ * republishes the scope BEFORE the list is rendered. Listing is therefore
217
+ * enough to notice that a directory disappeared (and enough to bring a
218
+ * restored one back) — which is what the panel's "Retry" button and the
219
+ * command's `list` both promise.
220
+ *
221
+ * Both halves are SOFT: the command registers only where a command registry is
222
+ * composed, and the channel only where a host Connection and web server exist
223
+ * (web), so a headless profile mounts this row with zero effect on the
224
+ * providers.
225
+ *
226
+ * The command's own text is English: a host-side command handler has no active
227
+ * locale to consult (the browser owns locale state). The panel is bilingual.
228
+ * That split is recorded in the M3 plan and the requirements as a known limit.
229
+ *
230
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/command
231
+ */
232
+ /** Services this row needs before it may activate. */
233
+ const inject = [
234
+ "commands",
235
+ "sandboxPolicy",
236
+ "multiRootRegistry"
237
+ ];
238
+ /** The command name, without the leading slash. */
239
+ const COMMAND_NAME = "workspace-folders";
240
+ /**
241
+ * Parse the text that follows `/workspace-folders`.
242
+ * @param rawInput - the verbatim `rawInput` of the command invocation.
243
+ * @returns the subcommand and its remaining text.
244
+ * @throws {RootValidationError} `invalid-ref` for an unknown subcommand.
245
+ */
246
+ function parseFoldersCommand(rawInput) {
247
+ const trimmed = rawInput.trim();
248
+ if (trimmed === "") return {
249
+ verb: "list",
250
+ rest: ""
251
+ };
252
+ const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(trimmed);
253
+ const verb = match?.[1] ?? "";
254
+ const rest = (match?.[2] ?? "").trim();
255
+ switch (verb) {
256
+ case "list":
257
+ case "add":
258
+ case "remove":
259
+ case "alias":
260
+ case "reveal":
261
+ case "help": return {
262
+ verb,
263
+ rest
264
+ };
265
+ default: throw new RootValidationError("invalid-ref", `unknown subcommand "${verb}"; try /${COMMAND_NAME} help`);
266
+ }
267
+ }
268
+ /** Strip one layer of matching quotes from an operator-supplied path. */
269
+ function unquote(text) {
270
+ if (text.length >= 2) {
271
+ const first = text[0];
272
+ const last = text[text.length - 1];
273
+ if (first === "\"" && last === "\"" || first === "'" && last === "'") return text.slice(1, -1);
274
+ }
275
+ return text;
276
+ }
277
+ /**
278
+ * Render the roots report the command returns. Additional roots are numbered
279
+ * from 1, which is the numbering every other subcommand accepts; the workspace
280
+ * root is never numbered because it cannot be removed.
281
+ * @param primaryRoot - the canonical workspace root.
282
+ * @param statuses - the registry's status list.
283
+ * @param unavailable - the store failure, when the registry could not read it.
284
+ * @returns the report text.
285
+ */
286
+ function renderRootsReport(primaryRoot, statuses, unavailable) {
287
+ const lines = [`Workspace root (primary; access follows the current sandbox mode): ${primaryRoot}`];
288
+ if (unavailable !== void 0) {
289
+ lines.push(`Root registry unavailable: ${unavailable}`);
290
+ return lines.join("\n");
291
+ }
292
+ if (statuses.length === 0) {
293
+ lines.push(`No additional roots. Add one with /${COMMAND_NAME} add <absolute path>.`);
294
+ return lines.join("\n");
295
+ }
296
+ for (const [index, status] of statuses.entries()) {
297
+ const alias = status.alias === void 0 ? "" : ` [${status.alias}]`;
298
+ const state = status.state === "available" ? "" : ` (${status.state}: ${status.detail ?? "unavailable"})`;
299
+ lines.push(` ${index + 1} ${status.path}${alias}${state}`);
300
+ }
301
+ const withheld = statuses.length - availableRoots(statuses).length;
302
+ if (withheld > 0) lines.push(`${withheld} root(s) are registered but not writable right now; restore the directory and run /${COMMAND_NAME} list, or remove the entry.`);
303
+ lines.push(`Writable additional roots: ${availableRoots(statuses).length} of ${statuses.length}.`);
304
+ return lines.join("\n");
305
+ }
306
+ /**
307
+ * Register the command and, where a host Connection exists, the panel channel.
308
+ * @param ctx - the host context carrying `commands`, `sandboxPolicy`, and the registry.
309
+ */
310
+ function apply(ctx) {
311
+ ctx.commands.register({
312
+ name: COMMAND_NAME,
313
+ description: "List, add, remove, alias, or reveal the additional workspace roots of this session",
314
+ input: { hint: "[list|add [path]|remove <n|path>|alias <n|path> [name]|reveal <n|path>|help]" },
315
+ handler: async (invocation) => await runCommand(ctx, invocation)
316
+ });
317
+ ctx.inject(["connection", "webServer"], (connectionCtx) => {
318
+ const connection = connectionCtx.root.get("connection");
319
+ if (connection === void 0) {
320
+ ctx.logger.warn("multi-root: connection resolved without the service; the panel channel is not mounted");
321
+ return;
322
+ }
323
+ const dispose = connection.rpc.handle(PANEL_CHANNEL, async (endpoint, payload, signal) => await dispatchPanelRequest(ctx, endpoint, payload, signal));
324
+ return () => {
325
+ dispose();
326
+ };
327
+ });
328
+ }
329
+ /** Execute one `/workspace-folders` invocation. */
330
+ async function runCommand(ctx, invocation) {
331
+ let parsed;
332
+ try {
333
+ parsed = parseFoldersCommand(invocation.rawInput);
334
+ } catch (error) {
335
+ return {
336
+ kind: "error",
337
+ text: failureText(error)
338
+ };
339
+ }
340
+ const primaryRoot = ctx.sandboxPolicy.resolve({ session: invocation.agent.session }).workspaceRoot;
341
+ const registry = ctx.multiRootRegistry;
342
+ try {
343
+ switch (parsed.verb) {
344
+ case "help": return {
345
+ kind: "success",
346
+ text: helpText()
347
+ };
348
+ case "list": return {
349
+ kind: "success",
350
+ text: await report(registry, primaryRoot)
351
+ };
352
+ case "add": {
353
+ const typed = unquote(parsed.rest);
354
+ const path = typed === "" ? await pickRootPath(ctx, invocation.signal) : typed;
355
+ if (path === void 0) return {
356
+ kind: "error",
357
+ text: `/${COMMAND_NAME}: no directory was selected; pass an absolute path or use the Workspace Folders panel`
358
+ };
359
+ await registry.add(primaryRoot, { path });
360
+ return {
361
+ kind: "success",
362
+ text: await report(registry, primaryRoot)
363
+ };
364
+ }
365
+ case "remove": {
366
+ const { ref } = await targetOf(registry, primaryRoot, parsed.rest);
367
+ await registry.removeAt(primaryRoot, ref);
368
+ return {
369
+ kind: "success",
370
+ text: await report(registry, primaryRoot)
371
+ };
372
+ }
373
+ case "alias": {
374
+ const { text: reference, rest: alias } = splitReference(parsed.rest);
375
+ const { ref } = await targetOf(registry, primaryRoot, reference);
376
+ await registry.setAlias(primaryRoot, ref, alias === "" ? void 0 : unquote(alias));
377
+ return {
378
+ kind: "success",
379
+ text: await report(registry, primaryRoot)
380
+ };
381
+ }
382
+ case "reveal": {
383
+ const { status: target } = await targetOf(registry, primaryRoot, parsed.rest);
384
+ await revealRoot(ctx, target.path);
385
+ return {
386
+ kind: "success",
387
+ text: `revealed ${target.path}`
388
+ };
389
+ }
390
+ }
391
+ } catch (error) {
392
+ return {
393
+ kind: "error",
394
+ text: failureText(error)
395
+ };
396
+ }
397
+ }
398
+ /**
399
+ * The current report for one primary root, after re-checking every registered
400
+ * directory. The refresh is what makes the report describe the present rather
401
+ * than the moment the process started.
402
+ */
403
+ async function report(registry, primaryRoot) {
404
+ const statuses = await registry.refresh(primaryRoot);
405
+ return renderRootsReport(canonicalRoot(primaryRoot), statuses, registry.unavailable);
406
+ }
407
+ /** Render a failure the way the composer displays it. */
408
+ function failureText(error) {
409
+ if (error instanceof RootValidationError) return `/${COMMAND_NAME}: ${error.code}: ${error.message}`;
410
+ return `/${COMMAND_NAME}: ${error instanceof Error ? error.message : String(error)}`;
411
+ }
412
+ /** The command's own help text. */
413
+ function helpText() {
414
+ return [
415
+ `/${COMMAND_NAME} — additional workspace roots of this session`,
416
+ ` /${COMMAND_NAME} list`,
417
+ ` /${COMMAND_NAME} add [absolute path] (no path opens the directory picker when one is composed)`,
418
+ ` /${COMMAND_NAME} remove <n|path>`,
419
+ ` /${COMMAND_NAME} alias <n|path> [name] (no name clears the alias)`,
420
+ ` /${COMMAND_NAME} reveal <n|path>`,
421
+ "Roots are canonicalized before they are stored: `~` expands, and a duplicate, a nested directory,",
422
+ "or this session's own workspace root is rejected."
423
+ ].join("\n");
424
+ }
425
+ /**
426
+ * Split `alias`'s argument into a reference and the alias text.
427
+ *
428
+ * The reference is the first whitespace-delimited token, except when it opens
429
+ * with a quote: a quoted path may contain spaces, so the whole quoted span is
430
+ * the reference and the remainder is the alias.
431
+ */
432
+ function splitReference(text) {
433
+ const trimmed = text.trim();
434
+ if (trimmed === "") throw new RootValidationError("invalid-ref", "a root reference is required");
435
+ if (trimmed.startsWith("\"") || trimmed.startsWith("'")) {
436
+ const quote = trimmed[0] ?? "";
437
+ const end = trimmed.indexOf(quote, 1);
438
+ if (end > 0) return {
439
+ text: trimmed.slice(0, end + 1),
440
+ rest: trimmed.slice(end + 1).trim()
441
+ };
442
+ }
443
+ const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(trimmed);
444
+ return {
445
+ text: match?.[1] ?? "",
446
+ rest: (match?.[2] ?? "").trim()
447
+ };
448
+ }
449
+ /**
450
+ * Resolve an operator reference — a 1-based number, a path, or an id — to the
451
+ * registered root it names.
452
+ * @param registry - the registry to read.
453
+ * @param primaryRoot - the workspace root the registrations belong to.
454
+ * @param text - the operator's text.
455
+ * @returns the matched status, after re-checking the registered directories.
456
+ * @throws {RootValidationError} `invalid-ref` for empty text, `not-found` when nothing matches.
457
+ */
458
+ async function targetOf(registry, primaryRoot, text) {
459
+ const trimmed = text.trim();
460
+ if (trimmed === "") throw new RootValidationError("invalid-ref", "a root reference is required");
461
+ const statuses = await registry.refresh(primaryRoot);
462
+ let status;
463
+ if (/^\d+$/.test(trimmed)) {
464
+ const ordinal = Number(trimmed);
465
+ status = resolveRootRef(statuses, {
466
+ kind: "ordinal",
467
+ ordinal
468
+ });
469
+ return {
470
+ status,
471
+ ref: entryRootRef(status, ordinal)
472
+ };
473
+ }
474
+ const asPath = unquote(trimmed);
475
+ if (asPath === "~" || asPath.startsWith("~") || asPath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(asPath)) status = resolveRootRef(statuses, {
476
+ kind: "path",
477
+ path: asPath
478
+ });
479
+ else status = resolveRootRef(statuses, {
480
+ kind: "id",
481
+ id: trimmed
482
+ });
483
+ const ordinal = statuses.indexOf(status) + 1;
484
+ return {
485
+ status,
486
+ ref: entryRootRef(status, ordinal)
487
+ };
488
+ }
489
+ /**
490
+ * Resolve the workspace root a panel request acts on.
491
+ *
492
+ * A client-supplied `primaryRoot` is accepted — the browser is the operator's
493
+ * own trusted client, and the panel names the root it displays — but it must be
494
+ * an existing directory. Otherwise the session's immutable cwd, then the
495
+ * deployment fallback, decides.
496
+ */
497
+ function primaryRootOf(ctx, request) {
498
+ if (request.primaryRoot !== void 0 && request.primaryRoot !== "") {
499
+ const canonical = canonicalRoot(expandRootInput(request.primaryRoot));
500
+ let isDirectory = false;
501
+ try {
502
+ isDirectory = statSync(canonical).isDirectory();
503
+ } catch {
504
+ isDirectory = false;
505
+ }
506
+ if (!isDirectory) throw new RootValidationError("missing", `"${request.primaryRoot}" is not an existing directory`, { reference: request.primaryRoot });
507
+ return canonical;
508
+ }
509
+ if (request.sessionId !== void 0 && request.sessionId !== "") {
510
+ const cwd = ctx.get("sessions")?.get(request.sessionId)?.header.cwd;
511
+ if (cwd !== void 0 && cwd !== "") return canonicalPath(cwd);
512
+ }
513
+ const policy = ctx.sandboxPolicy.resolve();
514
+ return canonicalRoot(policy.workspaceRoot);
515
+ }
516
+ /** Project one status into the panel's view. */
517
+ function toRootView(status, index) {
518
+ return {
519
+ ordinal: index + 1,
520
+ id: status.id,
521
+ path: status.path,
522
+ ...status.alias === void 0 ? {} : { alias: status.alias },
523
+ addedAt: status.addedAt,
524
+ state: status.state,
525
+ ...status.detail === void 0 ? {} : { detail: status.detail }
526
+ };
527
+ }
528
+ /**
529
+ * Resolve the display name of the primary root from the host's workspace
530
+ * registry — the title the operator set in the workspace browser, falling back
531
+ * to nothing (the panel then shows the path's basename).
532
+ *
533
+ * The registry is an optional sibling service, read through the explicit
534
+ * lookup like `sessions`/`subprocess`: property access would demand an
535
+ * injected dependency this row does not name. Membership wins over cwd, which
536
+ * mirrors the host's own session→workspace resolution.
537
+ */
538
+ async function primaryNameOf(ctx, primaryRoot, request) {
539
+ const registry = ctx.get("workspaceRegistry");
540
+ if (registry === void 0) return void 0;
541
+ try {
542
+ const sessionId = request.sessionId;
543
+ if (sessionId !== void 0 && sessionId !== "") {
544
+ const member = registry.list().find((workspace) => workspace.sessionIds.includes(sessionId));
545
+ if (member !== void 0) return member.title;
546
+ }
547
+ return (await registry.resolveByPath(primaryRoot))?.title;
548
+ } catch (error) {
549
+ ctx.logger.warn("multi-root: workspace title lookup failed; the panel falls back to the path basename", error);
550
+ return;
551
+ }
552
+ }
553
+ /**
554
+ * Build the panel's view of one primary root, after re-checking every
555
+ * registered directory — the panel's read path is the same revalidation the
556
+ * command uses, so "the list the operator sees" is always the list the scope
557
+ * currently enforces.
558
+ */
559
+ async function rootsViewOf(ctx, registry, primaryRoot, request) {
560
+ const statuses = await registry.refresh(primaryRoot);
561
+ const unavailable = registry.unavailable;
562
+ const primaryName = await primaryNameOf(ctx, primaryRoot, request);
563
+ return {
564
+ primaryRoot: canonicalRoot(primaryRoot),
565
+ ...primaryName === void 0 ? {} : { primaryName },
566
+ roots: statuses.map(toRootView),
567
+ ...unavailable === void 0 ? {} : { unavailable }
568
+ };
569
+ }
570
+ /**
571
+ * Serve one panel endpoint.
572
+ *
573
+ * The payload is validated before anything acts on it: it arrived from a
574
+ * browser, and the host is the side that must not be surprised by a shape it
575
+ * did not expect (see `contract.ts`, which owns the schema both halves share).
576
+ * A malformed request is reported as `panel/bad-request` rather than throwing
577
+ * through the channel.
578
+ */
579
+ async function dispatchPanelRequest(ctx, endpoint, payload, _signal) {
580
+ try {
581
+ const parsed = parsePanelCall(endpoint, payload);
582
+ if (!parsed.ok) return {
583
+ ok: false,
584
+ error: {
585
+ code: "panel/bad-request",
586
+ message: parsed.message,
587
+ details: {}
588
+ }
589
+ };
590
+ const request = parsed.value;
591
+ const registry = ctx.multiRootRegistry;
592
+ const primaryRoot = primaryRootOf(ctx, request);
593
+ switch (request.endpoint) {
594
+ case "list": return {
595
+ ok: true,
596
+ value: await rootsViewOf(ctx, registry, primaryRoot, request)
597
+ };
598
+ case "add":
599
+ if (request.path === void 0 || request.path === "") throw new RootValidationError("missing", "a directory path is required");
600
+ await registry.add(primaryRoot, {
601
+ path: request.path,
602
+ ...request.alias === void 0 ? {} : { alias: request.alias }
603
+ });
604
+ return {
605
+ ok: true,
606
+ value: await rootsViewOf(ctx, registry, primaryRoot, request)
607
+ };
608
+ case "remove":
609
+ await registry.removeAt(primaryRoot, requireEntry(request));
610
+ return {
611
+ ok: true,
612
+ value: await rootsViewOf(ctx, registry, primaryRoot, request)
613
+ };
614
+ case "alias":
615
+ await registry.setAlias(primaryRoot, requireEntry(request), request.alias);
616
+ return {
617
+ ok: true,
618
+ value: await rootsViewOf(ctx, registry, primaryRoot, request)
619
+ };
620
+ case "move":
621
+ await registry.move(primaryRoot, requireEntry(request), request.beforeEntry !== void 0 ? entryRefOf(request.beforeEntry) : request.beforeId === void 0 ? void 0 : {
622
+ kind: "id",
623
+ id: request.beforeId
624
+ });
625
+ return {
626
+ ok: true,
627
+ value: await rootsViewOf(ctx, registry, primaryRoot, request)
628
+ };
629
+ case "reveal": {
630
+ const ref = requireEntry(request);
631
+ const target = resolveRootRef(await registry.refresh(primaryRoot), ref);
632
+ await revealRoot(ctx, target.path);
633
+ return {
634
+ ok: true,
635
+ value: { revealed: target.path }
636
+ };
637
+ }
638
+ }
639
+ } catch (error) {
640
+ if (error instanceof RootValidationError) return {
641
+ ok: false,
642
+ error: {
643
+ code: error.code,
644
+ message: error.message,
645
+ details: {}
646
+ }
647
+ };
648
+ return {
649
+ ok: false,
650
+ error: {
651
+ code: "panel/internal",
652
+ message: error instanceof Error ? error.message : String(error),
653
+ details: {}
654
+ }
655
+ };
656
+ }
657
+ }
658
+ /** Convert a validated wire snapshot to the registry's exact-entry reference. */
659
+ function entryRefOf(entry) {
660
+ return {
661
+ kind: "entry",
662
+ ...entry
663
+ };
664
+ }
665
+ /** Require the exact list row a mutating panel request must carry. */
666
+ function requireEntry(request) {
667
+ if (request.entry !== void 0) return entryRefOf(request.entry);
668
+ if (request.id !== void 0 && request.id !== "") return {
669
+ kind: "id",
670
+ id: request.id
671
+ };
672
+ throw new RootValidationError("invalid-ref", "an exact root entry is required");
673
+ }
674
+ /**
675
+ * Open the composed directory picker when one exists, and answer `undefined`
676
+ * otherwise (the command then reports the missing affordance).
677
+ *
678
+ * Only the `native` capability can answer a host-side command: the `browse`
679
+ * capability is an in-app dialog that only the browser panel can render.
680
+ */
681
+ async function pickRootPath(ctx, signal) {
682
+ const picker = ctx.get("directoryPicker");
683
+ if (picker === void 0) return void 0;
684
+ const capability = picker.capability();
685
+ if (capability.kind !== "native") return void 0;
686
+ return await capability.pick(signal) ?? void 0;
687
+ }
688
+ /** The per-platform argv that reveals one directory in the OS file manager. */
689
+ function revealArgv(platform, path) {
690
+ switch (platform) {
691
+ case "darwin": return [
692
+ "open",
693
+ "-R",
694
+ path
695
+ ];
696
+ case "win32": return ["explorer", `/select,${path}`];
697
+ default: return ["xdg-open", path];
698
+ }
699
+ }
700
+ /**
701
+ * Reveal one directory in the OS file manager through `ctx.subprocess`.
702
+ *
703
+ * This runs no confined command and grants the agent nothing: it is an
704
+ * operator action taken from the panel or the command line, and the process is
705
+ * the platform's own file manager. Failures are reported to the caller.
706
+ */
707
+ async function revealRoot(ctx, path) {
708
+ const subprocess = ctx.get("subprocess");
709
+ if (subprocess === void 0) throw new RootValidationError("reveal-unavailable", "revealing a root needs a process runtime, which this composition does not mount");
710
+ const [program = "", ...args] = revealArgv(process.platform, path);
711
+ const executable = await subprocess.resolveExecutable(program);
712
+ const handle = subprocess.spawn({
713
+ argv: [executable, ...args],
714
+ cwd: path,
715
+ stdio: {
716
+ stdin: "ignore",
717
+ stdout: { maxBytes: 4096 },
718
+ stderr: { maxBytes: 4096 }
719
+ },
720
+ graceMs: 3e3
721
+ });
722
+ const outcome = await handle.done;
723
+ await handle.waitForExit();
724
+ if (outcome.exitCode !== 0) throw new RootValidationError("reveal-unavailable", `the file manager exited with ${String(outcome.exitCode)}`);
725
+ }
726
+ //#endregion
727
+ export { renderRootsReport as a, PANEL_ENDPOINTS as c, parseRootsView as d, parseFoldersCommand as i, parsePanelCall as l, apply as n, revealArgv as o, inject as r, PANEL_CHANNEL as s, COMMAND_NAME as t, parseRevealedView as u };