@achasoft/dsh-advanced-sidebar 0.1.0 → 0.3.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 (38) hide show
  1. package/README.md +279 -128
  2. package/cordis.patch.yml +31 -3
  3. package/lib/client.js +2803 -466
  4. package/lib/client.js.map +1 -1
  5. package/lib/host.js +2071 -418
  6. package/lib/index.js +6 -2
  7. package/lib/preview-content-BVUQ5oOR.js +465 -0
  8. package/lib/remote.js +330 -25
  9. package/lib/typert.host.js +330 -25
  10. package/lib/ui-preview.js +352 -0
  11. package/package.json +8 -2
  12. package/types/client/ActionMenu.d.ts +16 -1
  13. package/types/client/LogDownloadDialog.d.ts +24 -0
  14. package/types/client/contract.d.ts +57 -1
  15. package/types/client/index.d.ts +4 -2
  16. package/types/client/locales.d.ts +100 -0
  17. package/types/client/log-download.d.ts +179 -0
  18. package/types/client/panels/PreviewPanel.d.ts +20 -15
  19. package/types/client/panels/preview-file.d.ts +61 -0
  20. package/types/client/panels/preview-mode.d.ts +67 -0
  21. package/types/client/panels/preview-scratchpad.d.ts +53 -0
  22. package/types/client/panels/preview-url.d.ts +17 -0
  23. package/types/client/panels/shared.d.ts +15 -2
  24. package/types/client/preview-driver.d.ts +121 -0
  25. package/types/client/preview-storage.d.ts +43 -0
  26. package/types/client/preview-types.d.ts +21 -0
  27. package/types/client/preview-values.d.ts +43 -0
  28. package/types/host/deletion.d.ts +32 -23
  29. package/types/host/git.d.ts +94 -8
  30. package/types/host/index.d.ts +97 -5
  31. package/types/host/preview-content.d.ts +179 -0
  32. package/types/host/preview-serve.d.ts +242 -0
  33. package/types/host/settings-section.d.ts +49 -0
  34. package/types/host/types.d.ts +341 -0
  35. package/types/host/ui-bridge.d.ts +197 -0
  36. package/types/host/ui-preview-tool.d.ts +60 -0
  37. package/types/index.d.ts +6 -2
  38. package/types/ui-preview.d.ts +11 -0
package/lib/host.js CHANGED
@@ -1,22 +1,100 @@
1
+ import { a as contentTypeOf, c as fileUrl, f as proxyUrlFor, h as resolveWorkspace, i as classifyFile, l as injectBase, m as resolveInside, n as PROXY_ROUTE, o as decodeText, p as validateProxyTarget, r as SCRATCHPAD_ROUTE, s as encodeQuery, t as FILE_ROUTE, u as isTextual } from "./preview-content-BVUQ5oOR.js";
1
2
  import z from "@deepseek-ai/schemastery";
2
- import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
3
3
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
4
- import { rm } from "node:fs/promises";
5
4
  import { dirname, isAbsolute, join, resolve } from "node:path";
6
5
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
7
- import { randomUUID } from "node:crypto";
6
+ import { createHash, randomUUID } from "node:crypto";
8
7
  import { connect } from "node:net";
8
+ import { request } from "node:http";
9
+ import { request as request$1 } from "node:https";
9
10
  import { JobId } from "@deepseek-ai/dsh-jobs";
10
11
 
12
+ //#region tsbuild/host/settings-section.js
13
+ /**
14
+ * Attaching this plugin's settings section to the harness settings provider.
15
+ *
16
+ * Harness 0.1.1-rc.2 and earlier exported two free helpers from `@deepseek-ai/dsh-settings` for
17
+ * this, `installSettingsSection` and `settingsNamespace`. Current harnesses removed both: the same
18
+ * wiring is the provider's own `settings.installSection(owner, ns, schema, entry, hooks)`, reached
19
+ * through `ctx.inject(['settings'], …)`. Importing the old helpers by name fails to link on a
20
+ * current install, so the plugin carries these two small equivalents instead of depending on a
21
+ * compatibility shim that only exists on a development machine.
22
+ *
23
+ * Injecting `settings` rather than requiring it keeps the plugin loadable in a deployment that
24
+ * mounts no settings provider: the section simply never attaches, and the composition entry the
25
+ * plugin was configured with stays the source.
26
+ * @module @achasoft/dsh-advanced-sidebar/host/settings-section
27
+ */
28
+ /** The provider's namespace grammar: a lowercase, hyphenated identifier. */
29
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/u;
30
+ /**
31
+ * Check a settings namespace against the provider's grammar, failing at load rather than at attach.
32
+ * @param value - the namespace.
33
+ * @returns the same namespace.
34
+ * @throws TypeError when it is not a lowercase hyphenated identifier.
35
+ */
36
+ function settingsNamespace(value) {
37
+ if (!NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
38
+ return value;
39
+ }
40
+ /**
41
+ * Attach one settings section whenever a settings provider is present.
42
+ * @param ctx - the owning plugin context; its unload detaches the section.
43
+ * @param ns - the plugin's settings namespace.
44
+ * @param schema - schema resolving the section.
45
+ * @param entry - the composition entry, used as the base layer and as the fallback without a provider.
46
+ * @param hooks - source sink, change notification, and optional validation.
47
+ */
48
+ function installSettingsSection(ctx, ns, schema, entry, hooks) {
49
+ ctx.inject(["settings"], (settingsCtx) => {
50
+ settingsCtx.settings.installSection(ctx, ns, schema, entry, hooks);
51
+ });
52
+ }
53
+
54
+ //#endregion
11
55
  //#region tsbuild/host/deletion.js
12
56
  /**
57
+ * Session deletion.
58
+ *
59
+ * No harness capability deletes a session. Delete is therefore the one thing that does exist — the
60
+ * workspace registry's ARCHIVE, which hides a session while keeping its log and its accounting slot —
61
+ * and it is honest that this is all it did.
62
+ *
63
+ * `deleteMode: 'purge'` asked for more: the backend's own per-session artifact removed as well. On
64
+ * the harness this release targets (`0.1.5-rc.2`) that cannot be done correctly, and so it is not
65
+ * attempted:
66
+ *
67
+ * - The published `SessionPersistence` contract is `create`, `open`, `flush`, `stat` and `list`. It
68
+ * has no removal verb and no way to ask where a session's bytes live; the `supportsRawArtifacts`
69
+ * flag and `locate()` this module used to read were never part of it, so purging was silently
70
+ * impossible while the settings still offered it.
71
+ * - The JSONL backend keeps a session as a DIRECTORY — one immutable file per format generation plus
72
+ * a `session.lock` write lease — behind an in-process cold-log memo, and the workspace registry and
73
+ * the session projection cache index its header. Unlinking files underneath all of that bypasses the
74
+ * lease another process may hold, leaves stale caches answering for a log that is gone, and is the
75
+ * same class of out-of-band edit that produces "torn record" corruption reports.
76
+ *
77
+ * So the capability is reported as unavailable with that reason, the settings card shows the reason
78
+ * and refuses to select `purge`, and a composition that still configures it gets an archive whose
79
+ * result says, every time, why nothing was removed. When the harness grows a supported removal verb,
80
+ * this is the one module to change.
81
+ * @module @achasoft/dsh-advanced-sidebar/host/deletion
82
+ */
83
+ /**
84
+ * Why `purge` is unavailable, in the words the settings card and the Delete result show.
85
+ *
86
+ * One sentence for both, so the reason a person reads before choosing a mode is the reason they read
87
+ * after pressing Delete.
88
+ */
89
+ const PURGE_UNAVAILABLE_REASON = "this harness's session persistence has no supported way to remove a session log, so Delete archives the session and keeps its log";
90
+ /**
13
91
  * Commits Delete for the sidebar menu. Stateless apart from the context and settings it reads.
14
92
  */
15
93
  var SessionDeleter = class {
16
94
  ctx;
17
95
  source;
18
96
  /**
19
- * @param ctx - Host context carrying the workspace registry and session persistence.
97
+ * @param ctx - Host context carrying the workspace registry.
20
98
  * @param source - reads the current settings section; called per request.
21
99
  */
22
100
  constructor(ctx, source) {
@@ -25,27 +103,20 @@ var SessionDeleter = class {
25
103
  }
26
104
  /**
27
105
  * Report whether the durable log can be removed at all.
28
- * @returns the capability, with a reason when purging is impossible.
106
+ * @returns the capability, with the reason purging is impossible.
29
107
  */
30
108
  describe() {
31
- const persistence = this.ctx.get("sessionPersistence");
32
- if (persistence === void 0) return {
33
- canPurge: false,
34
- reason: "no session-persistence backend is mounted; nothing is written to remove"
35
- };
36
- if (!persistence.supportsRawArtifacts) return {
109
+ return {
37
110
  canPurge: false,
38
- reason: "this session-persistence backend keeps no per-session artifact, so Delete can only archive"
111
+ reason: PURGE_UNAVAILABLE_REASON
39
112
  };
40
- return { canPurge: true };
41
113
  }
42
114
  /**
43
- * Hide one session, and remove its durable artifact when the mode and the Host allow it.
115
+ * Hide one session, and say plainly when a requested purge did not happen.
44
116
  * @param request - the session to delete.
45
- * @param signal - cancellation for the persistence listing.
46
117
  * @returns what was actually done, or a classified failure.
47
118
  */
48
- async delete(request, signal) {
119
+ async delete(request$2) {
49
120
  const settings = this.source();
50
121
  if (!settings.showDelete) return {
51
122
  ok: false,
@@ -58,10 +129,8 @@ var SessionDeleter = class {
58
129
  code: "no-registry",
59
130
  message: "no workspace registry is mounted"
60
131
  };
61
- const sessionId = request.sessionId;
62
- const artifact = settings.deleteMode === "purge" ? await this.locateArtifact(sessionId, signal) : void 0;
63
132
  try {
64
- await registry.archiveSession(sessionId);
133
+ await registry.archiveSession(request$2.sessionId);
65
134
  } catch (error) {
66
135
  return {
67
136
  ok: false,
@@ -69,168 +138,23 @@ var SessionDeleter = class {
69
138
  message: error instanceof Error ? error.message : String(error)
70
139
  };
71
140
  }
72
- if (settings.deleteMode === "archive") return {
73
- ok: true,
74
- archived: true,
75
- purged: false
76
- };
77
- if (artifact === void 0 || "reason" in artifact) return {
78
- ok: true,
79
- archived: true,
80
- purged: false,
81
- purgeSkippedReason: artifact?.reason ?? this.describe().reason ?? "the session has no materialized artifact to remove"
82
- };
83
- try {
84
- await rm(artifact.path, { force: true });
85
- } catch (error) {
86
- return {
87
- ok: false,
88
- code: "remove-failed",
89
- message: `archived, but ${artifact.path} could not be removed: ${error instanceof Error ? error.message : String(error)}`
141
+ switch (settings.deleteMode) {
142
+ case "archive": return {
143
+ ok: true,
144
+ archived: true,
145
+ purged: false
90
146
  };
147
+ case "purge": return {
148
+ ok: true,
149
+ archived: true,
150
+ purged: false,
151
+ purgeSkippedReason: PURGE_UNAVAILABLE_REASON
152
+ };
153
+ default: throw new TypeError(`advanced-sidebar: unexpected deleteMode ${JSON.stringify(settings.deleteMode)}`);
91
154
  }
92
- return {
93
- ok: true,
94
- archived: true,
95
- purged: true,
96
- artifactPath: artifact.path
97
- };
98
- }
99
- /**
100
- * Find the backend artifact for one session, or say why it will not be removed.
101
- * @param sessionId - the session to look up.
102
- * @param signal - cancellation for the persistence listing.
103
- * @returns the artifact path, or the reason purging is skipped.
104
- */
105
- async locateArtifact(sessionId, signal) {
106
- const capability = this.describe();
107
- if (!capability.canPurge) return { reason: capability.reason ?? "purging is unavailable on this Host" };
108
- if (this.ctx.agents.get(sessionId) !== void 0) return { reason: "the session is live, so its log was kept; delete it again after it stops" };
109
- const persistence = this.ctx.sessionPersistence;
110
- let headers;
111
- try {
112
- headers = await persistence.list(signal);
113
- } catch (error) {
114
- return { reason: `the durable session list could not be read: ${error instanceof Error ? error.message : String(error)}` };
115
- }
116
- const header = headers.find((entry) => entry.id === sessionId);
117
- if (header === void 0) return { reason: "the session has no durable log on this Host" };
118
- const location = persistence.locate(header);
119
- if (location === void 0) return { reason: "this backend keeps no per-session artifact for that session" };
120
- return { path: location.path };
121
155
  }
122
156
  };
123
157
 
124
- //#endregion
125
- //#region tsbuild/host/paths.js
126
- /**
127
- * Path resolution and containment for every endpoint that takes a path from the browser.
128
- *
129
- * A browser-supplied path is untrusted input at a process boundary, so each one is resolved through
130
- * `ctx.fs` and proved to sit inside the workspace it claims to belong to before any command, read,
131
- * or launch sees it. `..` and symlinks are handled by the filesystem's own canonicalization rather
132
- * than by string arithmetic here.
133
- * @module @achasoft/dsh-advanced-sidebar/host/paths
134
- */
135
- /**
136
- * Resolve one absolute directory as a workspace root.
137
- * @param ctx - Host context carrying the optional filesystem capability.
138
- * @param path - absolute directory path supplied by the browser.
139
- * @param signal - cancellation for the backend round-trip.
140
- * @returns the canonical directory, or the reason it was refused.
141
- */
142
- async function resolveWorkspace(ctx, path, signal) {
143
- const fs = ctx.get("fs");
144
- if (fs === void 0) return {
145
- ok: false,
146
- rejection: {
147
- code: "no-filesystem",
148
- message: "no filesystem capability is mounted: this deployment composes no @deepseek-ai/dsh-fs provider"
149
- }
150
- };
151
- let target;
152
- try {
153
- target = await fs.resolve(path, signal === void 0 ? {} : { signal });
154
- } catch (error) {
155
- return {
156
- ok: false,
157
- rejection: {
158
- code: "path-denied",
159
- message: describe(error, path)
160
- }
161
- };
162
- }
163
- const info = await fs.stat(target, signal);
164
- if (info === void 0 || info.type !== "directory") return {
165
- ok: false,
166
- rejection: {
167
- code: "path-denied",
168
- message: `${path} is not a directory`
169
- }
170
- };
171
- return {
172
- ok: true,
173
- value: {
174
- target,
175
- processPath: fs.processPath(target)
176
- }
177
- };
178
- }
179
- /**
180
- * Resolve one path and prove it sits inside an already-resolved workspace.
181
- * @param ctx - Host context carrying the optional filesystem capability.
182
- * @param workspace - the canonical workspace directory the path must stay within.
183
- * @param path - absolute path supplied by the browser.
184
- * @param signal - cancellation for the backend round-trip.
185
- * @returns the canonical path, or the reason it was refused.
186
- */
187
- async function resolveInside(ctx, workspace, path, signal) {
188
- const fs = ctx.get("fs");
189
- /* v8 ignore next 4 -- the caller resolved `workspace` through the same service moments earlier. */
190
- if (fs === void 0) return {
191
- ok: false,
192
- rejection: {
193
- code: "no-filesystem",
194
- message: "filesystem capability withdrawn mid-request"
195
- }
196
- };
197
- let target;
198
- try {
199
- target = await fs.resolve(path, signal === void 0 ? {} : { signal });
200
- } catch (error) {
201
- return {
202
- ok: false,
203
- rejection: {
204
- code: "path-denied",
205
- message: describe(error, path)
206
- }
207
- };
208
- }
209
- if (!fs.contains(workspace.target, target)) return {
210
- ok: false,
211
- rejection: {
212
- code: "path-denied",
213
- message: `${path} is outside ${workspace.target.displayPath}`
214
- }
215
- };
216
- return {
217
- ok: true,
218
- value: {
219
- target,
220
- processPath: fs.processPath(target)
221
- }
222
- };
223
- }
224
- /**
225
- * Phrase one resolution failure without leaking a stack.
226
- * @param error - whatever the backend threw.
227
- * @param path - the path that was being resolved.
228
- * @returns a single-line operator diagnostic.
229
- */
230
- function describe(error, path) {
231
- return `cannot resolve ${path}: ${error instanceof Error ? error.message : String(error)}`;
232
- }
233
-
234
158
  //#endregion
235
159
  //#region tsbuild/host/files.js
236
160
  /**
@@ -271,20 +195,20 @@ var FileReader = class {
271
195
  * @param signal - cancellation for the listing.
272
196
  * @returns the level, or a classified failure.
273
197
  */
274
- async list(request, signal) {
198
+ async list(request$2, signal) {
275
199
  const fs = this.ctx.get("fs");
276
200
  if (fs === void 0) return {
277
201
  ok: false,
278
202
  code: "no-filesystem",
279
203
  message: "no filesystem capability is mounted"
280
204
  };
281
- const workspace = await resolveWorkspace(this.ctx, request.workspacePath, signal);
205
+ const workspace = await resolveWorkspace(this.ctx, request$2.workspacePath, signal);
282
206
  if (!workspace.ok) return {
283
207
  ok: false,
284
208
  code: workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
285
209
  message: workspace.rejection.message
286
210
  };
287
- const directory = await resolveInside(this.ctx, workspace.value, request.path, signal);
211
+ const directory = await resolveInside(this.ctx, workspace.value, request$2.path, signal);
288
212
  if (!directory.ok) return {
289
213
  ok: false,
290
214
  code: directory.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
@@ -294,7 +218,7 @@ var FileReader = class {
294
218
  if (info === void 0 || info.type !== "directory") return {
295
219
  ok: false,
296
220
  code: "not-a-file",
297
- message: `${request.path} is not a directory`
221
+ message: `${request$2.path} is not a directory`
298
222
  };
299
223
  let children;
300
224
  try {
@@ -333,20 +257,20 @@ var FileReader = class {
333
257
  * @param signal - cancellation for the read.
334
258
  * @returns the preview, or a classified failure.
335
259
  */
336
- async read(request, signal) {
260
+ async read(request$2, signal) {
337
261
  const fs = this.ctx.get("fs");
338
262
  if (fs === void 0) return {
339
263
  ok: false,
340
264
  code: "no-filesystem",
341
265
  message: "no filesystem capability is mounted"
342
266
  };
343
- const workspace = await resolveWorkspace(this.ctx, request.workspacePath, signal);
267
+ const workspace = await resolveWorkspace(this.ctx, request$2.workspacePath, signal);
344
268
  if (!workspace.ok) return {
345
269
  ok: false,
346
270
  code: workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
347
271
  message: workspace.rejection.message
348
272
  };
349
- const file = await resolveInside(this.ctx, workspace.value, request.path, signal);
273
+ const file = await resolveInside(this.ctx, workspace.value, request$2.path, signal);
350
274
  if (!file.ok) return {
351
275
  ok: false,
352
276
  code: file.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
@@ -356,7 +280,7 @@ var FileReader = class {
356
280
  if (info === void 0 || info.type !== "file") return {
357
281
  ok: false,
358
282
  code: "not-a-file",
359
- message: `${request.path} is not a regular file`
283
+ message: `${request$2.path} is not a regular file`
360
284
  };
361
285
  const max = this.source().filesMaxPreviewBytes;
362
286
  let bytes;
@@ -372,7 +296,7 @@ var FileReader = class {
372
296
  const size = info.size ?? bytes.byteLength;
373
297
  if (isBinary(bytes)) return {
374
298
  ok: true,
375
- path: request.path,
299
+ path: request$2.path,
376
300
  text: "",
377
301
  binary: true,
378
302
  truncated: false,
@@ -380,7 +304,7 @@ var FileReader = class {
380
304
  };
381
305
  return {
382
306
  ok: true,
383
- path: request.path,
307
+ path: request$2.path,
384
308
  text: new TextDecoder("utf-8", { fatal: false }).decode(bytes),
385
309
  binary: false,
386
310
  truncated: size > bytes.byteLength,
@@ -688,6 +612,47 @@ const MISSING_BINARY_EXIT = 127;
688
612
  /** The remote a branch with no upstream is published to when nothing else names one. */
689
613
  const DEFAULT_REMOTE = "origin";
690
614
  /**
615
+ * Configuration every invocation carries, ahead of the subcommand.
616
+ *
617
+ * `core.fsmonitor` names an executable git runs whenever it refreshes the index — which `status`,
618
+ * `diff`, `add` and `commit` all do — and it is read from the repository's own `.git/config`. A
619
+ * workspace whose config was written by someone else (an unpacked archive, a shared directory) would
620
+ * otherwise run their program the moment the Changes panel opened. The monitor is only a speed-up, so
621
+ * turning it off costs a slower status on a very large repository and nothing else. `-c` on the command
622
+ * line outranks every config file, and git passes it on to the child gits it starts (submodules).
623
+ */
624
+ const INVOCATION_CONFIG = ["-c", "core.fsmonitor=false"];
625
+ /**
626
+ * Flags every `git diff` carries, so a patch is git's own rendering and never a configured program's.
627
+ *
628
+ * `--no-ext-diff` refuses `diff.external` and per-attribute `diff.<driver>.command`; `--no-textconv`
629
+ * refuses `diff.<driver>.textconv`. Both name executables from repository config and attributes, and
630
+ * both would otherwise run on a reading nobody asked to be a command.
631
+ */
632
+ const DIFF_SAFETY_ARGS = ["--no-ext-diff", "--no-textconv"];
633
+ /**
634
+ * Config keys that name a content filter's executable.
635
+ *
636
+ * `filter.<driver>.clean` (and the long-running `process` protocol) run when git hashes a working-tree
637
+ * file — which `git status` does for every file whose stat data changed. A driver is attached by
638
+ * `.gitattributes`, which a repository ships, and defined in config, which is where the executable
639
+ * comes from; `smudge` runs only on checkout, which no reading here performs.
640
+ */
641
+ const FILTER_EXECUTABLE_KEYS = String.raw`^filter\..*\.(clean|process)$`;
642
+ /** The key shape one filter listing line carries, split into its driver name and its variable. */
643
+ const FILTER_KEY = /^filter\.(.+)\.(clean|process)$/u;
644
+ /**
645
+ * Config scopes whose filter programs a reading may run.
646
+ *
647
+ * `system` and `global` are files the operator (or their administrator) wrote, and they are where
648
+ * `git lfs install` puts its filter, so neutralizing them would turn every LFS file into a phantom
649
+ * change. `local` and `worktree` live inside the repository's own `.git`, which is exactly the
650
+ * config a workspace can arrive with.
651
+ */
652
+ const TRUSTED_CONFIG_SCOPES = new Set(["system", "global"]);
653
+ /** `git config --get-regexp` exits 1 to mean "no key matched", which is an ordinary answer. */
654
+ const CONFIG_NO_MATCH_EXIT = 1;
655
+ /**
691
656
  * What the model is told a commit message is, when the settings section supplies no prompt of its
692
657
  * own.
693
658
  *
@@ -740,6 +705,36 @@ function stripFence(text) {
740
705
  return (/^```[^\n]*\n([\s\S]*)\n```$/u.exec(trimmed)?.[1] ?? trimmed).trim();
741
706
  }
742
707
  /**
708
+ * The `-c` overrides that disarm the untrusted filter programs one config listing names.
709
+ *
710
+ * An empty value is git's own "no command" for a filter (`convert.c` runs a driver only when its
711
+ * command is non-empty), so the file is then hashed as its bytes, exactly as with no driver at all.
712
+ * @param listing - `git config --show-scope --name-only --get-regexp` output, one `scope<TAB>key` per
713
+ * line; a line with no scope (an older git) is treated as untrusted.
714
+ * @returns the overrides, or the key git could not be told about safely.
715
+ */
716
+ function filterOverrides(listing) {
717
+ const config = [];
718
+ for (const line of listing.split("\n")) {
719
+ if (line.trim() === "") continue;
720
+ const tab = line.indexOf(" ");
721
+ const scope = tab < 0 ? void 0 : line.slice(0, tab);
722
+ const key = tab < 0 ? line : line.slice(tab + 1);
723
+ if (scope !== void 0 && TRUSTED_CONFIG_SCOPES.has(scope)) continue;
724
+ if (key.includes("=") || FILTER_KEY.exec(key) === null) return { unsafeKey: key };
725
+ config.push("-c", `${key}=`);
726
+ }
727
+ return { config };
728
+ }
729
+ /**
730
+ * Whether a name could be read as an option by a git command it is passed to.
731
+ * @param name - a branch or remote name taken from repository state.
732
+ * @returns true when it begins with `-`.
733
+ */
734
+ function isOptionShaped(name) {
735
+ return name.startsWith("-");
736
+ }
737
+ /**
743
738
  * Reads one workspace's git state. One instance serves every request; the resolved `git` path is
744
739
  * cached across calls and dropped whenever a lookup fails, so installing git later needs no restart.
745
740
  */
@@ -788,17 +783,17 @@ var GitReader = class {
788
783
  * @param signal - cancellation for the reading.
789
784
  * @returns the reading, or a classified failure.
790
785
  */
791
- async status(request, signal) {
792
- const prepared = await this.prepare(request.workspacePath, signal);
786
+ async status(request$2, signal) {
787
+ const prepared = await this.prepare(request$2.workspacePath, signal);
793
788
  if ("failure" in prepared) return prepared.failure;
794
789
  const { repository, workspace } = prepared;
795
- const outcome = await this.git(workspace.value.processPath, [
790
+ const outcome = await this.read(repository, [
796
791
  "status",
797
792
  "--porcelain=v2",
798
793
  "--branch",
799
794
  "--untracked-files=all",
800
795
  ...NUL_ARGS
801
- ], signal);
796
+ ], signal, void 0, workspace.value.processPath);
802
797
  if (outcome.exitCode !== 0) return classify(outcome);
803
798
  const parsed = parsePorcelainV2(outcome.stdout);
804
799
  const limit = this.source().gitMaxFiles;
@@ -807,7 +802,7 @@ var GitReader = class {
807
802
  const conflicted = changes.filter((change) => change.conflicted);
808
803
  return {
809
804
  ok: true,
810
- write: await this.writeCapability(repository.root, signal),
805
+ write: await this.writeCapability(repository, signal),
811
806
  repositoryRoot: repository.root,
812
807
  prefix: repository.prefix,
813
808
  ...parsed.branch.branch === void 0 ? {} : { branch: parsed.branch.branch },
@@ -829,39 +824,39 @@ var GitReader = class {
829
824
  * @param signal - cancellation for the reading.
830
825
  * @returns the patch, or a classified failure.
831
826
  */
832
- async diff(request, signal) {
833
- const prepared = await this.prepare(request.workspacePath, signal);
827
+ async diff(request$2, signal) {
828
+ const prepared = await this.prepare(request$2.workspacePath, signal);
834
829
  if ("failure" in prepared) return prepared.failure;
835
830
  const { repository } = prepared;
836
- const contained = await this.contain(repository.root, [request.path], signal);
831
+ const contained = await this.contain(repository.root, [request$2.path], signal);
837
832
  if (contained !== void 0) return contained;
838
833
  const common = [
839
834
  "--no-pager",
840
835
  "diff",
841
836
  "--no-color",
842
- "--no-ext-diff"
837
+ ...DIFF_SAFETY_ARGS
843
838
  ];
844
- const argv = request.untracked ? [
839
+ const argv = request$2.untracked ? [
845
840
  ...common,
846
841
  "--no-index",
847
842
  "--",
848
843
  devNull(),
849
- request.path
844
+ request$2.path
850
845
  ] : [
851
846
  ...common,
852
- ...request.staged ? ["--cached"] : [],
847
+ ...request$2.staged ? ["--cached"] : [],
853
848
  "--",
854
- request.path
849
+ request$2.path
855
850
  ];
856
851
  const max = this.source().gitDiffMaxBytes;
857
- const outcome = await this.git(repository.root, argv, signal, max);
858
- const differed = request.untracked && outcome.exitCode === 1 && outcome.stderr.trim() === "";
852
+ const outcome = await this.read(repository, argv, signal, max);
853
+ const differed = request$2.untracked && outcome.exitCode === 1 && outcome.stderr.trim() === "";
859
854
  if (outcome.exitCode !== 0 && !differed) return classify(outcome);
860
855
  const patch = outcome.stdout;
861
856
  const truncated = outcome.stdoutLossy || patch.length > max;
862
857
  return {
863
858
  ok: true,
864
- path: request.path,
859
+ path: request$2.path,
865
860
  patch: patch.length > max ? patch.slice(0, max) : patch,
866
861
  binary: /^Binary files .* differ$/mu.test(patch),
867
862
  truncated
@@ -873,9 +868,9 @@ var GitReader = class {
873
868
  * @param signal - cancellation for the write and the reading that follows it.
874
869
  * @returns the reading after the write, or a classified failure.
875
870
  */
876
- stage(request, signal) {
871
+ stage(request$2, signal) {
877
872
  if (!this.source().allowGitStaging) return Promise.resolve(fail$4("disabled", "staging is switched off in the advanced-sidebar settings"));
878
- return this.write(request, signal, (paths) => [
873
+ return this.write(request$2, signal, (paths) => [
879
874
  "add",
880
875
  "--",
881
876
  ...paths
@@ -887,9 +882,9 @@ var GitReader = class {
887
882
  * @param signal - cancellation for the write and the reading that follows it.
888
883
  * @returns the reading after the write, or a classified failure.
889
884
  */
890
- unstage(request, signal) {
885
+ unstage(request$2, signal) {
891
886
  if (!this.source().allowGitStaging) return Promise.resolve(fail$4("disabled", "staging is switched off in the advanced-sidebar settings"));
892
- return this.write(request, signal, (paths) => [
887
+ return this.write(request$2, signal, (paths) => [
893
888
  "restore",
894
889
  "--staged",
895
890
  "--",
@@ -906,29 +901,29 @@ var GitReader = class {
906
901
  * @param signal - cancellation for the commit and the reading that follows it.
907
902
  * @returns the new commit and the reading after it, or a classified failure.
908
903
  */
909
- async commit(request, signal) {
904
+ async commit(request$2, signal) {
910
905
  const settings = this.source();
911
906
  if (!settings.allowGitCommit) return fail$4("disabled", "committing is switched off in the advanced-sidebar settings");
912
- const message = request.message.trim();
907
+ const message = request$2.message.trim();
913
908
  if (message === "") return fail$4("empty-message", "a commit needs a message");
914
- const prepared = await this.prepare(request.workspacePath, signal);
909
+ const prepared = await this.prepare(request$2.workspacePath, signal);
915
910
  if ("failure" in prepared) return prepared.failure;
916
911
  const { repository } = prepared;
917
- if (await this.author(repository.root, signal) === void 0) return fail$4("no-identity", "git has no user.name and user.email, so it has no author to record; set them with `git config --global user.name` and `git config --global user.email`");
918
- if (!request.amend && !await this.hasStaged(repository.root, signal)) return fail$4("nothing-staged", "nothing is staged, so there is nothing to commit");
912
+ if (await this.author(repository, signal) === void 0) return fail$4("no-identity", "git has no user.name and user.email, so it has no author to record; set them with `git config --global user.name` and `git config --global user.email`");
913
+ if (!request$2.amend && !await this.hasStaged(repository, signal)) return fail$4("nothing-staged", "nothing is staged, so there is nothing to commit");
919
914
  const outcome = await this.git(repository.root, [
920
915
  "commit",
921
- ...request.amend ? ["--amend"] : [],
916
+ ...request$2.amend ? ["--amend"] : [],
922
917
  "-m",
923
918
  message
924
919
  ], signal, void 0, settings.gitCommitTimeoutMs);
925
920
  if (outcome.exitCode !== 0) return classify(outcome);
926
- const [commit, subject] = (await this.git(repository.root, [
921
+ const [commit, subject] = (await this.read(repository, [
927
922
  "log",
928
923
  "-1",
929
924
  "--format=%h%n%s"
930
925
  ], signal)).stdout.split("\n");
931
- const status = await this.status({ workspacePath: request.workspacePath }, signal);
926
+ const status = await this.status({ workspacePath: request$2.workspacePath }, signal);
932
927
  if (!status.ok) return status;
933
928
  return {
934
929
  ok: true,
@@ -949,28 +944,25 @@ var GitReader = class {
949
944
  * @param signal - cancellation; the network wait runs under `gitPushTimeoutMs`.
950
945
  * @returns the push, the reading after it, or a classified failure.
951
946
  */
952
- async push(request, signal) {
947
+ async push(request$2, signal) {
953
948
  const settings = this.source();
954
949
  if (!settings.allowGitPush) return fail$4("disabled", "pushing is switched off in the advanced-sidebar settings");
955
- const prepared = await this.prepare(request.workspacePath, signal);
950
+ const prepared = await this.prepare(request$2.workspacePath, signal);
956
951
  if ("failure" in prepared) return prepared.failure;
957
952
  const { repository } = prepared;
958
- const before = await this.status({ workspacePath: request.workspacePath }, signal);
953
+ const before = await this.status({ workspacePath: request$2.workspacePath }, signal);
959
954
  if (!before.ok) return before;
960
955
  if (before.detached || before.branch === void 0) return fail$4("detached-head", "HEAD names a commit rather than a branch, so there is nothing to push");
961
956
  const branch = before.branch;
962
957
  const upstream = before.upstream;
963
- if (upstream === void 0 && !request.setUpstream) return fail$4("no-upstream", `${branch} has no upstream; publish it to a remote first, or use Publish to record one`);
964
- const remote = upstream === void 0 ? await this.defaultRemote(repository.root, signal) : upstream.split("/")[0] ?? DEFAULT_REMOTE;
958
+ if (upstream === void 0 && !request$2.setUpstream) return fail$4("no-upstream", `${branch} has no upstream; publish it to a remote first, or use Publish to record one`);
959
+ const remote = upstream === void 0 ? await this.defaultRemote(repository, signal) : upstream.split("/")[0] ?? DEFAULT_REMOTE;
965
960
  if (remote === void 0) return fail$4("no-upstream", "this repository has no remote to push to");
966
- const outcome = await this.git(repository.root, upstream === void 0 ? [
967
- "push",
968
- "--set-upstream",
969
- remote,
970
- branch
971
- ] : ["push"], signal, void 0, settings.gitPushTimeoutMs);
961
+ const argv = upstream === void 0 ? await this.publishArgv(repository, remote, branch, signal) : { argv: ["push"] };
962
+ if ("failure" in argv) return argv.failure;
963
+ const outcome = await this.git(repository.root, argv.argv, signal, void 0, settings.gitPushTimeoutMs);
972
964
  if (outcome.exitCode !== 0) return classify(outcome);
973
- const status = await this.status({ workspacePath: request.workspacePath }, signal);
965
+ const status = await this.status({ workspacePath: request$2.workspacePath }, signal);
974
966
  if (!status.ok) return status;
975
967
  return {
976
968
  ok: true,
@@ -982,6 +974,67 @@ var GitReader = class {
982
974
  };
983
975
  }
984
976
  /**
977
+ * The arguments that publish one branch to one remote and record it as the upstream.
978
+ *
979
+ * Both names come from repository state, not from the browser — and repository state is not
980
+ * trusted either. A HEAD of `refs/heads/--receive-pack=/tmp/x` is a valid ref (`check-ref-format`
981
+ * accepts it) that `git status` reports as the branch `--receive-pack=/tmp/x`; handed to
982
+ * `git push` as a bare argument, git parsed it as the option and ran `/tmp/x` as the remote's
983
+ * receive-pack. So three independent things stand in the way:
984
+ *
985
+ * 1. Both names are refused outright when they begin with `-`.
986
+ * 2. The branch must pass `git check-ref-format --branch`, which is git's own branch-name grammar
987
+ * (it rejects a leading `-`, `..`, control characters, `@{`) and must echo back unchanged, so a
988
+ * `@{-1}` shorthand cannot be expanded into some other branch. The remote must make a valid
989
+ * remote-tracking ref, which is how git itself validates a remote name.
990
+ * 3. The push names the refs after `--`, where `git push` (parse-options) stops reading options,
991
+ * and as a fully qualified `refs/heads/<b>:refs/heads/<b>` refspec, which also cannot be read as
992
+ * a shorter ref with the same name on the remote. `--set-upstream` records the same tracking
993
+ * branch it records for the short spelling.
994
+ * @param repository - the resolved repository.
995
+ * @param remote - the remote to publish to, from `git remote`.
996
+ * @param branch - the current branch, from `git status`.
997
+ * @param signal - cancellation for the validation invocations.
998
+ * @returns the push arguments, or the failure to return.
999
+ */
1000
+ async publishArgv(repository, remote, branch, signal) {
1001
+ if (isOptionShaped(branch) || !await this.isValidBranchName(repository, branch, signal)) return { failure: fail$4("git-failed", `refusing to publish: ${JSON.stringify(branch)} is not a valid branch name`) };
1002
+ if (isOptionShaped(remote) || !await this.isValidRemoteName(repository, remote, signal)) return { failure: fail$4("git-failed", `refusing to publish: ${JSON.stringify(remote)} is not a valid remote name`) };
1003
+ const ref = `refs/heads/${branch}`;
1004
+ return { argv: [
1005
+ "push",
1006
+ "--set-upstream",
1007
+ "--",
1008
+ remote,
1009
+ `${ref}:${ref}`
1010
+ ] };
1011
+ }
1012
+ /**
1013
+ * Whether git accepts a name as a branch name, spelled exactly as given.
1014
+ * @param repository - the resolved repository.
1015
+ * @param branch - the name, already known not to begin with `-`.
1016
+ * @param signal - cancellation for the invocation.
1017
+ * @returns true when `check-ref-format --branch` accepts it and echoes it back unchanged.
1018
+ */
1019
+ async isValidBranchName(repository, branch, signal) {
1020
+ const outcome = await this.read(repository, [
1021
+ "check-ref-format",
1022
+ "--branch",
1023
+ branch
1024
+ ], signal);
1025
+ return outcome.exitCode === 0 && outcome.stdout.replace(/\n$/u, "") === branch;
1026
+ }
1027
+ /**
1028
+ * Whether git accepts a name as a remote name.
1029
+ * @param repository - the resolved repository.
1030
+ * @param remote - the name, already known not to begin with `-`.
1031
+ * @param signal - cancellation for the invocation.
1032
+ * @returns true when `refs/remotes/<remote>/HEAD` is a well-formed ref, git's own remote-name rule.
1033
+ */
1034
+ async isValidRemoteName(repository, remote, signal) {
1035
+ return (await this.read(repository, ["check-ref-format", `refs/remotes/${remote}/HEAD`], signal)).exitCode === 0;
1036
+ }
1037
+ /**
985
1038
  * Ask the deployment's own model to write a commit message for what is staged.
986
1039
  *
987
1040
  * The model sees the staged patch and nothing else — not the working tree, not the repository's
@@ -991,16 +1044,16 @@ var GitReader = class {
991
1044
  * @param signal - cancellation for the readings and the model call.
992
1045
  * @returns the drafted message, or a classified failure.
993
1046
  */
994
- async draftCommitMessage(request, signal) {
1047
+ async draftCommitMessage(request$2, signal) {
995
1048
  const settings = this.source();
996
1049
  if (!settings.allowGitCommit || !settings.allowCommitMessageDraft) return fail$4("disabled", "the drafted commit message is switched off in the advanced-sidebar settings");
997
1050
  const llm = this.ctx.get("llm");
998
1051
  const models = this.ctx.get("agentDefaultModel");
999
1052
  if (llm === void 0 || models === void 0) return fail$4("no-model", "no model is configured for this deployment");
1000
- const prepared = await this.prepare(request.workspacePath, signal);
1053
+ const prepared = await this.prepare(request$2.workspacePath, signal);
1001
1054
  if ("failure" in prepared) return prepared.failure;
1002
1055
  const { repository } = prepared;
1003
- const patch = await this.stagedPatch(repository.root, request.amend, signal);
1056
+ const patch = await this.stagedPatch(repository, request$2.amend, signal);
1004
1057
  if ("failure" in patch) return patch.failure;
1005
1058
  if (patch.text.trim() === "") return fail$4("nothing-staged", "nothing is staged, so there is nothing to describe");
1006
1059
  const selection = models.currentSelection();
@@ -1038,24 +1091,26 @@ var GitReader = class {
1038
1091
  }
1039
1092
  /**
1040
1093
  * The patch a drafted message describes, bounded so a large change cannot become a large request.
1041
- * @param root - absolute repository root.
1094
+ * @param repository - the resolved repository.
1042
1095
  * @param amend - describe the previous commit's content as well as the index.
1043
1096
  * @param signal - cancellation for the invocations.
1044
1097
  * @returns the patch and whether it was cut, or the failure to return.
1045
1098
  */
1046
- async stagedPatch(root, amend, signal) {
1099
+ async stagedPatch(repository, amend, signal) {
1047
1100
  const settings = this.source();
1048
- const base = amend && await this.hasParent(root, signal) ? ["HEAD~1"] : [];
1049
- const stat = await this.git(root, [
1101
+ const base = amend && await this.hasParent(repository, signal) ? ["HEAD~1"] : [];
1102
+ const stat = await this.read(repository, [
1050
1103
  "diff",
1104
+ ...DIFF_SAFETY_ARGS,
1051
1105
  "--cached",
1052
1106
  "--stat",
1053
1107
  ...base
1054
1108
  ], signal);
1055
1109
  if (stat.exitCode !== 0) return { failure: classify(stat) };
1056
1110
  const cap = settings.commitMessageMaxBytes;
1057
- const patch = await this.git(root, [
1111
+ const patch = await this.read(repository, [
1058
1112
  "diff",
1113
+ ...DIFF_SAFETY_ARGS,
1059
1114
  "--cached",
1060
1115
  "--no-color",
1061
1116
  ...base
@@ -1074,12 +1129,12 @@ var GitReader = class {
1074
1129
  }
1075
1130
  /**
1076
1131
  * Whether HEAD has a parent commit.
1077
- * @param cwd - absolute repository root.
1132
+ * @param repository - the resolved repository.
1078
1133
  * @param signal - cancellation for the invocation.
1079
1134
  * @returns true when `HEAD~1` resolves.
1080
1135
  */
1081
- async hasParent(cwd, signal) {
1082
- return (await this.git(cwd, [
1136
+ async hasParent(repository, signal) {
1137
+ return (await this.read(repository, [
1083
1138
  "rev-parse",
1084
1139
  "--verify",
1085
1140
  "--quiet",
@@ -1088,12 +1143,12 @@ var GitReader = class {
1088
1143
  }
1089
1144
  /**
1090
1145
  * The remote an unpublished branch would be published to.
1091
- * @param cwd - absolute repository root.
1146
+ * @param repository - the resolved repository.
1092
1147
  * @param signal - cancellation for the invocation.
1093
1148
  * @returns `origin` when it exists, else the first remote, else undefined.
1094
1149
  */
1095
- async defaultRemote(cwd, signal) {
1096
- const outcome = await this.git(cwd, ["remote"], signal);
1150
+ async defaultRemote(repository, signal) {
1151
+ const outcome = await this.read(repository, ["remote"], signal);
1097
1152
  if (outcome.exitCode !== 0) return void 0;
1098
1153
  const remotes = outcome.stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "");
1099
1154
  return remotes.includes(DEFAULT_REMOTE) ? DEFAULT_REMOTE : remotes[0];
@@ -1105,16 +1160,16 @@ var GitReader = class {
1105
1160
  * @param argv - builds the git arguments from the accepted paths.
1106
1161
  * @returns the reading after the write, or a classified failure.
1107
1162
  */
1108
- async write(request, signal, argv) {
1109
- if (request.paths.length === 0) return fail$4("path-denied", "no paths were given");
1110
- const prepared = await this.prepare(request.workspacePath, signal);
1163
+ async write(request$2, signal, argv) {
1164
+ if (request$2.paths.length === 0) return fail$4("path-denied", "no paths were given");
1165
+ const prepared = await this.prepare(request$2.workspacePath, signal);
1111
1166
  if ("failure" in prepared) return prepared.failure;
1112
1167
  const { repository } = prepared;
1113
- const contained = await this.contain(repository.root, request.paths, signal);
1168
+ const contained = await this.contain(repository.root, request$2.paths, signal);
1114
1169
  if (contained !== void 0) return contained;
1115
- const outcome = await this.git(repository.root, argv(request.paths), signal);
1170
+ const outcome = await this.git(repository.root, argv(request$2.paths), signal);
1116
1171
  if (outcome.exitCode !== 0) return classify(outcome);
1117
- const status = await this.status({ workspacePath: request.workspacePath }, signal);
1172
+ const status = await this.status({ workspacePath: request$2.workspacePath }, signal);
1118
1173
  return status.ok ? {
1119
1174
  ok: true,
1120
1175
  status
@@ -1142,12 +1197,12 @@ var GitReader = class {
1142
1197
  }
1143
1198
  /**
1144
1199
  * The author `git commit` would record.
1145
- * @param cwd - the repository root.
1200
+ * @param repository - the resolved repository.
1146
1201
  * @param signal - cancellation for the invocation.
1147
1202
  * @returns `Name <email>`, or undefined when git has no identity configured.
1148
1203
  */
1149
- async author(cwd, signal) {
1150
- const outcome = await this.git(cwd, ["var", "GIT_AUTHOR_IDENT"], signal);
1204
+ async author(repository, signal) {
1205
+ const outcome = await this.read(repository, ["var", "GIT_AUTHOR_IDENT"], signal);
1151
1206
  if (outcome.exitCode !== 0) return void 0;
1152
1207
  const ident = outcome.stdout.trim();
1153
1208
  const at = ident.lastIndexOf(">");
@@ -1155,29 +1210,30 @@ var GitReader = class {
1155
1210
  }
1156
1211
  /**
1157
1212
  * Whether the index differs from HEAD.
1158
- * @param cwd - the repository root.
1213
+ * @param repository - the resolved repository.
1159
1214
  * @param signal - cancellation for the invocation.
1160
1215
  * @returns true when a commit would record something.
1161
1216
  */
1162
- async hasStaged(cwd, signal) {
1163
- return (await this.git(cwd, [
1217
+ async hasStaged(repository, signal) {
1218
+ return (await this.read(repository, [
1164
1219
  "diff",
1220
+ ...DIFF_SAFETY_ARGS,
1165
1221
  "--cached",
1166
1222
  "--quiet"
1167
1223
  ], signal)).exitCode !== 0;
1168
1224
  }
1169
1225
  /**
1170
1226
  * Report what the panel may do to this repository.
1171
- * @param cwd - the repository root.
1227
+ * @param repository - the resolved repository.
1172
1228
  * @param signal - cancellation for the identity lookup.
1173
1229
  * @returns the write capability.
1174
1230
  */
1175
- async writeCapability(cwd, signal) {
1231
+ async writeCapability(repository, signal) {
1176
1232
  const settings = this.source();
1177
1233
  const canStage = settings.allowGitStaging;
1178
1234
  const canCommit = canStage && settings.allowGitCommit;
1179
1235
  const canDraftMessage = canCommit && settings.allowCommitMessageDraft && this.ctx.get("llm") !== void 0 && this.ctx.get("agentDefaultModel") !== void 0;
1180
- const author = canCommit ? await this.author(cwd, signal) : void 0;
1236
+ const author = canCommit ? await this.author(repository, signal) : void 0;
1181
1237
  return {
1182
1238
  canStage,
1183
1239
  canCommit,
@@ -1217,16 +1273,70 @@ var GitReader = class {
1217
1273
  if (outcome.exitCode !== 0) return { failure: classify(outcome) };
1218
1274
  const [root, prefix] = outcome.stdout.split("\n");
1219
1275
  if (root === void 0 || root.trim() === "") return { failure: fail$4("not-a-repository", `${cwd} is not inside a git repository`) };
1276
+ const readingConfig = await this.untrustedFilterConfig(root.trim(), signal);
1277
+ if ("failure" in readingConfig) return { failure: readingConfig.failure };
1220
1278
  return { repository: {
1221
1279
  root: root.trim(),
1222
- prefix: (prefix ?? "").trim().replace(/\/$/u, "")
1280
+ prefix: (prefix ?? "").trim().replace(/\/$/u, ""),
1281
+ readingConfig: readingConfig.config
1223
1282
  } };
1224
1283
  }
1225
1284
  /**
1226
- * Run one git invocation with this plugin's own bounds.
1285
+ * The overrides that switch off the content filters a repository's own config defines.
1286
+ *
1287
+ * Listing config runs nothing — `git config` reads files — so this is safe to ask before any
1288
+ * reading. `--show-scope` arrived in git 2.26; an older git refuses the flag, and the listing is
1289
+ * then repeated without it and every filter it names is treated as untrusted, which can only make
1290
+ * a reading more conservative. A listing that fails both ways fails the reading: a status that
1291
+ * cannot prove its filters are disarmed is not run.
1292
+ * @param root - absolute repository root.
1293
+ * @param signal - cancellation for the listing.
1294
+ * @returns the `-c` pairs, or the failure to return.
1295
+ */
1296
+ async untrustedFilterConfig(root, signal) {
1297
+ const scoped = await this.git(root, [
1298
+ "config",
1299
+ "--show-scope",
1300
+ "--name-only",
1301
+ "--get-regexp",
1302
+ FILTER_EXECUTABLE_KEYS
1303
+ ], signal);
1304
+ const listing = isConfigListing(scoped) ? scoped : await this.git(root, [
1305
+ "config",
1306
+ "--name-only",
1307
+ "--get-regexp",
1308
+ FILTER_EXECUTABLE_KEYS
1309
+ ], signal);
1310
+ if (!isConfigListing(listing)) return { failure: classify(listing) };
1311
+ const overrides = filterOverrides(listing.stdout);
1312
+ if ("unsafeKey" in overrides) return { failure: fail$4("git-failed", `refusing to read this repository: its config defines ${JSON.stringify(overrides.unsafeKey)}, a filter program that cannot be switched off from the command line`) };
1313
+ return { config: overrides.config };
1314
+ }
1315
+ /**
1316
+ * Run one READING: an invocation the panel makes on its own, which must run no program the
1317
+ * repository's config names.
1318
+ *
1319
+ * On top of {@link git}'s own `core.fsmonitor` override it disarms the repository's content
1320
+ * filters. Writes (`add`, `restore`, `commit`, `push`) deliberately do not go through here: they are
1321
+ * an operator's explicit action, a filter such as LFS is part of what staging correctly means, and a
1322
+ * commit's hooks are a real answer the panel reports.
1323
+ * @param repository - the resolved repository, carrying its filter overrides.
1324
+ * @param args - arguments after the executable and the overrides.
1325
+ * @param signal - the caller's cancellation.
1326
+ * @param maxBytes - the caller's own output bound, when it has one.
1327
+ * @param cwd - directory to run in; the repository root unless the reading is relative to the workspace.
1328
+ * @returns the finished command.
1329
+ */
1330
+ read(repository, args, signal, maxBytes, cwd = repository.root) {
1331
+ return this.git(cwd, [...repository.readingConfig, ...args], signal, maxBytes);
1332
+ }
1333
+ /**
1334
+ * Run one git invocation with this plugin's own bounds and {@link INVOCATION_CONFIG}.
1227
1335
  * @param cwd - directory to run in.
1228
- * @param args - arguments after the executable.
1336
+ * @param args - arguments after the executable and the invocation config.
1229
1337
  * @param signal - the caller's cancellation.
1338
+ * @param maxBytes - the caller's own output bound, when it has one.
1339
+ * @param timeoutMs - the caller's own wall-clock bound, when it has one.
1230
1340
  * @returns the finished command.
1231
1341
  */
1232
1342
  async git(cwd, args, signal, maxBytes, timeoutMs) {
@@ -1242,7 +1352,11 @@ var GitReader = class {
1242
1352
  };
1243
1353
  const settings = this.source();
1244
1354
  return runCommand(this.ctx, {
1245
- argv: [executable, ...args],
1355
+ argv: [
1356
+ executable,
1357
+ ...INVOCATION_CONFIG,
1358
+ ...args
1359
+ ],
1246
1360
  cwd,
1247
1361
  timeoutMs: timeoutMs ?? settings.gitTimeoutMs,
1248
1362
  maxBytes: maxBytes ?? Math.max(settings.gitDiffMaxBytes, 1 << 20),
@@ -1284,6 +1398,15 @@ var GitReader = class {
1284
1398
  }
1285
1399
  };
1286
1400
  /**
1401
+ * Whether a `git config --get-regexp` finished with a usable answer.
1402
+ * @param outcome - the finished listing.
1403
+ * @returns true for a listing (exit 0) or for "nothing matched" (exit 1 with a silent stderr).
1404
+ */
1405
+ function isConfigListing(outcome) {
1406
+ if (outcome.exitCode === 0) return true;
1407
+ return outcome.exitCode === CONFIG_NO_MATCH_EXIT && outcome.stderr.trim() === "";
1408
+ }
1409
+ /**
1287
1410
  * The empty left-hand side of an untracked file's synthesized patch.
1288
1411
  * @returns the platform's null device path.
1289
1412
  */
@@ -1360,14 +1483,14 @@ var OpenInLauncher = class {
1360
1483
  * @param signal - cancellation for the launch.
1361
1484
  * @returns settlement, or a classified failure.
1362
1485
  */
1363
- async open(request, signal) {
1364
- const resolvedPath = await resolveWorkspace(this.ctx, request.path, signal);
1365
- const target = resolvedPath.ok ? resolvedPath.value.processPath : await this.resolveFile(request.path, signal);
1366
- if (target === void 0) return fail$3("path-denied", `${request.path} does not exist on this Host`);
1486
+ async open(request$2, signal) {
1487
+ const resolvedPath = await resolveWorkspace(this.ctx, request$2.path, signal);
1488
+ const target = resolvedPath.ok ? resolvedPath.value.processPath : await this.resolveFile(request$2.path, signal);
1489
+ if (target === void 0) return fail$3("path-denied", `${request$2.path} does not exist on this Host`);
1367
1490
  const directory = resolvedPath.ok;
1368
- if (request.targetId === REVEAL_TARGET_ID) return this.reveal(target, directory, signal);
1369
- const editor = this.source().editors.find((entry) => entry.id === request.targetId);
1370
- if (editor === void 0) return fail$3("unknown-target", `no Open in target "${request.targetId}"`);
1491
+ if (request$2.targetId === REVEAL_TARGET_ID) return this.reveal(target, directory, signal);
1492
+ const editor = this.source().editors.find((entry) => entry.id === request$2.targetId);
1493
+ if (editor === void 0) return fail$3("unknown-target", `no Open in target "${request$2.targetId}"`);
1371
1494
  const executable = await this.locate(editor.command, signal);
1372
1495
  if (executable === void 0) return fail$3("unavailable", `${editor.label}: "${editor.command}" does not resolve on this Host`);
1373
1496
  return this.launch([
@@ -1685,8 +1808,8 @@ var PreviewServers = class {
1685
1808
  * @param signal - cancellation for the file read.
1686
1809
  * @returns the list, or a classified failure.
1687
1810
  */
1688
- async list(request, signal) {
1689
- const workspace = await resolveWorkspace(this.ctx, request.workspacePath, signal);
1811
+ async list(request$2, signal) {
1812
+ const workspace = await resolveWorkspace(this.ctx, request$2.workspacePath, signal);
1690
1813
  if (!workspace.ok) return fail$2(workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied", workspace.rejection.message);
1691
1814
  const file = await this.readLaunchFile(workspace, signal);
1692
1815
  return {
@@ -1702,13 +1825,13 @@ var PreviewServers = class {
1702
1825
  * @param signal - cancellation of the start itself; a started server owns its later lifetime.
1703
1826
  * @returns the started row, or a classified failure.
1704
1827
  */
1705
- async start(request, signal) {
1828
+ async start(request$2, signal) {
1706
1829
  if (this.closing) return fail$2("closed", "the plugin is unloading");
1707
1830
  if (this.ctx.get("subprocess") === void 0) return fail$2("no-subprocess", "no subprocess capability is mounted");
1708
- const workspace = await resolveWorkspace(this.ctx, request.workspacePath, signal);
1831
+ const workspace = await resolveWorkspace(this.ctx, request$2.workspacePath, signal);
1709
1832
  if (!workspace.ok) return fail$2(workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied", workspace.rejection.message);
1710
- const resolved = mergeLaunches((await this.readLaunchFile(workspace, signal)).launches, this.settingsLaunches()).find((entry) => entry.launch.name === request.name);
1711
- if (resolved === void 0) return fail$2("unknown-server", `no launch configuration named "${request.name}"`);
1833
+ const resolved = mergeLaunches((await this.readLaunchFile(workspace, signal)).launches, this.settingsLaunches()).find((entry) => entry.launch.name === request$2.name);
1834
+ if (resolved === void 0) return fail$2("unknown-server", `no launch configuration named "${request$2.name}"`);
1712
1835
  const launch = resolved.launch;
1713
1836
  if (launch.runtimeExecutable === void 0 || launch.runtimeExecutable === "") return fail$2("not-startable", `"${launch.name}" names no command; it opens its url and starts nothing`);
1714
1837
  const key = `${workspace.value.processPath}\u0000${launch.name}`;
@@ -1804,9 +1927,9 @@ var PreviewServers = class {
1804
1927
  * @param request - the handle.
1805
1928
  * @returns settlement, or a classified failure.
1806
1929
  */
1807
- async stop(request) {
1808
- const record = this.records.get(request.serverId);
1809
- if (record === void 0) return fail$2("unknown-server", `no preview server ${request.serverId}`);
1930
+ async stop(request$2) {
1931
+ const record = this.records.get(request$2.serverId);
1932
+ if (record === void 0) return fail$2("unknown-server", `no preview server ${request$2.serverId}`);
1810
1933
  this.records.delete(record.serverId);
1811
1934
  await this.terminate(record);
1812
1935
  return { ok: true };
@@ -1816,12 +1939,12 @@ var PreviewServers = class {
1816
1939
  * @param request - the handle and the offset already rendered.
1817
1940
  * @returns the delta and the state, or a classified failure.
1818
1941
  */
1819
- logs(request) {
1820
- const record = this.records.get(request.serverId);
1821
- if (record === void 0) return fail$2("unknown-server", `no preview server ${request.serverId}`);
1942
+ logs(request$2) {
1943
+ const record = this.records.get(request$2.serverId);
1944
+ if (record === void 0) return fail$2("unknown-server", `no preview server ${request$2.serverId}`);
1822
1945
  this.drain(record);
1823
1946
  const total = record.base + record.buffer.length;
1824
- const from = Number.isFinite(request.fromOffset) ? Math.max(0, Math.floor(request.fromOffset)) : 0;
1947
+ const from = Number.isFinite(request$2.fromOffset) ? Math.max(0, Math.floor(request$2.fromOffset)) : 0;
1825
1948
  const lossy = from < record.base;
1826
1949
  const text = lossy ? record.buffer : record.buffer.slice(Math.min(from - record.base, record.buffer.length));
1827
1950
  return {
@@ -2042,34 +2165,101 @@ var PreviewServers = class {
2042
2165
  };
2043
2166
 
2044
2167
  //#endregion
2045
- //#region tsbuild/host/terminals.js
2046
- /** Compose one classified failure. */
2047
- function fail$1(code, message) {
2048
- return {
2049
- ok: false,
2050
- code,
2051
- message
2052
- };
2053
- }
2054
- /** Terminal geometry the substrate will accept, whatever the panel measured. */
2055
- function clampGeometry(cols, rows) {
2056
- const bound = (value, low, high) => Number.isFinite(value) ? Math.min(Math.max(Math.round(value), low), high) : low;
2057
- return {
2058
- cols: bound(cols, 20, 500),
2059
- rows: bound(rows, 5, 200)
2060
- };
2168
+ //#region tsbuild/host/preview-serve.js
2169
+ /**
2170
+ * Largest scratchpad document the Host will echo back.
2171
+ *
2172
+ * The scratchpad is a person typing HTML into a text area; a megabyte of it is a mistake or an
2173
+ * attempt to make the Host hold memory, and neither is worth serving.
2174
+ */
2175
+ const SCRATCHPAD_MAX_BYTES = 1024 * 1024;
2176
+ /**
2177
+ * How many bytes of a file's head feed the change token.
2178
+ *
2179
+ * The token is what makes the panel reload when a file changes on disk. Hashing the whole file
2180
+ * would read a video twice per poll; hashing the head misses an edit past the first 8 KB, so the
2181
+ * token is `version + size + head digest` — the backend's own opaque version already changes on any
2182
+ * write, and the head digest is the fallback for a backend that reports a constant version.
2183
+ */
2184
+ const TOKEN_PROBE_BYTES = 8192;
2185
+ /**
2186
+ * Largest byte window this module will ask a filesystem for.
2187
+ *
2188
+ * `ctx.fs.readBytes` takes a cap rather than a range, so a request for the middle of a file is
2189
+ * impossible through it; the window is what the fallback path reads, and it is bounded well below
2190
+ * `previewMaxFileBytes` so an over-limit read cannot become an out-of-memory.
2191
+ */
2192
+ const FALLBACK_READ_BYTES = 4 * 1024 * 1024;
2193
+ /**
2194
+ * Whether a filesystem implementation can read one byte window.
2195
+ * @param fs - the filesystem, or the value `ctx.get('fs')` returned.
2196
+ * @returns true when the ranged read is available.
2197
+ */
2198
+ function hasRangeRead(fs) {
2199
+ return "readByteRange" in fs && typeof fs.readByteRange === "function";
2061
2200
  }
2201
+ /** Upstream statuses re-sent as-is; everything else keeps its status but loses its body only on HEAD. */
2202
+ const HOP_BY_HOP = new Set([
2203
+ "connection",
2204
+ "keep-alive",
2205
+ "proxy-authenticate",
2206
+ "proxy-authorization",
2207
+ "te",
2208
+ "trailer",
2209
+ "transfer-encoding",
2210
+ "upgrade"
2211
+ ]);
2062
2212
  /**
2063
- * Owns every panel terminal in the process. One instance is created by the service and disposed
2064
- * with it, which is what guarantees no shell outlives the plugin.
2213
+ * Headers the proxy decides for itself.
2214
+ *
2215
+ * `host` is rewritten to the upstream's own authority, because a dev server routes on it and a
2216
+ * virtual-hosted one would answer the wrong site; `origin` and `referer` point at the GUI, and
2217
+ * forwarding them would make the dev server reject a request it thinks is cross-site; `accept-encoding`
2218
+ * is dropped so the upstream's response and the GUI's compression middleware never double-encode.
2065
2219
  */
2066
- var PanelTerminals = class {
2220
+ const REWRITTEN_REQUEST_HEADERS = new Set([
2221
+ "host",
2222
+ "origin",
2223
+ "referer",
2224
+ "accept-encoding",
2225
+ "connection"
2226
+ ]);
2227
+ /**
2228
+ * Name prefix of the harness's browser-session cookie (`dsh-client-connection`, `COOKIE_PREFIX`).
2229
+ *
2230
+ * The full name is this prefix plus a digest of the authority, so the prefix is the stable part. A
2231
+ * request forwarded to a dev server must not carry it — whatever listens on a loopback port would
2232
+ * otherwise hold a credential that authenticates it to the whole harness API — and a dev server's
2233
+ * `Set-Cookie` must not be able to replace it on the GUI's origin.
2234
+ */
2235
+ const HOST_AUTH_COOKIE_PREFIX = "dsh-auth-";
2236
+ /** The hop-by-hop headers a websocket tunnel must still forward, because they are the handshake. */
2237
+ const UPGRADE_HANDSHAKE_HEADERS = new Set(["connection", "upgrade"]);
2238
+ /**
2239
+ * Serves workspace files and proxies loopback dev servers on the GUI's own origin.
2240
+ *
2241
+ * One instance is created by the service and disposed with it, which is what guarantees no route
2242
+ * outlives the plugin: every registration returns a disposer, and `dispose()` runs them all.
2243
+ */
2244
+ var PreviewSurface = class {
2067
2245
  ctx;
2068
2246
  source;
2069
- records = /* @__PURE__ */ new Map();
2070
- closing = false;
2071
2247
  /**
2072
- * @param ctx - Host context carrying the subprocess and filesystem capabilities.
2248
+ * The upstream URL each panel last pointed its frame at, keyed by its client id.
2249
+ *
2250
+ * Only a fallback: a proxied document's own URLs all carry their target explicitly, because the
2251
+ * browser rewrites them from the injected `<base>`. What needs this map is a request the base
2252
+ * cannot reach — a `fetch()` from inside the page to a relative path, or a link with no base of
2253
+ * its own — and there is exactly one sensible target for those.
2254
+ */
2255
+ targets = /* @__PURE__ */ new Map();
2256
+ /** Every request currently in flight, so a dispose does not leave sockets open. */
2257
+ live = /* @__PURE__ */ new Set();
2258
+ closed = false;
2259
+ /** Whether the routes are registered; see {@link RouteState}. */
2260
+ routes = "absent";
2261
+ /**
2262
+ * @param ctx - Host context carrying the optional filesystem capability.
2073
2263
  * @param source - reads the current settings section; called per request.
2074
2264
  */
2075
2265
  constructor(ctx, source) {
@@ -2077,53 +2267,881 @@ var PanelTerminals = class {
2077
2267
  this.source = source;
2078
2268
  }
2079
2269
  /**
2080
- * Report whether a panel terminal can be allocated on this Host.
2081
- * @returns availability plus the shell that would answer.
2270
+ * What this surface is, for `describe()`.
2271
+ * @returns the route paths and whether the routes are actually mounted.
2082
2272
  */
2083
- describe() {
2084
- if (this.ctx.get("subprocess") === void 0) return {
2085
- available: false,
2086
- reason: "no subprocess capability is mounted: this deployment composes no @deepseek-ai/dsh-subprocess provider"
2087
- };
2088
- if (this.ctx.get("fs") === void 0) return {
2089
- available: false,
2090
- reason: "no filesystem capability is mounted: the terminal has no way to resolve its working directory"
2091
- };
2092
- return {
2093
- available: true,
2094
- detail: this.shell()
2095
- };
2273
+ info() {
2274
+ switch (this.routes) {
2275
+ case "mounted": return {
2276
+ fileRoute: FILE_ROUTE,
2277
+ proxyRoute: PROXY_ROUTE,
2278
+ available: true
2279
+ };
2280
+ case "absent": return {
2281
+ fileRoute: FILE_ROUTE,
2282
+ proxyRoute: PROXY_ROUTE,
2283
+ available: false,
2284
+ reason: "no web server capability is mounted: a workspace file has no same-origin URL to be framed from, and a dev server stays cross-origin"
2285
+ };
2286
+ case "ungated": return {
2287
+ fileRoute: FILE_ROUTE,
2288
+ proxyRoute: PROXY_ROUTE,
2289
+ available: false,
2290
+ reason: "this Host's connection exposes no request gate (requestRejection), so the preview routes are not mounted: without it they would serve workspace files to anything that can reach the port"
2291
+ };
2292
+ default: throw new TypeError(`advanced-sidebar: unexpected preview route state ${JSON.stringify(this.routes)}`);
2293
+ }
2096
2294
  }
2097
2295
  /**
2098
- * Allocate one terminal in a workspace.
2099
- * @param request - the directory and the panel's measured geometry.
2100
- * @param signal - cancellation of the allocation; a published terminal owns its later lifetime.
2101
- * @returns the handle, or a classified failure.
2296
+ * The URL one workspace file is framed from, or undefined when this Host serves no routes.
2297
+ * @param workspacePath - absolute Host workspace directory.
2298
+ * @param filePath - absolute Host path inside it.
2299
+ * @returns the same-origin path, query included.
2102
2300
  */
2103
- async open(request, signal) {
2104
- if (this.closing) return fail$1("closed", "the plugin is unloading");
2105
- const subprocess = this.ctx.get("subprocess");
2106
- if (subprocess === void 0) return fail$1("no-subprocess", "no subprocess capability is mounted");
2107
- const settings = this.source();
2108
- if ([...this.records.values()].filter((record$1) => record$1.running).length >= settings.maxTerminals) return fail$1("limit-reached", `${String(settings.maxTerminals)} panel terminals are already open`);
2109
- const workspace = await resolveWorkspace(this.ctx, request.workspacePath, signal);
2110
- if (!workspace.ok) return fail$1(workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied", workspace.rejection.message);
2111
- const shell = this.shell();
2112
- const { cols, rows } = clampGeometry(request.cols, request.rows);
2113
- let handle;
2114
- try {
2115
- handle = await subprocess.spawnTerminal({
2116
- argv: [shell],
2117
- cwd: workspace.value.processPath,
2118
- rows,
2119
- cols,
2120
- graceMs: settings.terminalGraceMs,
2121
- signal,
2122
- env: {
2123
- TERM: "xterm-256color",
2124
- COLUMNS: String(cols),
2125
- LINES: String(rows)
2126
- }
2301
+ fileUrl(workspacePath, filePath) {
2302
+ return this.info().available ? fileUrl(FILE_ROUTE, workspacePath, filePath) : void 0;
2303
+ }
2304
+ /**
2305
+ * The same-origin URL that proxies one loopback URL.
2306
+ * @param target - the loopback URL, already validated by the caller.
2307
+ * @returns the same-origin path, query included.
2308
+ */
2309
+ proxyUrl(target) {
2310
+ return proxyUrlFor(PROXY_ROUTE, target);
2311
+ }
2312
+ /**
2313
+ * Register every route with the mounted web server, each behind the connection's request gate.
2314
+ *
2315
+ * Registration goes through `ctx.inject(['connection', 'webServer'], …)` rather than a constructor
2316
+ * read: a headless deployment composes neither, and the inject face simply never runs, leaving the
2317
+ * rest of the plugin working. The connection is a hard requirement rather than an optional extra —
2318
+ * it is the only thing that can tell the GUI's own browser from any other client of the port — so a
2319
+ * connection without `requestRejection` mounts nothing and says so through {@link info}.
2320
+ */
2321
+ install() {
2322
+ this.ctx.inject(["connection", "webServer"], (routeCtx) => {
2323
+ const connection = Reflect.get(routeCtx, "connection");
2324
+ const webServer = Reflect.get(routeCtx, "webServer");
2325
+ const requestRejection = connection?.requestRejection;
2326
+ if (typeof requestRejection !== "function") {
2327
+ this.routes = "ungated";
2328
+ return () => {
2329
+ this.routes = "absent";
2330
+ };
2331
+ }
2332
+ const gate = (req) => requestRejection.call(connection, req);
2333
+ const disposeFile = webServer.register({
2334
+ kind: "exact",
2335
+ path: FILE_ROUTE,
2336
+ handler: (req, res) => refuseHttp(gate, req, res) ? void 0 : this.handleFile(req, res)
2337
+ });
2338
+ const disposeScratch = webServer.register({
2339
+ kind: "exact",
2340
+ path: SCRATCHPAD_ROUTE,
2341
+ handler: (req, res) => refuseHttp(gate, req, res) ? void 0 : this.handleScratchpad(req, res)
2342
+ });
2343
+ const disposeProxy = webServer.register({
2344
+ kind: "prefix",
2345
+ path: PROXY_ROUTE,
2346
+ handler: (req, res) => refuseHttp(gate, req, res) ? void 0 : this.handleProxy(req, res)
2347
+ });
2348
+ const disposeUpgrade = webServer.registerUpgrade({
2349
+ path: PROXY_ROUTE,
2350
+ handler: (req, socket, head) => {
2351
+ if (refuseUpgrade(gate, req, socket)) return;
2352
+ this.handleUpgrade(req, socket, head);
2353
+ }
2354
+ });
2355
+ this.routes = "mounted";
2356
+ return () => {
2357
+ this.routes = "absent";
2358
+ disposeUpgrade();
2359
+ disposeProxy();
2360
+ disposeScratch();
2361
+ disposeFile();
2362
+ };
2363
+ });
2364
+ }
2365
+ /** Forget every target and abort every in-flight request. Called from the plugin's teardown. */
2366
+ dispose() {
2367
+ this.closed = true;
2368
+ this.targets.clear();
2369
+ for (const upstream of [...this.live]) upstream.abort();
2370
+ this.live.clear();
2371
+ }
2372
+ /**
2373
+ * Describe one workspace file for the panel, or refuse it.
2374
+ * @param workspacePath - absolute Host workspace directory.
2375
+ * @param path - absolute Host path, or a path relative to the workspace.
2376
+ * @param signal - cancellation for the resolution and the metadata read.
2377
+ * @returns the file's kind, size, frame URL, and change token.
2378
+ */
2379
+ async info_(workspacePath, path, signal) {
2380
+ const resolved = await this.resolve(workspacePath, path, signal);
2381
+ if (!resolved.ok) return resolved.failure;
2382
+ return this.describeFile(resolved.workspace, resolved.target, resolved.display, signal);
2383
+ }
2384
+ /**
2385
+ * Serve one workspace file as the frame's document or as one of its subresources.
2386
+ * @param req - the incoming request.
2387
+ * @param res - the response the handler owns.
2388
+ */
2389
+ async handleFile(req, res) {
2390
+ if (req.method !== "GET" && req.method !== "HEAD") {
2391
+ send(res, 405, "text/plain; charset=utf-8", "only GET and HEAD are served here");
2392
+ return;
2393
+ }
2394
+ const query = new URL(req.url ?? "/", "http://x").searchParams;
2395
+ const resolved = await this.resolve(query.get("workspace") ?? void 0, query.get("path") ?? "", void 0);
2396
+ if (!resolved.ok) {
2397
+ res.setHeader("cache-control", "no-store");
2398
+ send(res, 404, "text/plain; charset=utf-8", resolved.failure.message);
2399
+ return;
2400
+ }
2401
+ const fs = this.ctx.get("fs");
2402
+ /* v8 ignore next -- `resolve` refuses before this point when the capability is absent. */
2403
+ if (fs === void 0) {
2404
+ send(res, 500, "text/plain; charset=utf-8", "filesystem capability withdrawn mid-request");
2405
+ return;
2406
+ }
2407
+ const stat = await fs.stat(resolved.target);
2408
+ if (stat === void 0 || stat.type !== "file") {
2409
+ res.setHeader("cache-control", "no-store");
2410
+ send(res, 404, "text/plain; charset=utf-8", `${resolved.display} is not a regular file`);
2411
+ return;
2412
+ }
2413
+ const size = stat.size ?? 0;
2414
+ const contentType = contentTypeOf(resolved.display);
2415
+ const etag = await this.etag(resolved.target, stat.version, size, stat.size);
2416
+ res.setHeader("cache-control", "no-store");
2417
+ res.setHeader("etag", etag);
2418
+ res.setHeader("last-modified", (/* @__PURE__ */ new Date()).toUTCString());
2419
+ if (req.headers["if-none-match"] === etag) {
2420
+ res.writeHead(304);
2421
+ res.end();
2422
+ return;
2423
+ }
2424
+ if (size > this.source().previewMaxFileBytes) {
2425
+ send(res, 413, "text/plain; charset=utf-8", `${resolved.display} is ${String(size)} bytes, over previewMaxFileBytes`);
2426
+ return;
2427
+ }
2428
+ res.setHeader("content-type", isTextual(contentType) ? `${contentType}; charset=utf-8` : contentType);
2429
+ res.setHeader("x-content-type-options", "nosniff");
2430
+ res.setHeader("content-encoding", "identity");
2431
+ const ranged = hasRangeRead(fs) ? fs : void 0;
2432
+ res.setHeader("accept-ranges", ranged === void 0 ? "none" : "bytes");
2433
+ const asked = ranged === void 0 ? void 0 : parseRange(req.headers.range, size);
2434
+ if (asked === "unsatisfiable") {
2435
+ res.setHeader("content-range", `bytes */${String(size)}`);
2436
+ send(res, 416, "text/plain; charset=utf-8", "the requested range is past the end of the file");
2437
+ return;
2438
+ }
2439
+ const range = typeof asked === "string" ? void 0 : asked;
2440
+ let bytes;
2441
+ try {
2442
+ bytes = range === void 0 ? await fs.readBytes(resolved.target, void 0, size) : await (ranged ?? fs).readByteRange(resolved.target, {
2443
+ offset: range.start,
2444
+ length: range.end - range.start + 1
2445
+ });
2446
+ } catch (error) {
2447
+ send(res, 500, "text/plain; charset=utf-8", error instanceof Error ? error.message : String(error));
2448
+ return;
2449
+ }
2450
+ let body = bytes;
2451
+ if (range === void 0 && contentType === "text/html" && size <= FALLBACK_READ_BYTES) body = new TextEncoder().encode(injectBase(decodeText(bytes), `${FILE_ROUTE}?workspace=${encodeQuery(resolved.workspace.processPath)}&path=`));
2452
+ if (range !== void 0) res.setHeader("content-range", `bytes ${String(range.start)}-${String(range.end)}/${String(size)}`);
2453
+ res.setHeader("content-length", String(body.byteLength));
2454
+ res.writeHead(range === void 0 ? 200 : 206);
2455
+ if (req.method === "HEAD") res.end();
2456
+ else res.end(Buffer.from(body));
2457
+ }
2458
+ /**
2459
+ * Serve text the panel posted, as an HTML document on this origin.
2460
+ *
2461
+ * The scratchpad renders as `text/html` rather than through `srcdoc` so that the frame's document
2462
+ * has a real URL with this origin: `document.baseURI`, relative `fetch`, and `window.location` all
2463
+ * then behave the way the page under test expects, and an agent's `eval` sees them.
2464
+ * @param req - the incoming request.
2465
+ * @param res - the response the handler owns.
2466
+ */
2467
+ handleScratchpad(req, res) {
2468
+ if (req.method !== "POST" && req.method !== "GET" && req.method !== "HEAD") {
2469
+ send(res, 405, "text/plain; charset=utf-8", "only GET, HEAD and POST are served here");
2470
+ return;
2471
+ }
2472
+ res.setHeader("cache-control", "no-store");
2473
+ res.setHeader("content-type", "text/html; charset=utf-8");
2474
+ res.setHeader("x-content-type-options", "nosniff");
2475
+ res.setHeader("content-encoding", "identity");
2476
+ if (req.method !== "POST") {
2477
+ res.setHeader("content-length", "0");
2478
+ res.writeHead(204);
2479
+ res.end();
2480
+ return;
2481
+ }
2482
+ const chunks = [];
2483
+ let total = 0;
2484
+ let refused = false;
2485
+ req.on("data", (chunk) => {
2486
+ if (refused) return;
2487
+ total += chunk.byteLength;
2488
+ if (total > SCRATCHPAD_MAX_BYTES) {
2489
+ refused = true;
2490
+ send(res, 413, "text/plain; charset=utf-8", "the scratchpad document is over its size limit");
2491
+ req.destroy();
2492
+ return;
2493
+ }
2494
+ chunks.push(chunk);
2495
+ });
2496
+ req.on("end", () => {
2497
+ if (refused) return;
2498
+ const document = Buffer.concat(chunks).toString("utf8");
2499
+ const body = Buffer.from(injectBase(document, "/"), "utf8");
2500
+ res.setHeader("content-length", String(body.byteLength));
2501
+ res.writeHead(200);
2502
+ if (req.method === "HEAD") res.end();
2503
+ else res.end(body);
2504
+ });
2505
+ req.on("error", () => {
2506
+ if (!refused) res.destroy();
2507
+ });
2508
+ }
2509
+ /**
2510
+ * Forward one request to a loopback upstream and stream the answer back.
2511
+ * @param req - the incoming request.
2512
+ * @param res - the response the handler owns.
2513
+ */
2514
+ async handleProxy(req, res) {
2515
+ if (this.closed) {
2516
+ send(res, 503, "text/plain; charset=utf-8", "the preview surface is unloading");
2517
+ return;
2518
+ }
2519
+ const url = new URL(req.url ?? "/", "http://x");
2520
+ const explicit = url.searchParams.get("url");
2521
+ const suffix = suffixOf(url.pathname);
2522
+ const target = explicit === null ? this.fallbackTarget(req, suffix) : validateProxyTarget(explicit);
2523
+ if (target === void 0) {
2524
+ send(res, 404, "text/plain; charset=utf-8", "this proxied request names no loopback target: the preview proxy forwards only requests that carry `?url=`, or that a bound preview frame could have produced");
2525
+ return;
2526
+ }
2527
+ if (!target.ok) {
2528
+ send(res, 403, "text/plain; charset=utf-8", target.message);
2529
+ return;
2530
+ }
2531
+ const upstreamUrl = new URL(target.url.href);
2532
+ if (explicit === null) {
2533
+ const basePath = upstreamUrl.pathname.replace(/\/$/u, "");
2534
+ if (suffix !== "") upstreamUrl.pathname = `${basePath}/${suffix}`;
2535
+ upstreamUrl.search = url.search;
2536
+ } else if (suffix !== "") {
2537
+ const basePath = upstreamUrl.pathname.replace(/\/$/u, "");
2538
+ upstreamUrl.pathname = suffix.startsWith(basePath.slice(1)) ? `/${suffix}` : `${basePath}/${suffix}`;
2539
+ const forwarded = new URLSearchParams(url.searchParams);
2540
+ forwarded.delete("url");
2541
+ const extra = forwarded.toString();
2542
+ upstreamUrl.search = extra === "" ? "" : `?${extra}`;
2543
+ }
2544
+ this.forward(req, res, upstreamUrl);
2545
+ }
2546
+ /**
2547
+ * Forward one upgraded connection to a loopback upstream.
2548
+ *
2549
+ * A dev server's live-reload socket is an optional convenience, not part of the inspection story:
2550
+ * a websocket carries no DOM and no console, and a page whose socket never opens still renders and
2551
+ * is still drivable. The tunnel is here because it is cheap — a raw `net` pipe with no protocol
2552
+ * knowledge — but a server that negotiates on a path other than the route's own root is not
2553
+ * tunnelled, because the web server's upgrade seat matches exact paths and claiming a wildcard
2554
+ * would collide with the app's own sockets.
2555
+ * @param req - the upgrade request.
2556
+ * @param socket - the client socket the handler owns.
2557
+ * @param head - bytes the parser already read past the request line.
2558
+ */
2559
+ handleUpgrade(req, socket, head) {
2560
+ const url = new URL(req.url ?? "/", "http://x");
2561
+ const explicit = url.searchParams.get("url");
2562
+ const suffix = suffixOf(url.pathname);
2563
+ const target = explicit === null ? this.fallbackTarget(req, suffix) : validateProxyTarget(explicit);
2564
+ if (target === void 0 || !target.ok) {
2565
+ socket.end("HTTP/1.1 404 Not Found\r\nconnection: close\r\n\r\n");
2566
+ return;
2567
+ }
2568
+ const upstreamUrl = new URL(target.url.href);
2569
+ const rest = explicit === null ? suffix : "";
2570
+ if (rest !== "") upstreamUrl.pathname = `${upstreamUrl.pathname.replace(/\/$/u, "")}/${rest.replace(/^\//u, "")}`;
2571
+ const port = upstreamUrl.port === "" ? upstreamUrl.protocol === "https:" ? 443 : 80 : Number(upstreamUrl.port);
2572
+ const upstream = connect({
2573
+ host: upstreamUrl.hostname.replace(/^\[|\]$/gu, ""),
2574
+ port
2575
+ });
2576
+ const close = () => {
2577
+ upstream.destroy();
2578
+ socket.destroy();
2579
+ };
2580
+ upstream.on("error", close);
2581
+ socket.on("error", close);
2582
+ upstream.on("connect", () => {
2583
+ const headers = {
2584
+ ...req.headers,
2585
+ host: upstreamUrl.host
2586
+ };
2587
+ const lines = [`${req.method ?? "GET"} ${upstreamUrl.pathname}${upstreamUrl.search} HTTP/1.1`];
2588
+ for (const [name, value] of Object.entries(headers)) {
2589
+ if (value === void 0) continue;
2590
+ const lowered = name.toLowerCase();
2591
+ if (lowered === "cookie") {
2592
+ const kept = withoutHostAuthCookies(value);
2593
+ if (kept !== void 0) lines.push(`cookie: ${kept}`);
2594
+ continue;
2595
+ }
2596
+ if (HOP_BY_HOP.has(lowered) && !UPGRADE_HANDSHAKE_HEADERS.has(lowered)) continue;
2597
+ if (Array.isArray(value)) for (const one of value) lines.push(`${name}: ${one}`);
2598
+ else lines.push(`${name}: ${value}`);
2599
+ }
2600
+ upstream.write(`${lines.join("\r\n")}\r\n\r\n`);
2601
+ if (head.byteLength > 0) upstream.write(head);
2602
+ upstream.pipe(socket);
2603
+ socket.pipe(upstream);
2604
+ });
2605
+ }
2606
+ /**
2607
+ * One upstream request, with the response streamed straight through.
2608
+ * @param req - the client request.
2609
+ * @param res - the client response.
2610
+ * @param upstreamUrl - the absolute loopback URL to fetch.
2611
+ */
2612
+ forward(req, res, upstreamUrl) {
2613
+ const headers = {};
2614
+ for (const [name, value] of Object.entries(req.headers)) {
2615
+ if (value === void 0) continue;
2616
+ const lowered = name.toLowerCase();
2617
+ if (HOP_BY_HOP.has(lowered) || REWRITTEN_REQUEST_HEADERS.has(lowered)) continue;
2618
+ if (lowered === "cookie") {
2619
+ const kept = withoutHostAuthCookies(value);
2620
+ if (kept !== void 0) headers[name] = kept;
2621
+ continue;
2622
+ }
2623
+ headers[name] = value;
2624
+ }
2625
+ headers.host = upstreamUrl.host;
2626
+ headers.accept = typeof req.headers.accept === "string" ? req.headers.accept : "*/*";
2627
+ const upstreamReq = (upstreamUrl.protocol === "https:" ? request$1 : request)({
2628
+ protocol: upstreamUrl.protocol,
2629
+ hostname: upstreamUrl.hostname.replace(/^\[|\]$/gu, ""),
2630
+ port: upstreamUrl.port === "" ? void 0 : Number(upstreamUrl.port),
2631
+ path: `${upstreamUrl.pathname}${upstreamUrl.search}`,
2632
+ method: req.method ?? "GET",
2633
+ headers
2634
+ }, (upstreamRes) => {
2635
+ const contentType = String(upstreamRes.headers["content-type"] ?? "");
2636
+ const streaming = contentType.includes("text/event-stream");
2637
+ res.setHeader("cache-control", streaming ? "no-cache" : "no-store");
2638
+ res.setHeader("x-content-type-options", "nosniff");
2639
+ res.setHeader("content-encoding", "identity");
2640
+ let injected;
2641
+ if (contentType.startsWith("text/html") && req.method !== "HEAD") {
2642
+ const chunks = [];
2643
+ upstreamRes.on("data", (chunk) => {
2644
+ chunks.push(chunk);
2645
+ });
2646
+ upstreamRes.on("end", () => {
2647
+ const text = Buffer.concat(chunks).toString("utf8");
2648
+ const base = baseOf(text) ?? `${PROXY_ROUTE}?url=${encodeQuery(upstreamUrl.href)}`;
2649
+ injected = Buffer.from(injectBase(text, base), "utf8");
2650
+ res.setHeader("content-length", String(injected.byteLength));
2651
+ res.writeHead(upstreamRes.statusCode ?? 502, forwardHeaders(upstreamRes));
2652
+ res.end(injected);
2653
+ });
2654
+ upstreamRes.on("error", () => {
2655
+ res.destroy();
2656
+ });
2657
+ upstreamReq.on("error", () => {
2658
+ if (!res.headersSent) send(res, 502, "text/plain; charset=utf-8", "the upstream dev server closed the connection");
2659
+ });
2660
+ return;
2661
+ }
2662
+ for (const [name, value] of Object.entries(upstreamRes.headers)) {
2663
+ if (value === void 0 || HOP_BY_HOP.has(name.toLowerCase())) continue;
2664
+ if (name.toLowerCase() === "set-cookie") {
2665
+ const kept = withoutHostAuthSetCookies(value);
2666
+ if (kept.length > 0) res.setHeader(name, kept);
2667
+ continue;
2668
+ }
2669
+ if (name.toLowerCase() === "content-type" && isTextual(String(value))) {
2670
+ res.setHeader(name, String(value).includes("charset") ? String(value) : `${String(value)}; charset=utf-8`);
2671
+ continue;
2672
+ }
2673
+ res.setHeader(name, value);
2674
+ }
2675
+ res.writeHead(upstreamRes.statusCode ?? 502);
2676
+ upstreamRes.pipe(res);
2677
+ upstreamRes.on("error", () => {
2678
+ res.destroy();
2679
+ });
2680
+ });
2681
+ const timeout = this.source().previewProxyTimeoutMs;
2682
+ upstreamReq.setTimeout(timeout, () => {
2683
+ upstreamReq.destroy(/* @__PURE__ */ new Error(`the upstream dev server did not answer within ${String(timeout)}ms`));
2684
+ });
2685
+ upstreamReq.on("error", (error) => {
2686
+ if (res.headersSent) {
2687
+ res.destroy();
2688
+ return;
2689
+ }
2690
+ send(res, 502, "text/plain; charset=utf-8", `the preview proxy could not reach ${upstreamUrl.origin}: ${error.message}`);
2691
+ });
2692
+ const upstream = { abort: () => {
2693
+ upstreamReq.destroy();
2694
+ } };
2695
+ this.live.add(upstream);
2696
+ res.on("close", () => {
2697
+ this.live.delete(upstream);
2698
+ upstreamReq.destroy();
2699
+ });
2700
+ req.pipe(upstreamReq);
2701
+ }
2702
+ /**
2703
+ * Resolve a workspace and one path inside it.
2704
+ * @param workspacePath - absolute Host workspace directory, absent to treat the path itself as one.
2705
+ * @param path - absolute Host path, or a path relative to the workspace.
2706
+ * @param signal - cancellation for the resolution.
2707
+ * @returns the workspace, the contained target, and the path as it should be displayed.
2708
+ */
2709
+ async resolve(workspacePath, path, signal) {
2710
+ if (path.trim() === "") return {
2711
+ ok: false,
2712
+ failure: {
2713
+ ok: false,
2714
+ code: "path-denied",
2715
+ message: "no path was given"
2716
+ }
2717
+ };
2718
+ const workspace = await resolveWorkspace(this.ctx, workspacePath ?? path, signal);
2719
+ if (!workspace.ok) return {
2720
+ ok: false,
2721
+ failure: {
2722
+ ok: false,
2723
+ code: workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
2724
+ message: workspace.rejection.message
2725
+ }
2726
+ };
2727
+ if (workspacePath === void 0) {
2728
+ const refused$1 = await this.outsideRegisteredWorkspaces(workspace.value.target, path, signal);
2729
+ if (refused$1 !== void 0) return {
2730
+ ok: false,
2731
+ failure: refused$1
2732
+ };
2733
+ return {
2734
+ ok: true,
2735
+ workspace: workspace.value,
2736
+ target: workspace.value.target,
2737
+ display: path
2738
+ };
2739
+ }
2740
+ const inside = await resolveInside(this.ctx, workspace.value, path, signal);
2741
+ if (!inside.ok) return {
2742
+ ok: false,
2743
+ failure: {
2744
+ ok: false,
2745
+ code: inside.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
2746
+ message: inside.rejection.message
2747
+ }
2748
+ };
2749
+ const refused = await this.outsideRegisteredWorkspaces(inside.value.target, path, signal);
2750
+ if (refused !== void 0) return {
2751
+ ok: false,
2752
+ failure: refused
2753
+ };
2754
+ return {
2755
+ ok: true,
2756
+ workspace: workspace.value,
2757
+ target: inside.value.target,
2758
+ display: inside.value.processPath
2759
+ };
2760
+ }
2761
+ /**
2762
+ * Refuse a target that sits inside none of the harness's registered workspaces.
2763
+ *
2764
+ * The workspace a request names is only the root its relative paths hang from; it is not evidence
2765
+ * that the directory is one the operator opened. `?workspace=/` would otherwise make the whole disk
2766
+ * a workspace, so the canonical target is checked against the registry's own canonical paths — the
2767
+ * list the sidebar shows, and nothing a request can add to. A subdirectory of a registered
2768
+ * workspace passes, because the file is still inside what the operator opened.
2769
+ *
2770
+ * Each registered path is resolved through the same filesystem as the target, so a symlinked
2771
+ * workspace compares as its real directory on both sides. A registered directory that no longer
2772
+ * resolves (deleted, unmounted) simply contains nothing.
2773
+ * @param target - the canonical target the request resolved to.
2774
+ * @param path - the path as asked, for the refusal message.
2775
+ * @param signal - cancellation for the resolutions.
2776
+ * @returns the refusal, or undefined when a registered workspace contains the target.
2777
+ */
2778
+ async outsideRegisteredWorkspaces(target, path, signal) {
2779
+ const fs = this.ctx.get("fs");
2780
+ /* v8 ignore next 3 -- the caller resolved the target through the same service moments earlier. */
2781
+ if (fs === void 0) return {
2782
+ ok: false,
2783
+ code: "no-filesystem",
2784
+ message: "filesystem capability withdrawn mid-request"
2785
+ };
2786
+ const registry = this.ctx.get("workspaceRegistry");
2787
+ if (registry === void 0) return {
2788
+ ok: false,
2789
+ code: "path-denied",
2790
+ message: "no workspace registry is mounted, so no preview file can be proved to belong to a workspace"
2791
+ };
2792
+ const registered = registry.list().map((workspace) => workspace.path);
2793
+ if ((await Promise.allSettled(registered.map((root) => fs.resolve(root, signal === void 0 ? {} : { signal })))).some((root) => root.status === "fulfilled" && fs.contains(root.value, target))) return void 0;
2794
+ return {
2795
+ ok: false,
2796
+ code: "path-denied",
2797
+ message: `${path} is outside every registered workspace`
2798
+ };
2799
+ }
2800
+ /**
2801
+ * Describe one already-resolved file.
2802
+ * @param workspace - the resolved workspace.
2803
+ * @param target - the resolved target.
2804
+ * @param display - the path to report back and to base the content type on.
2805
+ * @param signal - cancellation for the metadata and token reads.
2806
+ * @returns the description.
2807
+ */
2808
+ async describeFile(workspace, target, display, signal) {
2809
+ const fs = this.ctx.get("fs");
2810
+ /* v8 ignore next -- the caller resolved the workspace through the same service moments earlier. */
2811
+ if (fs === void 0) return {
2812
+ ok: false,
2813
+ code: "no-filesystem",
2814
+ message: "filesystem capability withdrawn mid-request"
2815
+ };
2816
+ const stat = await fs.stat(target, signal);
2817
+ if (stat === void 0 || stat.type !== "file") return {
2818
+ ok: false,
2819
+ code: "not-a-file",
2820
+ message: `${display} is not a regular file`
2821
+ };
2822
+ const size = stat.size ?? 0;
2823
+ const contentType = contentTypeOf(display);
2824
+ const kind = classifyFile(display, contentType);
2825
+ const name = display.slice(display.lastIndexOf("/") + 1);
2826
+ const url = kind === "other" || !this.info().available ? void 0 : fileUrl(FILE_ROUTE, workspace.processPath, display);
2827
+ return {
2828
+ ok: true,
2829
+ path: display,
2830
+ name,
2831
+ kind,
2832
+ contentType,
2833
+ bytes: size,
2834
+ withinLimit: size <= this.source().previewMaxFileBytes,
2835
+ ...url === void 0 ? {} : { url },
2836
+ token: await this.etag(target, stat.version, size, stat.size, signal),
2837
+ regular: true
2838
+ };
2839
+ }
2840
+ /**
2841
+ * A weak validator for one file: the backend's version, its size, and a digest of its head.
2842
+ * @param target - the resolved target.
2843
+ * @param version - the backend's opaque freshness token.
2844
+ * @param size - the byte size; `undefined` asks for no probe read.
2845
+ * @param reportedSize - the size the backend reported, which may be absent.
2846
+ * @param signal - cancellation for the probe read.
2847
+ * @returns the ETag body, quotes included.
2848
+ */
2849
+ async etag(target, version, size, reportedSize, signal) {
2850
+ const hash = createHash("sha1");
2851
+ hash.update(String(version));
2852
+ hash.update(`:${String(size)}:${String(reportedSize ?? "")}`);
2853
+ const fs = this.ctx.get("fs");
2854
+ if (fs !== void 0 && size > 0) try {
2855
+ const window = Math.min(size, TOKEN_PROBE_BYTES);
2856
+ const head = hasRangeRead(fs) ? await fs.readByteRange(target, {
2857
+ offset: 0,
2858
+ length: window
2859
+ }, signal) : await fs.readBytes(target, signal, window);
2860
+ hash.update(head);
2861
+ } catch {}
2862
+ return `W/"${hash.digest("hex").slice(0, 32)}"`;
2863
+ }
2864
+ /**
2865
+ * The target one subresource request should be forwarded to.
2866
+ * @param req - the subresource request.
2867
+ * @param suffix - the path below the proxy route, without a leading slash.
2868
+ * @returns the validated target, or undefined when this Host holds none for the caller.
2869
+ */
2870
+ fallbackTarget(req, suffix) {
2871
+ const client = req.headers["x-dsh-preview-client"];
2872
+ const key = typeof client === "string" && client !== "" ? client : void 0;
2873
+ const known = key === void 0 ? void 0 : this.targets.get(key);
2874
+ if (known !== void 0) {
2875
+ const parsed = validateProxyTarget(known);
2876
+ if (parsed.ok) return parsed;
2877
+ }
2878
+ if (this.targets.size === 1) {
2879
+ const only = [...this.targets.values()][0];
2880
+ /* v8 ignore next -- `size === 1` guarantees the element exists. */
2881
+ if (only !== void 0) {
2882
+ const parsed = validateProxyTarget(only);
2883
+ if (parsed.ok) return parsed;
2884
+ }
2885
+ }
2886
+ }
2887
+ /**
2888
+ * Record the target one panel is framing, so its subresources resolve.
2889
+ * @param clientId - the panel's id.
2890
+ * @param target - the upstream URL, or undefined when the panel stopped framing one.
2891
+ */
2892
+ rememberTarget(clientId, target) {
2893
+ if (target === void 0) this.targets.delete(clientId);
2894
+ else this.targets.set(clientId, target);
2895
+ }
2896
+ };
2897
+ /**
2898
+ * The path below the proxy route, without a leading slash.
2899
+ * @param pathname - the request's pathname.
2900
+ * @returns the suffix, or the empty string at the route's own root.
2901
+ */
2902
+ function suffixOf(pathname) {
2903
+ return pathname.slice(PROXY_ROUTE.length).replace(/^\/+/u, "");
2904
+ }
2905
+ /**
2906
+ * The `<base href>` a proxied document already declares, when it declares one.
2907
+ *
2908
+ * A dev-server framework frequently injects its own base tag (`vite` does not, `next` does), and a
2909
+ * second one would be ignored by the parser in favour of the first. Reading it back means this
2910
+ * proxy either keeps the document's own answer or supplies one, never both.
2911
+ * @param html - the document text.
2912
+ * @returns the declared href, or undefined.
2913
+ */
2914
+ function baseOf(html) {
2915
+ const match = /<base\s[^>]*href\s*=\s*("([^"]*)"|'([^']*)')/iu.exec(html);
2916
+ if (match === null) return void 0;
2917
+ return match[2] ?? match[3];
2918
+ }
2919
+ /**
2920
+ * Upstream headers minus the hop-by-hop set, for the buffered HTML branch.
2921
+ * @param res - the upstream response.
2922
+ * @returns the headers to copy.
2923
+ */
2924
+ function forwardHeaders(res) {
2925
+ const headers = {};
2926
+ for (const [name, value] of Object.entries(res.headers)) {
2927
+ if (value === void 0 || HOP_BY_HOP.has(name.toLowerCase()) || name.toLowerCase() === "content-length") continue;
2928
+ if (name.toLowerCase() === "set-cookie") {
2929
+ const kept = withoutHostAuthSetCookies(value);
2930
+ if (kept.length > 0) headers[name] = [...kept];
2931
+ continue;
2932
+ }
2933
+ headers[name] = value;
2934
+ }
2935
+ return headers;
2936
+ }
2937
+ /**
2938
+ * Answer a refused HTTP request, when the gate refuses it.
2939
+ *
2940
+ * The body is one fixed word: the gate's reasons (which authority, which cookie) are exactly what a
2941
+ * probing client wants, and the GUI's own browser never sees this answer.
2942
+ * @param gate - the connection's request gate.
2943
+ * @param req - the incoming request.
2944
+ * @param res - the response the handler owns.
2945
+ * @returns true when the request was refused and answered.
2946
+ */
2947
+ function refuseHttp(gate, req, res) {
2948
+ const rejection = gate(req);
2949
+ if (rejection === void 0) return false;
2950
+ send(res, rejection, "text/plain; charset=utf-8", rejectionText(rejection));
2951
+ return true;
2952
+ }
2953
+ /**
2954
+ * Close a refused upgrade with a well-formed HTTP answer, when the gate refuses it.
2955
+ *
2956
+ * Written as a complete response, the way the harness's own Typert websocket refuses one, so a
2957
+ * client sees `401`/`403` rather than a reset it would retry.
2958
+ * @param gate - the connection's request gate.
2959
+ * @param req - the upgrade request.
2960
+ * @param socket - the client socket the handler owns.
2961
+ * @returns true when the upgrade was refused and the socket closed.
2962
+ */
2963
+ function refuseUpgrade(gate, req, socket) {
2964
+ const rejection = gate(req);
2965
+ if (rejection === void 0) return false;
2966
+ const body = rejectionText(rejection);
2967
+ socket.end([
2968
+ `HTTP/1.1 ${String(rejection)} ${rejection === 401 ? "Unauthorized" : "Forbidden"}`,
2969
+ "connection: close",
2970
+ "content-type: text/plain; charset=utf-8",
2971
+ `content-length: ${String(Buffer.byteLength(body))}`,
2972
+ "",
2973
+ body
2974
+ ].join("\r\n"));
2975
+ return true;
2976
+ }
2977
+ /**
2978
+ * The body a refusal carries.
2979
+ * @param rejection - the gate's status.
2980
+ * @returns the word for it.
2981
+ */
2982
+ function rejectionText(rejection) {
2983
+ switch (rejection) {
2984
+ case 401: return "unauthorized";
2985
+ case 403: return "forbidden";
2986
+ default: throw new TypeError(`advanced-sidebar: unexpected rejection ${String(rejection)}`);
2987
+ }
2988
+ }
2989
+ /**
2990
+ * A `Cookie` request header minus the harness's own browser-session cookie.
2991
+ * @param header - the raw header; Node joins repeated `Cookie` headers into one string.
2992
+ * @returns the remaining pairs, or undefined when nothing remains.
2993
+ */
2994
+ function withoutHostAuthCookies(header) {
2995
+ const kept = (typeof header === "string" ? header : header.join("; ")).split(";").map((pair) => pair.trim()).filter((pair) => pair !== "" && !pair.startsWith(HOST_AUTH_COOKIE_PREFIX));
2996
+ return kept.length === 0 ? void 0 : kept.join("; ");
2997
+ }
2998
+ /**
2999
+ * Upstream `Set-Cookie` values minus any that would replace the harness's own session cookie.
3000
+ * @param header - one value or the list Node collects.
3001
+ * @returns the values a dev server may still set on this origin.
3002
+ */
3003
+ function withoutHostAuthSetCookies(header) {
3004
+ return (typeof header === "string" ? [header] : header).filter((value) => !value.trimStart().startsWith(HOST_AUTH_COOKIE_PREFIX));
3005
+ }
3006
+ /**
3007
+ * Parse one `Range` header against a known size.
3008
+ *
3009
+ * Only `bytes=` and only a single range are honoured: a multipart range costs a multipart encoder to
3010
+ * serve, and the one caller that matters — a `<video>` scrubber — asks for one range at a time.
3011
+ * @param header - the raw header value.
3012
+ * @param size - the file's size in bytes.
3013
+ * @returns the range, undefined for a full response, or `unsatisfiable`.
3014
+ */
3015
+ function parseRange(header, size) {
3016
+ if (header === void 0 || !header.startsWith("bytes=")) return void 0;
3017
+ const parts = (header.slice(6).split(",")[0]?.trim() ?? "").split("-");
3018
+ if (parts.length !== 2) return void 0;
3019
+ const [rawStart, rawEnd] = parts;
3020
+ const start = rawStart === void 0 || rawStart === "" ? void 0 : Number.parseInt(rawStart, 10);
3021
+ const end = rawEnd === void 0 || rawEnd === "" ? void 0 : Number.parseInt(rawEnd, 10);
3022
+ if (start !== void 0 && Number.isNaN(start)) return void 0;
3023
+ if (end !== void 0 && Number.isNaN(end)) return void 0;
3024
+ if (start === void 0 && end === void 0) return void 0;
3025
+ if (size === 0) return "unsatisfiable";
3026
+ if (start === void 0) {
3027
+ const length = end ?? 0;
3028
+ if (length <= 0) return "unsatisfiable";
3029
+ return {
3030
+ start: Math.max(0, size - length),
3031
+ end: size - 1
3032
+ };
3033
+ }
3034
+ if (start >= size) return "unsatisfiable";
3035
+ const last = end === void 0 ? size - 1 : Math.min(end, size - 1);
3036
+ if (last < start) return "unsatisfiable";
3037
+ return {
3038
+ start,
3039
+ end: last
3040
+ };
3041
+ }
3042
+ /**
3043
+ * Answer one request with a short, uncached body.
3044
+ * @param res - the response.
3045
+ * @param status - the HTTP status.
3046
+ * @param contentType - the MIME type.
3047
+ * @param body - the text to send.
3048
+ */
3049
+ function send(res, status, contentType, body) {
3050
+ if (res.headersSent) {
3051
+ res.end();
3052
+ return;
3053
+ }
3054
+ res.setHeader("content-type", contentType);
3055
+ res.setHeader("cache-control", "no-store");
3056
+ res.setHeader("content-encoding", "identity");
3057
+ res.setHeader("content-length", String(Buffer.byteLength(body)));
3058
+ res.writeHead(status);
3059
+ res.end(body);
3060
+ }
3061
+
3062
+ //#endregion
3063
+ //#region tsbuild/host/terminals.js
3064
+ /** Compose one classified failure. */
3065
+ function fail$1(code, message) {
3066
+ return {
3067
+ ok: false,
3068
+ code,
3069
+ message
3070
+ };
3071
+ }
3072
+ /** Terminal geometry the substrate will accept, whatever the panel measured. */
3073
+ function clampGeometry(cols, rows) {
3074
+ const bound = (value, low, high) => Number.isFinite(value) ? Math.min(Math.max(Math.round(value), low), high) : low;
3075
+ return {
3076
+ cols: bound(cols, 20, 500),
3077
+ rows: bound(rows, 5, 200)
3078
+ };
3079
+ }
3080
+ /**
3081
+ * Owns every panel terminal in the process. One instance is created by the service and disposed
3082
+ * with it, which is what guarantees no shell outlives the plugin.
3083
+ */
3084
+ var PanelTerminals = class {
3085
+ ctx;
3086
+ source;
3087
+ records = /* @__PURE__ */ new Map();
3088
+ closing = false;
3089
+ /**
3090
+ * @param ctx - Host context carrying the subprocess and filesystem capabilities.
3091
+ * @param source - reads the current settings section; called per request.
3092
+ */
3093
+ constructor(ctx, source) {
3094
+ this.ctx = ctx;
3095
+ this.source = source;
3096
+ }
3097
+ /**
3098
+ * Report whether a panel terminal can be allocated on this Host.
3099
+ * @returns availability plus the shell that would answer.
3100
+ */
3101
+ describe() {
3102
+ if (this.ctx.get("subprocess") === void 0) return {
3103
+ available: false,
3104
+ reason: "no subprocess capability is mounted: this deployment composes no @deepseek-ai/dsh-subprocess provider"
3105
+ };
3106
+ if (this.ctx.get("fs") === void 0) return {
3107
+ available: false,
3108
+ reason: "no filesystem capability is mounted: the terminal has no way to resolve its working directory"
3109
+ };
3110
+ return {
3111
+ available: true,
3112
+ detail: this.shell()
3113
+ };
3114
+ }
3115
+ /**
3116
+ * Allocate one terminal in a workspace.
3117
+ * @param request - the directory and the panel's measured geometry.
3118
+ * @param signal - cancellation of the allocation; a published terminal owns its later lifetime.
3119
+ * @returns the handle, or a classified failure.
3120
+ */
3121
+ async open(request$2, signal) {
3122
+ if (this.closing) return fail$1("closed", "the plugin is unloading");
3123
+ const subprocess = this.ctx.get("subprocess");
3124
+ if (subprocess === void 0) return fail$1("no-subprocess", "no subprocess capability is mounted");
3125
+ const settings = this.source();
3126
+ if ([...this.records.values()].filter((record$1) => record$1.running).length >= settings.maxTerminals) return fail$1("limit-reached", `${String(settings.maxTerminals)} panel terminals are already open`);
3127
+ const workspace = await resolveWorkspace(this.ctx, request$2.workspacePath, signal);
3128
+ if (!workspace.ok) return fail$1(workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied", workspace.rejection.message);
3129
+ const shell = this.shell();
3130
+ const { cols, rows } = clampGeometry(request$2.cols, request$2.rows);
3131
+ let handle;
3132
+ try {
3133
+ handle = await subprocess.spawnTerminal({
3134
+ argv: [shell],
3135
+ cwd: workspace.value.processPath,
3136
+ rows,
3137
+ cols,
3138
+ graceMs: settings.terminalGraceMs,
3139
+ signal,
3140
+ env: {
3141
+ TERM: "xterm-256color",
3142
+ COLUMNS: String(cols),
3143
+ LINES: String(rows)
3144
+ }
2127
3145
  });
2128
3146
  } catch (error) {
2129
3147
  return fail$1("spawn-failed", error instanceof Error ? error.message : String(error));
@@ -2154,11 +3172,11 @@ var PanelTerminals = class {
2154
3172
  * @param request - the handle and the offset already rendered.
2155
3173
  * @returns the delta and the process state, or a classified failure.
2156
3174
  */
2157
- read(request) {
2158
- const record = this.records.get(request.terminalId);
2159
- if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request.terminalId}`);
3175
+ read(request$2) {
3176
+ const record = this.records.get(request$2.terminalId);
3177
+ if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request$2.terminalId}`);
2160
3178
  const total = record.base + record.buffer.length;
2161
- const from = Number.isFinite(request.fromOffset) ? Math.max(0, Math.floor(request.fromOffset)) : 0;
3179
+ const from = Number.isFinite(request$2.fromOffset) ? Math.max(0, Math.floor(request$2.fromOffset)) : 0;
2162
3180
  const lossy = from < record.base;
2163
3181
  const text = lossy ? record.buffer : record.buffer.slice(Math.min(from - record.base, record.buffer.length));
2164
3182
  return {
@@ -2179,12 +3197,12 @@ var PanelTerminals = class {
2179
3197
  * @param request - the handle and the text to deliver verbatim.
2180
3198
  * @returns settlement, or a classified failure.
2181
3199
  */
2182
- async write(request) {
2183
- const record = this.records.get(request.terminalId);
2184
- if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request.terminalId}`);
3200
+ async write(request$2) {
3201
+ const record = this.records.get(request$2.terminalId);
3202
+ if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request$2.terminalId}`);
2185
3203
  if (!record.running) return fail$1("unknown-terminal", `panel terminal ${record.id} has exited`);
2186
3204
  try {
2187
- await record.handle.write(request.data);
3205
+ await record.handle.write(request$2.data);
2188
3206
  } catch (error) {
2189
3207
  return fail$1("spawn-failed", error instanceof Error ? error.message : String(error));
2190
3208
  }
@@ -2195,12 +3213,12 @@ var PanelTerminals = class {
2195
3213
  * @param request - the handle and the signal.
2196
3214
  * @returns settlement, or a classified failure.
2197
3215
  */
2198
- async signal(request) {
2199
- const record = this.records.get(request.terminalId);
2200
- if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request.terminalId}`);
3216
+ async signal(request$2) {
3217
+ const record = this.records.get(request$2.terminalId);
3218
+ if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request$2.terminalId}`);
2201
3219
  if (!record.running) return fail$1("unknown-terminal", `panel terminal ${record.id} has exited`);
2202
3220
  try {
2203
- await record.handle.signalForeground(request.signal);
3221
+ await record.handle.signalForeground(request$2.signal);
2204
3222
  } catch {}
2205
3223
  return { ok: true };
2206
3224
  }
@@ -2209,9 +3227,9 @@ var PanelTerminals = class {
2209
3227
  * @param request - the handle.
2210
3228
  * @returns settlement, or a classified failure.
2211
3229
  */
2212
- async close(request) {
2213
- const record = this.records.get(request.terminalId);
2214
- if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request.terminalId}`);
3230
+ async close(request$2) {
3231
+ const record = this.records.get(request$2.terminalId);
3232
+ if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request$2.terminalId}`);
2215
3233
  this.records.delete(record.id);
2216
3234
  await terminateQuietly(record.handle);
2217
3235
  return { ok: true };
@@ -2346,13 +3364,13 @@ var TaskController = class {
2346
3364
  * @param request - the owning session and the task id.
2347
3365
  * @returns what the registry did, or a classified failure.
2348
3366
  */
2349
- async kill(request) {
3367
+ async kill(request$2) {
2350
3368
  if (!this.source().allowTaskKill) return fail("disabled", "stopping a background task is switched off in the advanced-sidebar settings");
2351
- const bound = this.bind(request.sessionId, request.taskId);
3369
+ const bound = this.bind(request$2.sessionId, request$2.taskId);
2352
3370
  if ("failure" in bound) return bound.failure;
2353
3371
  try {
2354
3372
  const outcome = bound.jobs.kill(bound.id, bound.agent, "stopped from the DeepSeek Harness sidebar");
2355
- await this.absorb(request.sessionId, request.taskId);
3373
+ await this.absorb(request$2.sessionId, request$2.taskId);
2356
3374
  return {
2357
3375
  ok: true,
2358
3376
  outcome
@@ -2366,9 +3384,9 @@ var TaskController = class {
2366
3384
  * @param request - the owning session and the task id.
2367
3385
  * @returns the accumulated output, or a classified failure.
2368
3386
  */
2369
- async output(request) {
3387
+ async output(request$2) {
2370
3388
  if (!this.source().showTaskOutput) return fail("disabled", "task output is switched off in the advanced-sidebar settings");
2371
- const bound = this.bind(request.sessionId, request.taskId);
3389
+ const bound = this.bind(request$2.sessionId, request$2.taskId);
2372
3390
  if ("failure" in bound) return bound.failure;
2373
3391
  let snapshot;
2374
3392
  try {
@@ -2376,20 +3394,20 @@ var TaskController = class {
2376
3394
  } catch (error) {
2377
3395
  return fail("unknown-task", error instanceof Error ? error.message : String(error));
2378
3396
  }
2379
- const retained = this.collected.get(request.taskId) ?? "";
3397
+ const retained = this.collected.get(request$2.taskId) ?? "";
2380
3398
  if (isLive(snapshot) || !snapshot.reported) return {
2381
3399
  ok: true,
2382
- taskId: request.taskId,
3400
+ taskId: request$2.taskId,
2383
3401
  readable: retained !== "",
2384
3402
  text: retained,
2385
3403
  reason: isLive(snapshot) ? "the task is still running; its output stream belongs to the model until the task settles" : "the task has settled but its completion has not been reported to the model yet"
2386
3404
  };
2387
- await this.absorb(request.sessionId, request.taskId);
3405
+ await this.absorb(request$2.sessionId, request$2.taskId);
2388
3406
  return {
2389
3407
  ok: true,
2390
- taskId: request.taskId,
3408
+ taskId: request$2.taskId,
2391
3409
  readable: true,
2392
- text: this.collected.get(request.taskId) ?? ""
3410
+ text: this.collected.get(request$2.taskId) ?? ""
2393
3411
  };
2394
3412
  }
2395
3413
  /** Drop retained output. Called from the service's teardown effect. */
@@ -2432,6 +3450,388 @@ var TaskController = class {
2432
3450
  }
2433
3451
  };
2434
3452
 
3453
+ //#endregion
3454
+ //#region tsbuild/host/ui-bridge.js
3455
+ /**
3456
+ * How many commands one panel may have queued without polling.
3457
+ *
3458
+ * A person presses one button at a time and a model issues one tool call at a time, so a backlog
3459
+ * past a handful is a panel that left; the cap is what turns that into a refusal instead of growth.
3460
+ */
3461
+ const QUEUE_CAP = 32;
3462
+ /**
3463
+ * How many console entries are retained per panel.
3464
+ *
3465
+ * A dev server's client bundle can log per animation frame; the retained window is what a model
3466
+ * reads back, and the cursor makes the loss visible rather than silent.
3467
+ */
3468
+ const CONSOLE_CAP = 1e3;
3469
+ /**
3470
+ * How many characters of one console line are kept. */
3471
+ const CONSOLE_LINE_CAP = 8192;
3472
+ /**
3473
+ * The result kind each command kind must answer with.
3474
+ *
3475
+ * A panel that answered a `dom` command with an `ack` would leave the model reading a sentence where
3476
+ * it asked for markup, and nothing else in the chain would notice. The table is what turns that into
3477
+ * a reported failure instead of a confusing one, and it is exhaustive over the command union so a
3478
+ * new kind cannot be added without deciding what it returns.
3479
+ */
3480
+ const EXPECTED_RESULT = {
3481
+ open: "ack",
3482
+ dom: "dom",
3483
+ eval: "eval",
3484
+ console: "console",
3485
+ click: "ack",
3486
+ input: "ack",
3487
+ reload: "ack",
3488
+ resize: "ack",
3489
+ close: "ack"
3490
+ };
3491
+ /**
3492
+ * The command queue, the panel registry, and the console buffer.
3493
+ *
3494
+ * Held by the Host service and disposed with it, so no timer survives an unload.
3495
+ */
3496
+ var PreviewBindings = class {
3497
+ options;
3498
+ panels = /* @__PURE__ */ new Map();
3499
+ /**
3500
+ * Panels that said they were done, so a later bind cannot resurrect them.
3501
+ *
3502
+ * A closed dock's queued work must fail, and "closed" is a fact only the browser knows: without
3503
+ * this, a bind request the Host never asked for would put a dead tab back on the roster and the
3504
+ * model would wait on it. A fresh mount generates a fresh client id, so a reload is unaffected.
3505
+ */
3506
+ retired = /* @__PURE__ */ new Set();
3507
+ inFlight = /* @__PURE__ */ new Map();
3508
+ closed = false;
3509
+ /**
3510
+ * @param options - reads the current deadlines; called per operation so an edited settings
3511
+ * section reaches the next command with no registration to rebuild.
3512
+ */
3513
+ constructor(options) {
3514
+ this.options = options;
3515
+ }
3516
+ /** How many panels have polled recently enough to be considered present. */
3517
+ get livePanels() {
3518
+ return [...this.panels.values()].filter((panel) => this.isLive(panel)).length;
3519
+ }
3520
+ /**
3521
+ * Record a panel's state. Called by every poll, so it doubles as the liveness heartbeat.
3522
+ * @param bind - what the panel reported.
3523
+ * @returns the panel's id, for a caller that wants to address it later.
3524
+ */
3525
+ bind(bind) {
3526
+ const existing = this.panels.get(bind.clientId);
3527
+ if (existing === void 0) {
3528
+ this.panels.set(bind.clientId, {
3529
+ clientId: bind.clientId,
3530
+ sessionId: bind.sessionId,
3531
+ bind,
3532
+ polledAt: this.now(),
3533
+ queue: [],
3534
+ console: [],
3535
+ consoleBase: 0,
3536
+ consoleTotal: 0,
3537
+ controls: []
3538
+ });
3539
+ return bind.clientId;
3540
+ }
3541
+ existing.bind = bind;
3542
+ existing.polledAt = this.now();
3543
+ return bind.clientId;
3544
+ }
3545
+ /**
3546
+ * Take everything queued for one panel.
3547
+ *
3548
+ * The poll is also the heartbeat, so a stale panel is refreshed even when it has no work: what the
3549
+ * tool needs to know is that the tab is alive, not that it is busy.
3550
+ * @param clientId - the panel asking.
3551
+ * @param mounted - whether a preview surface is actually rendered; false leaves the queue alone.
3552
+ * @param console - entries the panel observed since its last report.
3553
+ * @returns the work to execute and the interval after which this poll is stale.
3554
+ */
3555
+ poll(clientId, mounted, console = []) {
3556
+ const options = this.options();
3557
+ const panel = this.panels.get(clientId);
3558
+ if (panel === void 0) return {
3559
+ message: {
3560
+ commands: [],
3561
+ controls: []
3562
+ },
3563
+ bindTtlMs: options.bindTtlMs
3564
+ };
3565
+ panel.polledAt = this.now();
3566
+ this.appendConsole(panel, console);
3567
+ if (!mounted) {
3568
+ this.dropPanelWork(panel.clientId, "the preview panel is open but shows no preview surface");
3569
+ return {
3570
+ message: {
3571
+ commands: [],
3572
+ controls: []
3573
+ },
3574
+ bindTtlMs: options.bindTtlMs
3575
+ };
3576
+ }
3577
+ const commands = panel.queue.splice(0, panel.queue.length);
3578
+ for (const command of commands) {
3579
+ const record = this.inFlight.get(command.id);
3580
+ if (record !== void 0) record.delivered = true;
3581
+ }
3582
+ return {
3583
+ message: {
3584
+ commands,
3585
+ controls: panel.controls.splice(0, panel.controls.length)
3586
+ },
3587
+ bindTtlMs: options.bindTtlMs
3588
+ };
3589
+ }
3590
+ /**
3591
+ * A panel's state, or the panel itself when it has never reported in.
3592
+ *
3593
+ * This is what lets a tool call wake a dock that has not polled yet — the operator opens the
3594
+ * Preview panel and a model asks for a DOM reading in the same second. Only the FIRST bind is
3595
+ * stored without a poll behind it: once a panel is known, its liveness rules, so a closed tab
3596
+ * cannot be resurrected by a bind request it never sent.
3597
+ * @param bind - what the panel reported.
3598
+ * @returns nothing; the caller polls afterwards.
3599
+ */
3600
+ bindAt(bind) {
3601
+ if (this.panels.has(bind.clientId) || this.retired.has(bind.clientId)) return;
3602
+ this.bind(bind);
3603
+ }
3604
+ /**
3605
+ * Record what one command did, and append any console lines it carried.
3606
+ *
3607
+ * A result whose command already timed out is accepted and dropped: the deadline fired, the tool
3608
+ * has its answer, and an operator's panel must not be told its report was invalid. A result of the
3609
+ * WRONG KIND is a different matter — it means the two halves disagree about the command, which is
3610
+ * a defect worth reporting rather than a late answer worth ignoring.
3611
+ * @param clientId - the panel reporting.
3612
+ * @param id - the command id.
3613
+ * @param outcome - success with a result, or the reason it failed.
3614
+ * @param console - entries the panel observed alongside the result.
3615
+ * @returns true when the id matched an in-flight command.
3616
+ */
3617
+ post(clientId, id, outcome, console = []) {
3618
+ const panel = this.panels.get(clientId);
3619
+ if (panel !== void 0) this.appendConsole(panel, console);
3620
+ const record = this.inFlight.get(id);
3621
+ if (record === void 0) return false;
3622
+ if (outcome.ok) {
3623
+ const expected = EXPECTED_RESULT[record.kind];
3624
+ if (outcome.result.kind !== expected) {
3625
+ this.finish(record, {
3626
+ ok: false,
3627
+ error: `the Preview panel answered a ${record.kind} command with a ${outcome.result.kind} result, which this Host cannot read`
3628
+ });
3629
+ return true;
3630
+ }
3631
+ }
3632
+ this.finish(record, outcome);
3633
+ return true;
3634
+ }
3635
+ /**
3636
+ * Forget a panel and fail everything it was holding.
3637
+ * @param clientId - the panel that closed.
3638
+ */
3639
+ release(clientId) {
3640
+ this.dropPanelWork(clientId, "the preview panel closed before the command finished");
3641
+ if (this.panels.delete(clientId)) this.retired.add(clientId);
3642
+ }
3643
+ /**
3644
+ * Whether one panel is present and able to take a command.
3645
+ * @param clientId - the panel id, or undefined to ask about any panel in a session.
3646
+ * @param sessionId - the session the panel must belong to.
3647
+ * @returns the live panel's id, or undefined.
3648
+ */
3649
+ active(sessionId, clientId) {
3650
+ return [...this.panels.values()].filter((panel) => this.isLive(panel)).filter((panel) => sessionId === void 0 || panel.sessionId === sessionId).filter((panel) => clientId === void 0 || panel.clientId === clientId).sort((left, right) => right.polledAt - left.polledAt)[0]?.clientId;
3651
+ }
3652
+ /**
3653
+ * The last thing one panel reported about itself.
3654
+ * @param clientId - the panel id.
3655
+ * @returns the bind, or undefined for a panel this Host has not heard from.
3656
+ */
3657
+ bindOf(clientId) {
3658
+ return this.panels.get(clientId)?.bind;
3659
+ }
3660
+ /**
3661
+ * Queue one command against a live panel and wait for its answer.
3662
+ *
3663
+ * Every path out of here is bounded: a missing panel refuses immediately, a queue that is full
3664
+ * refuses immediately, and a delivered command that is never answered fails on its own deadline.
3665
+ * The one thing this must never do is wait for a browser that is not going to answer.
3666
+ * @param sessionId - the session whose panel should execute it.
3667
+ * @param command - the command body, without its id or its panel.
3668
+ * @returns the result, or the reason there is none.
3669
+ */
3670
+ async queue(sessionId, command) {
3671
+ if (this.closed) return {
3672
+ ok: false,
3673
+ code: "no-surface",
3674
+ message: "the plugin is unloading"
3675
+ };
3676
+ const clientId = this.active(sessionId);
3677
+ if (clientId === void 0) return {
3678
+ ok: false,
3679
+ code: "no-surface",
3680
+ message: "no Preview panel is open in this session, so there is nothing to inspect. Open the Preview panel first (the session header's Preview entry), then call this tool again."
3681
+ };
3682
+ return this.send(clientId, command);
3683
+ }
3684
+ /**
3685
+ * Queue one command against one known panel, without requiring it to be live.
3686
+ *
3687
+ * Used by `open`, which must be able to hand a mode change to a panel before that panel has had a
3688
+ * chance to poll — the very first call against a freshly opened dock.
3689
+ * @param clientId - the panel id.
3690
+ * @param command - the command body.
3691
+ * @returns the result, or the reason there is none.
3692
+ */
3693
+ async send(clientId, command) {
3694
+ if (this.closed) return {
3695
+ ok: false,
3696
+ code: "no-surface",
3697
+ message: "the plugin is unloading"
3698
+ };
3699
+ const panel = this.panels.get(clientId);
3700
+ if (panel === void 0) return {
3701
+ ok: false,
3702
+ code: "no-surface",
3703
+ message: "no Preview panel has reported in yet, so there is nothing to inspect"
3704
+ };
3705
+ if (panel.queue.length >= QUEUE_CAP) return {
3706
+ ok: false,
3707
+ code: "no-surface",
3708
+ message: `the Preview panel is not keeping up: ${String(QUEUE_CAP)} commands are already queued and unanswered`
3709
+ };
3710
+ const options = this.options();
3711
+ const timeoutMs = command.timeoutMs ?? options.commandTimeoutMs;
3712
+ const id = randomUUID();
3713
+ const full = {
3714
+ ...command,
3715
+ id,
3716
+ clientId,
3717
+ timeoutMs
3718
+ };
3719
+ const outcome = new Promise((resolve$1) => {
3720
+ const timer = setTimeout(() => {
3721
+ const record = this.inFlight.get(id);
3722
+ if (record === void 0) return;
3723
+ this.inFlight.delete(id);
3724
+ resolve$1({
3725
+ ok: false,
3726
+ code: "timeout",
3727
+ message: record.delivered ? `the Preview panel did not answer the ${command.kind} command within ${String(timeoutMs)}ms` : `the Preview panel has not polled for work within ${String(timeoutMs)}ms, so the ${command.kind} command was never delivered`
3728
+ });
3729
+ }, timeoutMs);
3730
+ timer.unref?.();
3731
+ this.inFlight.set(id, {
3732
+ id,
3733
+ kind: command.kind,
3734
+ clientId,
3735
+ sessionId: panel.sessionId,
3736
+ mode: "direct",
3737
+ delivered: false,
3738
+ timer,
3739
+ settle: (result) => {
3740
+ resolve$1(result.ok ? {
3741
+ ok: true,
3742
+ result: result.result
3743
+ } : {
3744
+ ok: false,
3745
+ code: "timeout",
3746
+ message: result.error
3747
+ });
3748
+ }
3749
+ });
3750
+ });
3751
+ panel.queue.push(full);
3752
+ return outcome;
3753
+ }
3754
+ /**
3755
+ * Queue a mode change that needs no answer.
3756
+ * @param clientId - the panel to change.
3757
+ * @param control - what to change.
3758
+ * @returns false when the panel is unknown.
3759
+ */
3760
+ control(clientId, control) {
3761
+ const panel = this.panels.get(clientId);
3762
+ if (panel === void 0) return false;
3763
+ panel.controls.push(control);
3764
+ return true;
3765
+ }
3766
+ /** Forget every panel and fail everything in flight. Called from the plugin's teardown. */
3767
+ dispose() {
3768
+ this.closed = true;
3769
+ for (const clientId of [...this.panels.keys()]) this.dropPanelWork(clientId, "the plugin is unloading");
3770
+ this.panels.clear();
3771
+ this.retired.clear();
3772
+ }
3773
+ /**
3774
+ * One panel's liveness.
3775
+ * @param panel - the panel.
3776
+ * @returns true when its last poll is inside the trust window.
3777
+ */
3778
+ isLive(panel) {
3779
+ return this.now() - panel.polledAt <= this.options().bindTtlMs;
3780
+ }
3781
+ /** The configured clock. */
3782
+ now() {
3783
+ return (this.options().now ?? Date.now)();
3784
+ }
3785
+ /**
3786
+ * Append console entries to a panel's window, dropping the oldest past the cap.
3787
+ * @param panel - the panel.
3788
+ * @param entries - entries observed since the last report.
3789
+ */
3790
+ appendConsole(panel, entries) {
3791
+ for (const entry of entries.slice(0, CONSOLE_CAP)) {
3792
+ panel.console.push({
3793
+ level: entry.level,
3794
+ text: entry.text.length > CONSOLE_LINE_CAP ? `${entry.text.slice(0, CONSOLE_LINE_CAP)}…` : entry.text,
3795
+ at: Number.isFinite(entry.at) ? entry.at : this.now()
3796
+ });
3797
+ panel.consoleTotal += 1;
3798
+ }
3799
+ while (panel.console.length > CONSOLE_CAP) {
3800
+ panel.console.shift();
3801
+ panel.consoleBase += 1;
3802
+ }
3803
+ }
3804
+ /**
3805
+ * Fail every command one panel is holding and drop its queue.
3806
+ * @param clientId - the panel.
3807
+ * @param reason - the sentence the tool reports.
3808
+ */
3809
+ dropPanelWork(clientId, reason) {
3810
+ const panel = this.panels.get(clientId);
3811
+ if (panel !== void 0) {
3812
+ panel.queue.length = 0;
3813
+ panel.controls.length = 0;
3814
+ }
3815
+ for (const record of [...this.inFlight.values()]) {
3816
+ if (record.clientId !== clientId) continue;
3817
+ this.finish(record, {
3818
+ ok: false,
3819
+ error: reason
3820
+ });
3821
+ }
3822
+ }
3823
+ /**
3824
+ * Resolve one in-flight command and forget it.
3825
+ * @param record - the record.
3826
+ * @param outcome - what to resolve with.
3827
+ */
3828
+ finish(record, outcome) {
3829
+ this.inFlight.delete(record.id);
3830
+ clearTimeout(record.timer);
3831
+ record.settle(outcome);
3832
+ }
3833
+ };
3834
+
2435
3835
  //#endregion
2436
3836
  //#region tsbuild/host/index.js
2437
3837
  /**
@@ -2561,6 +3961,10 @@ let AdvancedSidebarService = (() => {
2561
3961
  let _previewStart_decorators;
2562
3962
  let _previewStop_decorators;
2563
3963
  let _previewLogs_decorators;
3964
+ let _previewFileInfo_decorators;
3965
+ let _previewPoll_decorators;
3966
+ let _previewResult_decorators;
3967
+ let _previewRelease_decorators;
2564
3968
  let _readFile_decorators;
2565
3969
  let _openIn_decorators;
2566
3970
  let _taskKill_decorators;
@@ -2587,6 +3991,10 @@ let AdvancedSidebarService = (() => {
2587
3991
  _previewStart_decorators = [Remote("previewStart")];
2588
3992
  _previewStop_decorators = [Remote("previewStop")];
2589
3993
  _previewLogs_decorators = [Remote("previewLogs")];
3994
+ _previewFileInfo_decorators = [Remote("previewFileInfo")];
3995
+ _previewPoll_decorators = [Remote("previewPoll")];
3996
+ _previewResult_decorators = [Remote("previewResult")];
3997
+ _previewRelease_decorators = [Remote("previewRelease")];
2590
3998
  _readFile_decorators = [Remote("readFile")];
2591
3999
  _openIn_decorators = [Remote("openIn")];
2592
4000
  _taskKill_decorators = [Remote("taskKill")];
@@ -2790,6 +4198,50 @@ let AdvancedSidebarService = (() => {
2790
4198
  },
2791
4199
  metadata: _metadata
2792
4200
  }, null, _instanceExtraInitializers);
4201
+ __esDecorate(this, null, _previewFileInfo_decorators, {
4202
+ kind: "method",
4203
+ name: "previewFileInfo",
4204
+ static: false,
4205
+ private: false,
4206
+ access: {
4207
+ has: (obj) => "previewFileInfo" in obj,
4208
+ get: (obj) => obj.previewFileInfo
4209
+ },
4210
+ metadata: _metadata
4211
+ }, null, _instanceExtraInitializers);
4212
+ __esDecorate(this, null, _previewPoll_decorators, {
4213
+ kind: "method",
4214
+ name: "previewPoll",
4215
+ static: false,
4216
+ private: false,
4217
+ access: {
4218
+ has: (obj) => "previewPoll" in obj,
4219
+ get: (obj) => obj.previewPoll
4220
+ },
4221
+ metadata: _metadata
4222
+ }, null, _instanceExtraInitializers);
4223
+ __esDecorate(this, null, _previewResult_decorators, {
4224
+ kind: "method",
4225
+ name: "previewResult",
4226
+ static: false,
4227
+ private: false,
4228
+ access: {
4229
+ has: (obj) => "previewResult" in obj,
4230
+ get: (obj) => obj.previewResult
4231
+ },
4232
+ metadata: _metadata
4233
+ }, null, _instanceExtraInitializers);
4234
+ __esDecorate(this, null, _previewRelease_decorators, {
4235
+ kind: "method",
4236
+ name: "previewRelease",
4237
+ static: false,
4238
+ private: false,
4239
+ access: {
4240
+ has: (obj) => "previewRelease" in obj,
4241
+ get: (obj) => obj.previewRelease
4242
+ },
4243
+ metadata: _metadata
4244
+ }, null, _instanceExtraInitializers);
2793
4245
  __esDecorate(this, null, _readFile_decorators, {
2794
4246
  kind: "method",
2795
4247
  name: "readFile",
@@ -2863,7 +4315,7 @@ let AdvancedSidebarService = (() => {
2863
4315
  showArchive: z.boolean().required(),
2864
4316
  showDelete: z.boolean().required(),
2865
4317
  showPreview: z.boolean().required(),
2866
- panelWidth: z.number().step(1).min(280).max(1400).required(),
4318
+ panelWidth: z.number().step(1).min(280).max(960).required(),
2867
4319
  confirmDelete: z.boolean().required(),
2868
4320
  deleteMode: z.union(["archive", "purge"]).required(),
2869
4321
  allowTaskKill: z.boolean().required(),
@@ -2892,7 +4344,11 @@ let AdvancedSidebarService = (() => {
2892
4344
  maxPreviews: z.number().step(1).min(1).max(16).required(),
2893
4345
  previewReadyTimeoutMs: z.number().step(1).min(1e3).max(6e5).required(),
2894
4346
  previewScrollback: z.number().step(1).min(1024).max(4 * 1024 * 1024).required(),
2895
- previewGraceMs: z.number().step(1).min(100).max(6e4).required()
4347
+ previewGraceMs: z.number().step(1).min(100).max(6e4).required(),
4348
+ previewMaxFileBytes: z.number().step(1).min(1024).max(512 * 1024 * 1024).required(),
4349
+ previewProxyTimeoutMs: z.number().step(1).min(1e3).max(6e5).required(),
4350
+ previewCommandTimeoutMs: z.number().step(1).min(1e3).max(6e5).required(),
4351
+ previewBindTtlMs: z.number().step(1).min(1e3).max(12e4).required()
2896
4352
  });
2897
4353
  source = __runInitializers(this, _instanceExtraInitializers);
2898
4354
  git;
@@ -2902,6 +4358,8 @@ let AdvancedSidebarService = (() => {
2902
4358
  tasks;
2903
4359
  deleter;
2904
4360
  preview;
4361
+ surface;
4362
+ bindings;
2905
4363
  /**
2906
4364
  * @param ctx - Host context; every capability this service uses is resolved optionally, so a
2907
4365
  * deployment missing one still serves a view that explains which panel is dark and why.
@@ -2919,6 +4377,15 @@ let AdvancedSidebarService = (() => {
2919
4377
  this.tasks = new TaskController(ctx, read);
2920
4378
  this.deleter = new SessionDeleter(ctx, read);
2921
4379
  this.preview = new PreviewServers(ctx, read);
4380
+ this.surface = new PreviewSurface(ctx, read);
4381
+ this.bindings = new PreviewBindings(() => {
4382
+ const settings = this.source();
4383
+ return {
4384
+ commandTimeoutMs: settings.previewCommandTimeoutMs,
4385
+ bindTtlMs: settings.previewBindTtlMs
4386
+ };
4387
+ });
4388
+ this.surface.install();
2922
4389
  installSettingsSection(ctx, ADVANCED_SIDEBAR_SETTINGS_NAMESPACE, AdvancedSidebarService$1.Config, config, {
2923
4390
  setSource: (current) => {
2924
4391
  this.source = current;
@@ -2930,6 +4397,8 @@ let AdvancedSidebarService = (() => {
2930
4397
  });
2931
4398
  ctx.effect(() => async () => {
2932
4399
  this.tasks.dispose();
4400
+ this.bindings.dispose();
4401
+ this.surface.dispose();
2933
4402
  await Promise.all([this.terminals.disposeAll(), this.preview.disposeAll()]);
2934
4403
  }, "advanced-sidebar: panel terminals, preview servers, retained task output");
2935
4404
  }
@@ -2946,7 +4415,10 @@ let AdvancedSidebarService = (() => {
2946
4415
  git: await this.git.describe(signal),
2947
4416
  terminal: this.terminals.describe(),
2948
4417
  files: this.files.describe(),
2949
- preview: this.preview.describe(),
4418
+ preview: {
4419
+ ...this.preview.describe(),
4420
+ surface: this.surface.info()
4421
+ },
2950
4422
  tasks: this.tasks.describe(),
2951
4423
  openIn: await this.launcher.describe(signal),
2952
4424
  settings,
@@ -2964,8 +4436,8 @@ let AdvancedSidebarService = (() => {
2964
4436
  * @param signal - gateway-supplied cancellation for the caller's abandoned request.
2965
4437
  * @returns the reading, or a classified failure.
2966
4438
  */
2967
- gitStatus(request, signal) {
2968
- return this.git.status(request, signal);
4439
+ gitStatus(request$2, signal) {
4440
+ return this.git.status(request$2, signal);
2969
4441
  }
2970
4442
  /**
2971
4443
  * Read one path's patch.
@@ -2973,8 +4445,8 @@ let AdvancedSidebarService = (() => {
2973
4445
  * @param signal - gateway-supplied cancellation for the caller's abandoned request.
2974
4446
  * @returns the patch, or a classified failure.
2975
4447
  */
2976
- gitDiff(request, signal) {
2977
- return this.git.diff(request, signal);
4448
+ gitDiff(request$2, signal) {
4449
+ return this.git.diff(request$2, signal);
2978
4450
  }
2979
4451
  /**
2980
4452
  * Stage paths into the index.
@@ -2982,8 +4454,8 @@ let AdvancedSidebarService = (() => {
2982
4454
  * @param signal - gateway-supplied cancellation for the caller's abandoned request.
2983
4455
  * @returns the reading after the write, or a classified failure.
2984
4456
  */
2985
- gitStage(request, signal) {
2986
- return this.git.stage(request, signal);
4457
+ gitStage(request$2, signal) {
4458
+ return this.git.stage(request$2, signal);
2987
4459
  }
2988
4460
  /**
2989
4461
  * Take paths back out of the index, leaving the working tree alone.
@@ -2991,8 +4463,8 @@ let AdvancedSidebarService = (() => {
2991
4463
  * @param signal - gateway-supplied cancellation for the caller's abandoned request.
2992
4464
  * @returns the reading after the write, or a classified failure.
2993
4465
  */
2994
- gitUnstage(request, signal) {
2995
- return this.git.unstage(request, signal);
4466
+ gitUnstage(request$2, signal) {
4467
+ return this.git.unstage(request$2, signal);
2996
4468
  }
2997
4469
  /**
2998
4470
  * Record the staged changes.
@@ -3000,8 +4472,8 @@ let AdvancedSidebarService = (() => {
3000
4472
  * @param signal - gateway-supplied cancellation; hooks run under `gitCommitTimeoutMs`.
3001
4473
  * @returns the new commit and the reading after it, or a classified failure.
3002
4474
  */
3003
- gitCommit(request, signal) {
3004
- return this.git.commit(request, signal);
4475
+ gitCommit(request$2, signal) {
4476
+ return this.git.commit(request$2, signal);
3005
4477
  }
3006
4478
  /**
3007
4479
  * Send the current branch's commits to its remote.
@@ -3009,8 +4481,8 @@ let AdvancedSidebarService = (() => {
3009
4481
  * @param signal - gateway-supplied cancellation; the network wait runs under `gitPushTimeoutMs`.
3010
4482
  * @returns the push and the reading after it, or a classified failure.
3011
4483
  */
3012
- gitPush(request, signal) {
3013
- return this.git.push(request, signal);
4484
+ gitPush(request$2, signal) {
4485
+ return this.git.push(request$2, signal);
3014
4486
  }
3015
4487
  /**
3016
4488
  * Ask the deployment's own model to write a commit message for what is staged.
@@ -3018,8 +4490,8 @@ let AdvancedSidebarService = (() => {
3018
4490
  * @param signal - gateway-supplied cancellation for the readings and the model call.
3019
4491
  * @returns the drafted message, or a classified failure.
3020
4492
  */
3021
- gitCommitMessage(request, signal) {
3022
- return this.git.draftCommitMessage(request, signal);
4493
+ gitCommitMessage(request$2, signal) {
4494
+ return this.git.draftCommitMessage(request$2, signal);
3023
4495
  }
3024
4496
  /**
3025
4497
  * Allocate a panel terminal.
@@ -3027,40 +4499,40 @@ let AdvancedSidebarService = (() => {
3027
4499
  * @param signal - gateway-supplied cancellation of the allocation.
3028
4500
  * @returns the handle, or a classified failure.
3029
4501
  */
3030
- terminalOpen(request, signal) {
3031
- return this.terminals.open(request, signal);
4502
+ terminalOpen(request$2, signal) {
4503
+ return this.terminals.open(request$2, signal);
3032
4504
  }
3033
4505
  /**
3034
4506
  * Read a panel terminal's output from a caller-owned offset.
3035
4507
  * @param request - the handle and the offset already rendered.
3036
4508
  * @returns the delta and the process state, or a classified failure.
3037
4509
  */
3038
- terminalRead(request) {
3039
- return Promise.resolve(this.terminals.read(request));
4510
+ terminalRead(request$2) {
4511
+ return Promise.resolve(this.terminals.read(request$2));
3040
4512
  }
3041
4513
  /**
3042
4514
  * Send keystrokes to a panel terminal.
3043
4515
  * @param request - the handle and the text to deliver verbatim.
3044
4516
  * @returns settlement, or a classified failure.
3045
4517
  */
3046
- terminalWrite(request) {
3047
- return this.terminals.write(request);
4518
+ terminalWrite(request$2) {
4519
+ return this.terminals.write(request$2);
3048
4520
  }
3049
4521
  /**
3050
4522
  * Deliver a signal to a panel terminal's foreground process group.
3051
4523
  * @param request - the handle and the signal.
3052
4524
  * @returns settlement, or a classified failure.
3053
4525
  */
3054
- terminalSignal(request) {
3055
- return this.terminals.signal(request);
4526
+ terminalSignal(request$2) {
4527
+ return this.terminals.signal(request$2);
3056
4528
  }
3057
4529
  /**
3058
4530
  * Close a panel terminal.
3059
4531
  * @param request - the handle.
3060
4532
  * @returns settlement, or a classified failure.
3061
4533
  */
3062
- terminalClose(request) {
3063
- return this.terminals.close(request);
4534
+ terminalClose(request$2) {
4535
+ return this.terminals.close(request$2);
3064
4536
  }
3065
4537
  /**
3066
4538
  * List one directory level for the Files panel.
@@ -3068,8 +4540,8 @@ let AdvancedSidebarService = (() => {
3068
4540
  * @param signal - gateway-supplied cancellation for the caller's abandoned request.
3069
4541
  * @returns the level, or a classified failure.
3070
4542
  */
3071
- listEntries(request, signal) {
3072
- return this.files.list(request, signal);
4543
+ listEntries(request$2, signal) {
4544
+ return this.files.list(request$2, signal);
3073
4545
  }
3074
4546
  /**
3075
4547
  * List one workspace's preview launch configurations, each with its current state.
@@ -3077,8 +4549,8 @@ let AdvancedSidebarService = (() => {
3077
4549
  * @param signal - gateway-supplied cancellation for the caller's abandoned request.
3078
4550
  * @returns the list, or a classified failure.
3079
4551
  */
3080
- previewList(request, signal) {
3081
- return this.preview.list(request, signal);
4552
+ previewList(request$2, signal) {
4553
+ return this.preview.list(request$2, signal);
3082
4554
  }
3083
4555
  /**
3084
4556
  * Start one preview configuration.
@@ -3086,24 +4558,84 @@ let AdvancedSidebarService = (() => {
3086
4558
  * @param signal - gateway-supplied cancellation of the start.
3087
4559
  * @returns the started row, or a classified failure.
3088
4560
  */
3089
- previewStart(request, signal) {
3090
- return this.preview.start(request, signal);
4561
+ previewStart(request$2, signal) {
4562
+ return this.preview.start(request$2, signal);
3091
4563
  }
3092
4564
  /**
3093
4565
  * Stop one running preview server.
3094
4566
  * @param request - the handle.
3095
4567
  * @returns settlement, or a classified failure.
3096
4568
  */
3097
- previewStop(request) {
3098
- return this.preview.stop(request);
4569
+ previewStop(request$2) {
4570
+ return this.preview.stop(request$2);
3099
4571
  }
3100
4572
  /**
3101
4573
  * Read one preview server's output from a caller-owned offset, with its state at read time.
3102
4574
  * @param request - the handle and the offset already rendered.
3103
4575
  * @returns the delta and the state, or a classified failure.
3104
4576
  */
3105
- previewLogs(request) {
3106
- return Promise.resolve(this.preview.logs(request));
4577
+ previewLogs(request$2) {
4578
+ return Promise.resolve(this.preview.logs(request$2));
4579
+ }
4580
+ /**
4581
+ * Describe one workspace file for the Preview panel's Files mode.
4582
+ *
4583
+ * Separate from `readFile`, which returns text for the Files panel's reader: a preview needs the
4584
+ * kind, the size, the same-origin URL, and a change token, and it must not pull a 200 MB video
4585
+ * through the wire to find out what it is.
4586
+ * @param request - the workspace and the file inside it.
4587
+ * @param signal - gateway-supplied cancellation for the resolution and metadata reads.
4588
+ * @returns the file's kind and frame URL, or a classified failure.
4589
+ */
4590
+ previewFileInfo(request$2, signal) {
4591
+ return this.surface.info_(request$2.workspacePath, request$2.path, signal);
4592
+ }
4593
+ /**
4594
+ * Register one Preview panel and take whatever the agent queued for it.
4595
+ *
4596
+ * This is the polling half of the agent channel: the browser calls it while a preview is mounted,
4597
+ * the call is the panel's liveness heartbeat, and its answer carries the commands to execute.
4598
+ * @param request - which panel, where it is, and whether a preview is actually rendered.
4599
+ * @returns the work to do, or a classified failure.
4600
+ */
4601
+ previewPoll(request$2) {
4602
+ if (request$2.mounted) this.bindings.bind(request$2.bind);
4603
+ else this.bindings.bindAt(request$2.bind);
4604
+ this.surface.rememberTarget(request$2.clientId, request$2.bind.inspectable ? request$2.bind.url : void 0);
4605
+ const polled = this.bindings.poll(request$2.clientId, request$2.mounted);
4606
+ return Promise.resolve({
4607
+ ok: true,
4608
+ message: polled.message,
4609
+ bindTtlMs: polled.bindTtlMs
4610
+ });
4611
+ }
4612
+ /**
4613
+ * Record what one command did.
4614
+ * @param request - the panel, the command id, and the outcome.
4615
+ * @returns settlement.
4616
+ */
4617
+ previewResult(request$2) {
4618
+ this.bindings.post(request$2.clientId, request$2.id, request$2.ok ? request$2.result === void 0 ? {
4619
+ ok: false,
4620
+ error: "the panel reported success without a result"
4621
+ } : {
4622
+ ok: true,
4623
+ result: request$2.result
4624
+ } : {
4625
+ ok: false,
4626
+ error: request$2.error ?? "the panel reported a failure with no reason"
4627
+ }, request$2.console ?? []);
4628
+ return Promise.resolve({ ok: true });
4629
+ }
4630
+ /**
4631
+ * Say that one panel is gone, so its queued work is dropped and its waits fail now.
4632
+ * @param request - the panel that closed.
4633
+ * @returns settlement.
4634
+ */
4635
+ previewRelease(request$2) {
4636
+ this.bindings.release(request$2.clientId);
4637
+ this.surface.rememberTarget(request$2.clientId, void 0);
4638
+ return Promise.resolve({ ok: true });
3107
4639
  }
3108
4640
  /**
3109
4641
  * Read one file for the Files panel preview.
@@ -3111,8 +4643,8 @@ let AdvancedSidebarService = (() => {
3111
4643
  * @param signal - gateway-supplied cancellation for the caller's abandoned request.
3112
4644
  * @returns the preview, or a classified failure.
3113
4645
  */
3114
- readFile(request, signal) {
3115
- return this.files.read(request, signal);
4646
+ readFile(request$2, signal) {
4647
+ return this.files.read(request$2, signal);
3116
4648
  }
3117
4649
  /**
3118
4650
  * Hand one path to an external application or to the operating system's file manager.
@@ -3120,36 +4652,157 @@ let AdvancedSidebarService = (() => {
3120
4652
  * @param signal - gateway-supplied cancellation for the launch.
3121
4653
  * @returns settlement, or a classified failure.
3122
4654
  */
3123
- openIn(request, signal) {
3124
- return this.launcher.open(request, signal);
4655
+ openIn(request$2, signal) {
4656
+ return this.launcher.open(request$2, signal);
3125
4657
  }
3126
4658
  /**
3127
4659
  * Stop one live background task.
3128
4660
  * @param request - the owning session and the task id.
3129
4661
  * @returns what the registry did, or a classified failure.
3130
4662
  */
3131
- taskKill(request) {
3132
- return this.tasks.kill(request);
4663
+ taskKill(request$2) {
4664
+ return this.tasks.kill(request$2);
3133
4665
  }
3134
4666
  /**
3135
4667
  * Read one settled background task's output.
3136
4668
  * @param request - the owning session and the task id.
3137
4669
  * @returns the accumulated output, or a classified failure.
3138
4670
  */
3139
- taskOutput(request) {
3140
- return this.tasks.output(request);
4671
+ taskOutput(request$2) {
4672
+ return this.tasks.output(request$2);
3141
4673
  }
3142
4674
  /**
3143
- * Delete one session: archive it, and remove its durable artifact when the mode and Host allow.
4675
+ * Delete one session: archive it, and report plainly that a configured purge did not happen.
3144
4676
  * @param request - the session to delete.
3145
- * @param signal - gateway-supplied cancellation for the persistence listing.
3146
4677
  * @returns what was actually done, or a classified failure.
3147
4678
  */
3148
- deleteSession(request, signal) {
3149
- return this.deleter.delete(request, signal);
4679
+ deleteSession(request$2) {
4680
+ return this.deleter.delete(request$2);
4681
+ }
4682
+ /**
4683
+ * Queue one command against the session's Preview panel and wait for its answer.
4684
+ * @param sessionId - the session whose panel should execute it.
4685
+ * @param body - the command, without its id, panel, or deadline.
4686
+ * @returns the result, or a sentence explaining why there is none.
4687
+ */
4688
+ queueCommand(sessionId, body) {
4689
+ return this.bindings.queue(sessionId, {
4690
+ ...body,
4691
+ timeoutMs: this.source().previewCommandTimeoutMs
4692
+ });
4693
+ }
4694
+ /**
4695
+ * Point the session's panel at a URL.
4696
+ *
4697
+ * The panel decides for itself how to frame it — a loopback URL goes through this Host's proxy and
4698
+ * becomes inspectable, anything else is framed cross-origin and is not — so the answer says which
4699
+ * happened rather than the tool guessing.
4700
+ * @param sessionId - the session whose panel should show it.
4701
+ * @param url - the absolute `http(s)` URL, already validated by the caller.
4702
+ * @param waitMs - how long the panel may take to mount and load.
4703
+ * @returns the outcome and what the panel is now framing.
4704
+ */
4705
+ async openUrl(sessionId, url, waitMs) {
4706
+ const clientId = this.bindings.active(sessionId);
4707
+ if (clientId === void 0) return {
4708
+ ok: false,
4709
+ message: NO_SURFACE
4710
+ };
4711
+ if (!this.bindings.control(clientId, {
4712
+ control: "open",
4713
+ open: {
4714
+ clientId,
4715
+ mode: "url",
4716
+ url
4717
+ }
4718
+ })) return {
4719
+ ok: false,
4720
+ message: NO_SURFACE
4721
+ };
4722
+ const sameOrigin = validateProxyTarget(url).ok;
4723
+ const outcome = await this.bindings.send(clientId, {
4724
+ kind: "open",
4725
+ timeoutMs: waitMs
4726
+ });
4727
+ if (!outcome.ok) return {
4728
+ ok: false,
4729
+ message: outcome.message
4730
+ };
4731
+ return {
4732
+ ok: true,
4733
+ message: sameOrigin ? `The Preview panel is loading ${url} through this Host's same-origin proxy, so its DOM, console and events are all inspectable.` : `The Preview panel is showing ${url}. It is not a loopback URL, so the frame stays cross-origin and cannot be inspected: dom, eval, click and type will refuse it.`,
4734
+ detail: {
4735
+ url,
4736
+ inspectable: sameOrigin,
4737
+ ...sameOrigin ? { proxyRoute: PROXY_ROUTE } : {}
4738
+ }
4739
+ };
4740
+ }
4741
+ /**
4742
+ * Point the session's panel at one workspace file.
4743
+ * @param request - the session, the workspace, the file, its kind, and the wait budget.
4744
+ * @returns the outcome and where the panel is now framed from.
4745
+ */
4746
+ async openFile(request$2) {
4747
+ const clientId = this.bindings.active(request$2.sessionId);
4748
+ if (clientId === void 0) return {
4749
+ ok: false,
4750
+ message: NO_SURFACE
4751
+ };
4752
+ if (!this.bindings.control(clientId, {
4753
+ control: "open",
4754
+ open: {
4755
+ clientId,
4756
+ mode: "file",
4757
+ filePath: request$2.filePath,
4758
+ workspacePath: request$2.workspacePath
4759
+ }
4760
+ })) return {
4761
+ ok: false,
4762
+ message: NO_SURFACE
4763
+ };
4764
+ const framed = this.surface.info().available;
4765
+ if (request$2.kind !== "iframe" && request$2.kind !== "markdown" && !framed) return {
4766
+ ok: false,
4767
+ message: `${request$2.filePath} is a ${request$2.kind} file, and this Host composes no web server, so there is no same-origin URL to frame it from`
4768
+ };
4769
+ const outcome = await this.bindings.send(clientId, {
4770
+ kind: "open",
4771
+ timeoutMs: request$2.waitMs
4772
+ });
4773
+ if (!outcome.ok) return {
4774
+ ok: false,
4775
+ message: outcome.message
4776
+ };
4777
+ const url = this.surface.fileUrl(request$2.workspacePath, request$2.filePath);
4778
+ return {
4779
+ ok: true,
4780
+ message: `The Preview panel is showing ${request$2.filePath} (${request$2.kind})${request$2.kind === "iframe" ? " as a live document" : ""}; its DOM is same-origin and inspectable.`,
4781
+ detail: {
4782
+ path: request$2.filePath,
4783
+ kind: request$2.kind,
4784
+ ...url === void 0 ? {} : { url }
4785
+ }
4786
+ };
4787
+ }
4788
+ /**
4789
+ * Read one workspace file's preview description, for the tool's own `open` validation.
4790
+ * @param request - the workspace and the file.
4791
+ * @param signal - cancellation for the reads.
4792
+ * @returns the description, or a classified failure.
4793
+ */
4794
+ describeFile(request$2, signal) {
4795
+ return this.surface.info_(request$2.workspacePath, request$2.path, signal);
3150
4796
  }
3151
4797
  };
3152
4798
  })();
4799
+ /**
4800
+ * The refusal a tool call gets when no Preview panel is open in its session.
4801
+ *
4802
+ * A constant rather than a method: it is the same sentence for every action, and a model that reads
4803
+ * it twice should read the same thing twice.
4804
+ */
4805
+ const NO_SURFACE = "no Preview panel is open in this session, so there is nothing to inspect. Open the Preview panel first (the session header's Preview entry), then call this tool again.";
3153
4806
  var host_default = AdvancedSidebarService;
3154
4807
 
3155
4808
  //#endregion