@deepseek-ai/dsh-experimental-client-ui-forge 0.0.0-stage → 0.2.2-alpha.3

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +37 -0
  3. package/README.md +142 -2
  4. package/README.zh.md +143 -0
  5. package/lib/client.js +11802 -0
  6. package/lib/index.js +3238 -0
  7. package/lib/typert.host.d.ts +3 -0
  8. package/lib/typert.host.js +2502 -0
  9. package/lib/typert.remote-client.d.ts +85 -0
  10. package/lib/typert.remote-client.js +1538 -0
  11. package/lib/types/client/ActionToast.d.ts +14 -0
  12. package/lib/types/client/ChangeRequestCard.d.ts +56 -0
  13. package/lib/types/client/ChangedFiles.d.ts +36 -0
  14. package/lib/types/client/ChangesTab.d.ts +59 -0
  15. package/lib/types/client/ChangesTabTitle.d.ts +13 -0
  16. package/lib/types/client/DiffFile.d.ts +76 -0
  17. package/lib/types/client/FileLabel.d.ts +20 -0
  18. package/lib/types/client/FileTreePane.d.ts +15 -0
  19. package/lib/types/client/ForgeTab.d.ts +57 -0
  20. package/lib/types/client/ForgeTabTitle.d.ts +11 -0
  21. package/lib/types/client/HeaderButton.d.ts +27 -0
  22. package/lib/types/client/LocalChangesCard.d.ts +29 -0
  23. package/lib/types/client/RefreshGlyph.d.ts +18 -0
  24. package/lib/types/client/ReviewComposer.d.ts +40 -0
  25. package/lib/types/client/ReviewThread.d.ts +26 -0
  26. package/lib/types/client/SessionHoverForge.d.ts +20 -0
  27. package/lib/types/client/SessionRowForge.d.ts +39 -0
  28. package/lib/types/client/SubmitReview.d.ts +22 -0
  29. package/lib/types/client/WorktreeProgress.d.ts +44 -0
  30. package/lib/types/client/WorktreeToggle.d.ts +43 -0
  31. package/lib/types/client/changes-address.d.ts +70 -0
  32. package/lib/types/client/changes-store.d.ts +39 -0
  33. package/lib/types/client/checkout.d.ts +34 -0
  34. package/lib/types/client/code-selection.d.ts +23 -0
  35. package/lib/types/client/diff-rows.d.ts +96 -0
  36. package/lib/types/client/file-tree.d.ts +33 -0
  37. package/lib/types/client/format.d.ts +21 -0
  38. package/lib/types/client/glyphs.d.ts +33 -0
  39. package/lib/types/client/index.d.ts +25 -0
  40. package/lib/types/client/locales.d.ts +282 -0
  41. package/lib/types/client/mount.d.ts +17 -0
  42. package/lib/types/client/review-queue.d.ts +136 -0
  43. package/lib/types/client/review.d.ts +56 -0
  44. package/lib/types/client/session-list.d.ts +46 -0
  45. package/lib/types/client/source.d.ts +146 -0
  46. package/lib/types/client/status.d.ts +99 -0
  47. package/lib/types/diff.d.ts +32 -0
  48. package/lib/types/git.d.ts +93 -0
  49. package/lib/types/index.d.ts +296 -0
  50. package/lib/types/local-diff.d.ts +100 -0
  51. package/lib/types/panel.d.ts +135 -0
  52. package/lib/types/parse.d.ts +57 -0
  53. package/lib/types/patch-cache.d.ts +33 -0
  54. package/lib/types/states.d.ts +95 -0
  55. package/lib/types/types.d.ts +399 -0
  56. package/lib/types/worktree.d.ts +225 -0
  57. package/locale/de.json +6 -0
  58. package/locale/en.json +6 -0
  59. package/locale/es.json +6 -0
  60. package/locale/fr.json +6 -0
  61. package/locale/ja.json +6 -0
  62. package/locale/pt.json +6 -0
  63. package/locale/ru.json +6 -0
  64. package/locale/zh.json +6 -0
  65. package/package.json +123 -3
package/lib/index.js ADDED
@@ -0,0 +1,3238 @@
1
+ import { Service } from "@deepseek-ai/cordis";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { changeRequestKey } from "@deepseek-ai/dsh-experimental-forge";
4
+ import { appendPluginRecord, pluginRecordOf } from "@deepseek-ai/dsh-session";
5
+ import { MAX_TIMER_DELAY_MS, deadline } from "@deepseek-ai/dsh-timeout";
6
+ import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
7
+ import { parsePatch } from "diff";
8
+ import { FsError } from "@deepseek-ai/dsh-fs";
9
+ import { assertNever } from "@deepseek-ai/dsh-util-values";
10
+ import { isAbsolute, relative, sep } from "node:path";
11
+ import { generatedWorktreeName } from "@deepseek-ai/dsh-experimental-worktree";
12
+ import { z as z$1 } from "zod";
13
+ import { Buffer } from "node:buffer";
14
+ import { createHash } from "node:crypto";
15
+ import { defineDomain } from "@deepseek-ai/dsh-storage-domain";
16
+ //#region lib/types/parse.js
17
+ /** Parsers for the porcelain output of the read-only Git commands the panel runs. */
18
+ /**
19
+ * Read the first line that `git rev-parse` prints for a directory query such as `--show-toplevel`.
20
+ * @param output - stdout of the command.
21
+ * @returns the directory.
22
+ * @throws when the output is empty.
23
+ */
24
+ function parseTopLevel(output) {
25
+ const [root = ""] = output.split(/\r?\n/);
26
+ if (root.length === 0) throw new Error("git rev-parse printed no directory");
27
+ return root;
28
+ }
29
+ /**
30
+ * Read the first line of a one-name output such as `git symbolic-ref --short HEAD`.
31
+ * @param output - stdout of the command.
32
+ * @returns the name, or null when the command printed none.
33
+ */
34
+ function parseName(output) {
35
+ const [name = ""] = output.split(/\r?\n/);
36
+ return name.length === 0 ? null : name;
37
+ }
38
+ /**
39
+ * Order worktree base branches: the checked-out branch first, then the other
40
+ * listed branches in listed order.
41
+ * @param current - checked-out branch, or null when HEAD is detached.
42
+ * @param output - stdout of `git for-each-ref --format=%(refname:lstrip=2) refs/heads/`.
43
+ * @param limit - most names to return.
44
+ * @returns distinct branch names, at most `limit`.
45
+ */
46
+ function baseBranches(current, output, limit) {
47
+ const listed = output.split(/\r?\n/).filter((name) => name.length > 0 && name !== current);
48
+ return (current === null ? listed : [current, ...listed]).slice(0, limit);
49
+ }
50
+ /**
51
+ * Group the lines of `git remote -v` by remote.
52
+ * @param output - stdout of the command.
53
+ * @returns remotes in first-listed order.
54
+ */
55
+ function parseRemotes(output) {
56
+ const remotes = /* @__PURE__ */ new Map();
57
+ for (const line of output.split(/\r?\n/)) {
58
+ const [, name = "", url = "", direction = ""] = /^(\S+)\t(.+) \((fetch|push)\)$/.exec(line) ?? [];
59
+ if (name.length === 0) continue;
60
+ let urls = remotes.get(name);
61
+ if (urls === void 0) {
62
+ urls = [];
63
+ remotes.set(name, urls);
64
+ }
65
+ if (urls.includes(url)) continue;
66
+ if (direction === "fetch") urls.unshift(url);
67
+ else urls.push(url);
68
+ }
69
+ return [...remotes].map(([name, urls]) => ({
70
+ name,
71
+ urls
72
+ }));
73
+ }
74
+ /**
75
+ * Split an upstream branch such as `origin/feature/x` into its remote and the
76
+ * branch on that remote. The remote is the longest remote name followed by `/`
77
+ * at the start of the upstream branch.
78
+ * @param upstream - upstream branch such as `origin/main`, or null.
79
+ * @param remotes - remotes in listed order.
80
+ * @returns the remote and branch, or undefined without an upstream or a remote it starts with.
81
+ */
82
+ function upstreamBranch(upstream, remotes) {
83
+ if (upstream === null) return void 0;
84
+ const remote = remotes.filter((candidate) => upstream.startsWith(`${candidate.name}/`)).reduce((longest, candidate) => longest === void 0 || candidate.name.length > longest.name.length ? candidate : longest, void 0);
85
+ return remote === void 0 ? void 0 : {
86
+ remote,
87
+ branch: upstream.slice(remote.name.length + 1)
88
+ };
89
+ }
90
+ /**
91
+ * Order remotes for code host recognition: the upstream's remote first, then
92
+ * `origin`, then the rest in listed order.
93
+ * @param upstream - upstream branch such as `origin/main`, or null.
94
+ * @param remotes - remotes in listed order.
95
+ * @returns the same remotes in recognition order.
96
+ */
97
+ function remoteOrder(upstream, remotes) {
98
+ const tracked = upstreamBranch(upstream, remotes)?.remote;
99
+ const rank = (remote) => remote === tracked ? 0 : remote.name === "origin" ? 1 : 2;
100
+ return [...remotes].sort((left, right) => rank(left) - rank(right));
101
+ }
102
+ //#endregion
103
+ //#region lib/types/git.js
104
+ /** Git queries in a Session's current directory, using its permissions and execution world. */
105
+ var __addDisposableResource$1 = function(env, value, async) {
106
+ if (value !== null && value !== void 0) {
107
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
108
+ var dispose, inner;
109
+ if (async) {
110
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
111
+ dispose = value[Symbol.asyncDispose];
112
+ }
113
+ if (dispose === void 0) {
114
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
115
+ dispose = value[Symbol.dispose];
116
+ if (async) inner = dispose;
117
+ }
118
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
119
+ if (inner) dispose = function() {
120
+ try {
121
+ inner.call(this);
122
+ } catch (e) {
123
+ return Promise.reject(e);
124
+ }
125
+ };
126
+ env.stack.push({
127
+ value,
128
+ dispose,
129
+ async
130
+ });
131
+ } else if (async) env.stack.push({ async: true });
132
+ return value;
133
+ };
134
+ var __disposeResources$1 = (function(SuppressedError) {
135
+ return function(env) {
136
+ function fail(e) {
137
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
138
+ env.hasError = true;
139
+ }
140
+ var r, s = 0;
141
+ function next() {
142
+ while (r = env.stack.pop()) try {
143
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
144
+ if (r.dispose) {
145
+ var result = r.dispose.call(r.value);
146
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
147
+ fail(e);
148
+ return next();
149
+ });
150
+ } else s |= 1;
151
+ } catch (e) {
152
+ fail(e);
153
+ }
154
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
155
+ if (env.hasError) throw env.error;
156
+ }
157
+ return next();
158
+ };
159
+ })(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
160
+ var e = new Error(message);
161
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
162
+ });
163
+ /** Thrown when a Git command prints more than `maxOutputBytes` on standard output. */
164
+ var GitOutputTooLarge = class extends Error {
165
+ name = "GitOutputTooLarge";
166
+ };
167
+ /**
168
+ * Clear inherited repository, configuration, and replacement-ref selectors;
169
+ * disable prompts, optional index writes, and lazy fetches of missing objects.
170
+ * Git older than 2.45 ignores `GIT_NO_LAZY_FETCH`.
171
+ */
172
+ const GIT_ENV = {
173
+ ...Object.fromEntries([
174
+ "GIT_DIR",
175
+ "GIT_WORK_TREE",
176
+ "GIT_COMMON_DIR",
177
+ "GIT_INDEX_FILE",
178
+ "GIT_OBJECT_DIRECTORY",
179
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
180
+ "GIT_CONFIG",
181
+ "GIT_CONFIG_PARAMETERS",
182
+ "GIT_CONFIG_COUNT",
183
+ "GIT_CEILING_DIRECTORIES",
184
+ "GIT_PREFIX",
185
+ "GIT_SHALLOW_FILE",
186
+ "GIT_GRAFT_FILE",
187
+ "GIT_REPLACE_REF_BASE",
188
+ "GIT_ATTR_SOURCE"
189
+ ].map((key) => [key, void 0])),
190
+ GIT_TERMINAL_PROMPT: "0",
191
+ GIT_OPTIONAL_LOCKS: "0",
192
+ GIT_NO_LAZY_FETCH: "1",
193
+ LC_ALL: "C"
194
+ };
195
+ /** Arguments before every subcommand: no index refresh writes, hooks, or filesystem monitor. */
196
+ const GIT_PREFIX = [
197
+ "--no-optional-locks",
198
+ "-c",
199
+ "core.hooksPath=/dev/null",
200
+ "-c",
201
+ "core.fsmonitor=false"
202
+ ];
203
+ const NOT_A_CHECKOUT = /not a git repository|must be run in a work tree/i;
204
+ /**
205
+ * Inspect the checkout containing `directory`. Every command runs through the
206
+ * mounted subprocess provider under the Session's sandbox policy and reads
207
+ * refs and configuration only, never the working tree.
208
+ * The top-level and shared Git directories Git prints are resolved
209
+ * through `ctx.fs` into canonical process paths, the form the
210
+ * working-directory service records.
211
+ * @param ctx - context that provides `fs`, `subprocess`, `sandbox`, and `sandboxPolicy`.
212
+ * @param config - executable and process limits.
213
+ * @param session - Session whose sandbox policy and execution world apply.
214
+ * @param directory - absolute directory to inspect.
215
+ * @param signal - cancellation of every command.
216
+ * @returns the checkout facts, shared Git directory, upstream, and remotes, or undefined when `directory` is not inside a checkout.
217
+ * @throws when Git cannot run, fails, times out, or prints more than `maxOutputBytes`.
218
+ */
219
+ async function inspectCheckout(ctx, config, session, directory, signal) {
220
+ const top = await gitIn(ctx, config, session, directory, signal)("rev-parse", ["rev-parse", "--show-toplevel"], [0, 128]);
221
+ if (top.exitCode !== 0) {
222
+ if (NOT_A_CHECKOUT.test(top.stderr)) return void 0;
223
+ throw new Error(`git rev-parse failed: ${firstLine(top.stderr)}`);
224
+ }
225
+ const [root, commonDir, gitDir, branch, commit, upstream, remotes] = await together(signal, (signal) => {
226
+ const git = gitIn(ctx, config, session, directory, signal);
227
+ const canonical = async (path) => ctx.fs.processPath(await ctx.fs.resolve(path, {
228
+ cwd: directory,
229
+ signal
230
+ }));
231
+ return [
232
+ canonical(parseTopLevel(top.stdout)),
233
+ git("common-dir", [
234
+ "rev-parse",
235
+ "--path-format=absolute",
236
+ "--git-common-dir"
237
+ ]).then((output) => canonical(parseTopLevel(output.stdout))),
238
+ git("git-dir", [
239
+ "rev-parse",
240
+ "--path-format=absolute",
241
+ "--git-dir"
242
+ ]).then((output) => canonical(parseTopLevel(output.stdout))),
243
+ currentBranch(git),
244
+ headCommit(git),
245
+ git("upstream", [
246
+ "rev-parse",
247
+ "--abbrev-ref",
248
+ "--symbolic-full-name",
249
+ "@{upstream}"
250
+ ], [0, 128]),
251
+ git("remote", ["remote", "-v"])
252
+ ];
253
+ });
254
+ return {
255
+ checkout: {
256
+ root,
257
+ branch,
258
+ linked: gitDir !== commonDir,
259
+ commit
260
+ },
261
+ commonDir,
262
+ upstream: upstream.exitCode === 0 ? parseName(upstream.stdout) : null,
263
+ remotes: parseRemotes(remotes.stdout)
264
+ };
265
+ }
266
+ /**
267
+ * List the local branches that can start a worktree of the checkout containing
268
+ * `directory`, using the Session's permissions as in {@link inspectCheckout}.
269
+ * @param ctx - context that provides `subprocess`, `sandbox`, and `sandboxPolicy`.
270
+ * @param config - executable and process limits.
271
+ * @param session - Session whose sandbox policy and execution world apply.
272
+ * @param directory - absolute directory inside the checkout.
273
+ * @param limit - most branch names to return.
274
+ * @param signal - cancellation of every command.
275
+ * @returns the checked-out branch and local branches, the checked-out one first and then most recently committed first.
276
+ * @throws when `directory` is not inside a checkout, or Git cannot run, fails, times out, or prints more than `maxOutputBytes`.
277
+ */
278
+ async function listBranches(ctx, config, session, directory, limit, signal) {
279
+ const [current, refs] = await together(signal, (signal) => {
280
+ const git = gitIn(ctx, config, session, directory, signal);
281
+ return [currentBranch(git), git("for-each-ref", [
282
+ "for-each-ref",
283
+ "--sort=-committerdate",
284
+ `--count=${limit}`,
285
+ "--format=%(refname:lstrip=2)",
286
+ "refs/heads/"
287
+ ])];
288
+ });
289
+ return {
290
+ current,
291
+ branches: baseBranches(current, refs.stdout, limit)
292
+ };
293
+ }
294
+ /** A failed read cancels its siblings and returns only after every read has finished cleanup. */
295
+ async function together(signal, start) {
296
+ const stop = new AbortController();
297
+ const tasks = start(AbortSignal.any([signal, stop.signal]));
298
+ try {
299
+ return await Promise.all(tasks);
300
+ } catch (error) {
301
+ stop.abort(error);
302
+ await Promise.allSettled(tasks);
303
+ throw error;
304
+ }
305
+ }
306
+ /**
307
+ * Report whether a local branch exists, using the Session's permissions as in {@link inspectCheckout}.
308
+ * @param ctx - context that provides `subprocess`, `sandbox`, and `sandboxPolicy`.
309
+ * @param config - executable and process limits.
310
+ * @param session - Session whose sandbox policy and execution world apply.
311
+ * @param repositoryRoot - absolute root of a checkout of the repository.
312
+ * @param branch - local branch name.
313
+ * @param signal - cancellation of the command.
314
+ * @returns whether `refs/heads/<branch>` exists.
315
+ * @throws when Git cannot run, fails, times out, or prints more than `maxOutputBytes`.
316
+ */
317
+ async function hasBranch(ctx, config, session, repositoryRoot, branch, signal) {
318
+ return (await gitIn(ctx, config, session, repositoryRoot, signal)("show-ref", [
319
+ "show-ref",
320
+ "--verify",
321
+ "--quiet",
322
+ `refs/heads/${branch}`
323
+ ], [0, 1])).exitCode === 0;
324
+ }
325
+ /**
326
+ * Bind the Git runner to one Session and directory: every command
327
+ * runs under the Session's sandbox policy with the
328
+ * scrubbed environment, the fixed command prefix, and `config`'s limits.
329
+ * @param ctx - context that provides `subprocess`, `sandbox`, and `sandboxPolicy`.
330
+ * @param config - executable and process limits.
331
+ * @param session - Session whose sandbox policy and execution world apply.
332
+ * @param directory - working directory of every command.
333
+ * @param signal - cancellation of every command.
334
+ * @returns the runner.
335
+ */
336
+ function gitIn(ctx, config, session, directory, signal) {
337
+ const policy = ctx.sandboxPolicy.resolve({ session });
338
+ return (name, args, allowedExitCodes = [0], configOverrides = {}) => runGit(ctx, config, policy, directory, name, args, signal, allowedExitCodes, configOverrides);
339
+ }
340
+ /** Read the checked-out branch, including an unborn one; null when HEAD is detached. */
341
+ async function currentBranch(git) {
342
+ const head = await git("symbolic-ref", [
343
+ "symbolic-ref",
344
+ "--quiet",
345
+ "--short",
346
+ "HEAD"
347
+ ], [0, 1]);
348
+ return head.exitCode === 0 ? parseName(head.stdout) : null;
349
+ }
350
+ /** Read the abbreviated commit HEAD names; null while the checked-out branch has no commit. */
351
+ async function headCommit(git) {
352
+ const head = await git("head", [
353
+ "rev-parse",
354
+ "--short",
355
+ "HEAD"
356
+ ], [0, 128]);
357
+ return head.exitCode === 0 ? parseName(head.stdout) : null;
358
+ }
359
+ async function runGit(ctx, config, policy, cwd, name, args, signal, allowedExitCodes, configOverrides) {
360
+ const env_1 = {
361
+ stack: [],
362
+ error: void 0,
363
+ hasError: false
364
+ };
365
+ try {
366
+ const timeout = __addDisposableResource$1(env_1, deadline(signal, config.gitTimeoutMs, "FORGE_PANEL_GIT_TIMEOUT"), false);
367
+ const command = [
368
+ await ctx.subprocess.resolveExecutable(config.gitCommand, void 0, timeout.signal),
369
+ ...GIT_PREFIX,
370
+ ...args
371
+ ];
372
+ const argv = policy.mode === "danger-full-access" ? command : (await ctx.sandbox.confine(command, {
373
+ ...policy,
374
+ mode: policy.mode
375
+ }, timeout.signal)).argv;
376
+ timeout.signal.throwIfAborted();
377
+ const overrides = Object.entries(configOverrides);
378
+ const child = ctx.subprocess.spawn({
379
+ argv,
380
+ cwd,
381
+ env: {
382
+ ...GIT_ENV,
383
+ GIT_CONFIG_COUNT: String(overrides.length),
384
+ ...Object.fromEntries(overrides.flatMap(([key, value], index) => [[`GIT_CONFIG_KEY_${index}`, key], [`GIT_CONFIG_VALUE_${index}`, value]]))
385
+ },
386
+ signal: timeout.signal,
387
+ graceMs: config.graceMs,
388
+ stdio: {
389
+ stdin: "ignore",
390
+ stdout: { maxBytes: config.maxOutputBytes },
391
+ stderr: { maxBytes: config.maxOutputBytes }
392
+ }
393
+ });
394
+ try {
395
+ const outcome = await child.done;
396
+ timeout.signal.throwIfAborted();
397
+ const captured = child.collected;
398
+ const stdout = captured.stdout.readFrom(0);
399
+ const stderr = captured.stderr.readFrom(0);
400
+ if (outcome.exitCode === null || !allowedExitCodes.includes(outcome.exitCode)) throw new Error(`git ${name} failed (${outcome.signal ?? outcome.exitCode}): ${firstLine(stderr.text)}`);
401
+ if (stdout.lossy) throw new GitOutputTooLarge(`git ${name} output exceeds maxOutputBytes`);
402
+ return {
403
+ stdout: stdout.text,
404
+ stderr: stderr.text,
405
+ exitCode: outcome.exitCode
406
+ };
407
+ } finally {
408
+ child.terminate();
409
+ await child.waitForExit();
410
+ }
411
+ } catch (e_1) {
412
+ env_1.error = e_1;
413
+ env_1.hasError = true;
414
+ } finally {
415
+ __disposeResources$1(env_1);
416
+ }
417
+ }
418
+ function firstLine(text) {
419
+ return text.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0) ?? "no diagnostic";
420
+ }
421
+ //#endregion
422
+ //#region lib/types/diff.js
423
+ /** Unified patch parsing into the panel's browser-safe diff model, and the diff and size of an untracked file. */
424
+ /** Git's name for the missing side of an added or deleted file. */
425
+ const NULL_PATH = "/dev/null";
426
+ /** Bytes Git inspects for a NUL byte before it calls content binary. */
427
+ const BINARY_PROBE_BYTES = 8e3;
428
+ /**
429
+ * Parse a Git-style unified patch, as `git diff` or a code host prints it,
430
+ * with `a/` and `b/` path prefixes. Hunk ranges follow jsdiff: a range that
431
+ * covers no line starts at the line it comes before.
432
+ * @param patch - the patch text.
433
+ * @returns one file per patch section in patch order.
434
+ */
435
+ function parseDiff(patch) {
436
+ return parsePatch(patch).flatMap((section) => {
437
+ const before = pathOf(section.oldFileName, "a/");
438
+ const after = pathOf(section.newFileName, "b/");
439
+ const path = after ?? before;
440
+ if (path === null) return [];
441
+ const status = section.isCreate === true || before === null ? "added" : section.isDelete === true || after === null ? "deleted" : section.isRename === true ? "renamed" : "modified";
442
+ let additions = 0;
443
+ let deletions = 0;
444
+ for (const hunk of section.hunks) for (const line of hunk.lines) if (line.startsWith("+")) additions++;
445
+ else if (line.startsWith("-")) deletions++;
446
+ return [{
447
+ path,
448
+ previousPath: status === "renamed" ? before : null,
449
+ status,
450
+ binary: section.isBinary === true,
451
+ oversized: false,
452
+ untracked: false,
453
+ additions,
454
+ deletions,
455
+ hunks: section.hunks.map(({ oldStart, oldLines, newStart, newLines, lines }) => ({
456
+ oldStart,
457
+ oldLines,
458
+ newStart,
459
+ newLines,
460
+ lines
461
+ }))
462
+ }];
463
+ });
464
+ }
465
+ /**
466
+ * Describe an untracked file as added in full.
467
+ * @param path - path relative to the repository root.
468
+ * @param content - the file's bytes.
469
+ * @returns the file with one hunk of added lines, none when it is empty, or none and `binary` when its content is not text.
470
+ */
471
+ function untrackedFile(path, content) {
472
+ const file = {
473
+ path,
474
+ previousPath: null,
475
+ status: "added",
476
+ binary: false,
477
+ oversized: false,
478
+ untracked: true,
479
+ additions: 0,
480
+ deletions: 0,
481
+ hunks: []
482
+ };
483
+ const text = decodeText(content);
484
+ if (text === void 0) return {
485
+ ...file,
486
+ binary: true
487
+ };
488
+ if (text.length === 0) return file;
489
+ const lines = text.split("\n");
490
+ const ended = lines.at(-1) === "";
491
+ if (ended) lines.pop();
492
+ return {
493
+ ...file,
494
+ additions: lines.length,
495
+ hunks: [{
496
+ oldStart: 1,
497
+ oldLines: 0,
498
+ newStart: 1,
499
+ newLines: lines.length,
500
+ lines: [...lines.map((line) => `+${line}`), ...ended ? [] : ["\"]]
501
+ }]
502
+ };
503
+ }
504
+ /**
505
+ * Decode file content as text the way Git decides it is not binary: no NUL
506
+ * byte among the first 8000 bytes, and, here, valid UTF-8 throughout.
507
+ * @param content - the bytes.
508
+ * @returns the text, or undefined for binary content.
509
+ */
510
+ function decodeText(content) {
511
+ if (content.subarray(0, BINARY_PROBE_BYTES).includes(0)) return void 0;
512
+ try {
513
+ return new TextDecoder("utf-8", { fatal: true }).decode(content);
514
+ } catch (error) {
515
+ return;
516
+ }
517
+ }
518
+ /**
519
+ * Parse `git diff --numstat -z` output, in which a rename prints its counts,
520
+ * an empty path, and then the old and new paths as separate fields.
521
+ * @param output - the command's standard output.
522
+ * @returns one file per record, named by its new path; a binary file counts no lines.
523
+ */
524
+ function parseNumstat(output) {
525
+ const fields = output.split("\0");
526
+ const files = [];
527
+ let skip = 0;
528
+ for (const [index, record] of fields.entries()) {
529
+ if (skip > 0) {
530
+ skip--;
531
+ continue;
532
+ }
533
+ if (record.length === 0) continue;
534
+ const [added = "", deleted = ""] = record.split(" ", 2);
535
+ let path = record.slice(added.length + deleted.length + 2);
536
+ if (path.length === 0) {
537
+ const renamed = fields[index + 2];
538
+ if (renamed === void 0) break;
539
+ path = renamed;
540
+ skip = 2;
541
+ }
542
+ const binary = added === "-";
543
+ files.push({
544
+ path,
545
+ additions: binary ? 0 : Number(added),
546
+ deletions: binary ? 0 : Number(deleted),
547
+ binary
548
+ });
549
+ }
550
+ return files;
551
+ }
552
+ function pathOf(name, prefix) {
553
+ if (name === void 0 || name === NULL_PATH) return null;
554
+ return name.startsWith(prefix) ? name.slice(prefix.length) : name;
555
+ }
556
+ //#endregion
557
+ //#region lib/types/local-diff.js
558
+ /**
559
+ * Local changes of a Session's checkout: where the `branch` scope forks, the
560
+ * patch of one scope with its untracked files, the new-side text of one file,
561
+ * and the size of the `branch` scope for the panel's card. Git runs through
562
+ * the Session's Git runner; untracked and working-tree files are read through
563
+ * `ctx.fs`, both within `maxDiffBytes`.
564
+ */
565
+ /** Thrown when a patch, a file list, or a file exceeds `maxDiffBytes`. */
566
+ var DiffTooLarge = class extends Error {
567
+ name = "DiffTooLarge";
568
+ };
569
+ /** Thrown when filter configuration cannot be read completely; no comparison has run. */
570
+ var FilterConfigError = class extends Error {
571
+ name = "FilterConfigError";
572
+ };
573
+ /** Thrown for the `branch` scope of a checkout without a fork point. */
574
+ var NoForkPoint = class extends Error {
575
+ name = "NoForkPoint";
576
+ };
577
+ /** No executable diff helpers or submodule working-tree inspection; preserve recorded submodule commit changes. */
578
+ const DIFF = [
579
+ "-c",
580
+ "core.quotePath=false",
581
+ "diff",
582
+ "--no-ext-diff",
583
+ "--no-textconv",
584
+ "--submodule=short",
585
+ "--ignore-submodules=dirty",
586
+ "--no-color",
587
+ "-M",
588
+ "--src-prefix=a/",
589
+ "--dst-prefix=b/"
590
+ ];
591
+ /** Disable enumerated clean/process filters for one diff without modifying Git configuration. */
592
+ async function diffFilterOverrides(git) {
593
+ let configured;
594
+ try {
595
+ configured = await git("filters", [
596
+ "config",
597
+ "--null",
598
+ "--name-only",
599
+ "--get-regexp",
600
+ "^filter\\."
601
+ ], [0, 1]);
602
+ } catch (error) {
603
+ throw new FilterConfigError("Cannot read complete Git filter configuration; comparison was not run", { cause: error });
604
+ }
605
+ const overrides = {};
606
+ for (const key of configured.stdout.split("\0")) {
607
+ if (!key.endsWith(".clean") && !key.endsWith(".process")) continue;
608
+ const driver = key.slice(0, key.lastIndexOf("."));
609
+ overrides[`${driver}.clean`] = "";
610
+ overrides[`${driver}.process`] = "";
611
+ overrides[`${driver}.required`] = "false";
612
+ }
613
+ return overrides;
614
+ }
615
+ /** Characters Git inspects for a NUL character before it calls content binary. */
616
+ const BINARY_PROBE = 8e3;
617
+ /**
618
+ * Find where the `branch` scope forks: the base commit of the worktree the
619
+ * Session created while the checkout is that worktree, else the merge base of
620
+ * HEAD with the default branch of the first remote that names one, in the
621
+ * order the panel recognizes code hosts.
622
+ * @param git - runner bound to the checkout.
623
+ * @param inspection - the checkout and its remotes.
624
+ * @param created - worktree the Session created, or null.
625
+ * @returns the fork point, or null when no remote names a default branch or HEAD shares no history with it.
626
+ */
627
+ async function forkPoint(git, inspection, created) {
628
+ if (created !== null && created.path === inspection.checkout.root) return {
629
+ commit: created.baseCommit,
630
+ label: created.from ?? created.baseCommit.slice(0, 7)
631
+ };
632
+ for (const remote of remoteOrder(inspection.upstream, inspection.remotes)) {
633
+ const head = await git("remote-head", [
634
+ "symbolic-ref",
635
+ "--quiet",
636
+ "--short",
637
+ `refs/remotes/${remote.name}/HEAD`
638
+ ], [
639
+ 0,
640
+ 1,
641
+ 128
642
+ ]);
643
+ const base = head.exitCode === 0 ? parseName(head.stdout) : null;
644
+ if (base === null) continue;
645
+ const merge = await git("merge-base", [
646
+ "merge-base",
647
+ "HEAD",
648
+ base
649
+ ], [
650
+ 0,
651
+ 1,
652
+ 128
653
+ ]);
654
+ const commit = merge.exitCode === 0 ? parseName(merge.stdout) : null;
655
+ return commit === null ? null : {
656
+ commit,
657
+ label: base
658
+ };
659
+ }
660
+ return null;
661
+ }
662
+ /**
663
+ * Read changes at `root` with command-local clean/process filter overrides and without
664
+ * submodule working-tree inspection. Git's built-in newline, ident, and encoding conversions still apply.
665
+ * @param ctx - context that provides `fs`, `subprocess`, `sandbox`, and `sandboxPolicy`.
666
+ * @param config - Git and size limits.
667
+ * @param session - Session whose sandbox policy and execution world apply.
668
+ * @param root - top-level directory of the checkout.
669
+ * @param fork - fork point of the `branch` scope, or null without one.
670
+ * @param scope - which changes to read.
671
+ * @param signal - cancellation of every command and read.
672
+ * @returns tracked files in Git's order, then untracked files unless the scope is `staged`.
673
+ * @throws NoForkPoint for the `branch` scope without `fork`; FilterConfigError when filter configuration
674
+ * cannot be read within `maxDiffBytes`; DiffTooLarge for oversized comparison output; an Error when Git fails.
675
+ */
676
+ async function localDiff(ctx, config, session, root, fork, scope, signal) {
677
+ const git = gitIn(ctx, {
678
+ ...config,
679
+ maxOutputBytes: config.maxDiffBytes
680
+ }, session, root, signal);
681
+ let range;
682
+ switch (scope.kind) {
683
+ case "branch":
684
+ if (fork === null) throw new NoForkPoint("The checkout has no fork point to compare its branch with");
685
+ range = [fork.commit];
686
+ break;
687
+ case "uncommitted":
688
+ range = [await headOrEmptyTree(git)];
689
+ break;
690
+ case "unstaged":
691
+ range = [];
692
+ break;
693
+ case "staged":
694
+ range = ["--cached", await headOrEmptyTree(git)];
695
+ break;
696
+ /* v8 ignore next -- closed ForgeLocalScope union exhaustiveness guard */
697
+ default: return assertNever(scope, "ForgeLocalScope.kind");
698
+ }
699
+ const overrides = await diffFilterOverrides(git);
700
+ const tracked = parseDiff((await bounded(git("diff", [
701
+ ...DIFF,
702
+ ...range,
703
+ "--"
704
+ ], void 0, overrides))).stdout);
705
+ if (scope.kind === "staged") return {
706
+ base: null,
707
+ files: tracked,
708
+ omitted: 0
709
+ };
710
+ const untracked = await untrackedFiles(ctx, config, git, root, signal);
711
+ return {
712
+ base: fork === null || scope.kind !== "branch" ? null : fork.label,
713
+ files: [...tracked, ...untracked.files],
714
+ omitted: untracked.omitted
715
+ };
716
+ }
717
+ /**
718
+ * Read the new side of one file in a scope, which supplies the unchanged
719
+ * lines between hunks: the index for `staged`, else the working tree.
720
+ * @param ctx - context that provides `fs`, `subprocess`, `sandbox`, and `sandboxPolicy`.
721
+ * @param config - Git and size limits.
722
+ * @param session - Session whose sandbox policy and execution world apply.
723
+ * @param root - top-level directory of the checkout.
724
+ * @param scope - the scope whose new side to read.
725
+ * @param path - path relative to `root`, as the diff names it.
726
+ * @param signal - cancellation.
727
+ * @returns the text.
728
+ * @throws DiffTooLarge over `maxDiffBytes`; an Error for a path outside the checkout, binary content, or a failed read.
729
+ */
730
+ async function localText(ctx, config, session, root, scope, path, signal) {
731
+ if (!insideCheckout(path)) throw new Error(`forge-panel: ${JSON.stringify(path)} is not a path inside the checkout`);
732
+ let text;
733
+ if (scope.kind === "staged") {
734
+ const shown = (await bounded(gitIn(ctx, {
735
+ ...config,
736
+ maxOutputBytes: config.maxDiffBytes
737
+ }, session, root, signal)("show", ["show", `:${path}`]))).stdout;
738
+ text = shown.slice(0, BINARY_PROBE).includes("\0") ? void 0 : shown;
739
+ } else text = decodeText(await readFile(ctx, config, root, path, signal));
740
+ if (text === void 0) throw new Error(`forge-panel: ${path} is binary`);
741
+ return text;
742
+ }
743
+ /**
744
+ * Measure the `branch` scope for the panel's card.
745
+ * @param ctx - context that provides `fs`, `subprocess`, `sandbox`, and `sandboxPolicy`.
746
+ * @param config - Git and size limits.
747
+ * @param session - Session whose sandbox policy and execution world apply.
748
+ * @param inspection - the checkout and its remotes.
749
+ * @param created - worktree the Session created, or null.
750
+ * @param signal - cancellation of every command and read.
751
+ * @returns the base and the changed files, or null without a fork point.
752
+ * @throws FilterConfigError when filter configuration cannot be read within `maxOutputBytes`;
753
+ * DiffTooLarge when the untracked file list exceeds `maxDiffBytes`; an Error when Git fails.
754
+ */
755
+ async function localChanges(ctx, config, session, inspection, created, signal) {
756
+ const root = inspection.checkout.root;
757
+ const git = gitIn(ctx, config, session, root, signal);
758
+ const fork = await forkPoint(git, inspection, created);
759
+ if (fork === null) return null;
760
+ const overrides = await diffFilterOverrides(git);
761
+ const numstat = await git("numstat", [
762
+ ...DIFF,
763
+ "--numstat",
764
+ "-z",
765
+ fork.commit,
766
+ "--"
767
+ ], void 0, overrides);
768
+ const untracked = await untrackedFiles(ctx, config, gitIn(ctx, {
769
+ ...config,
770
+ maxOutputBytes: config.maxDiffBytes
771
+ }, session, root, signal), root, signal);
772
+ return {
773
+ base: fork.label,
774
+ files: [...parseNumstat(numstat.stdout), ...untracked.files.map((file) => ({
775
+ path: file.path,
776
+ additions: file.additions,
777
+ deletions: 0,
778
+ binary: file.binary
779
+ }))]
780
+ };
781
+ }
782
+ /**
783
+ * Count the commits the checkout has beyond a change request's head, which a
784
+ * change request diff does not show yet.
785
+ * @param git - runner bound to the Session's directory.
786
+ * @param branch - the change request's head branch.
787
+ * @param commit - the change request's head commit.
788
+ * @returns the count, or null on another branch, outside a checkout, or without the commit locally.
789
+ */
790
+ async function aheadOf(git, branch, commit) {
791
+ const head = await git("symbolic-ref", [
792
+ "symbolic-ref",
793
+ "--quiet",
794
+ "--short",
795
+ "HEAD"
796
+ ], [
797
+ 0,
798
+ 1,
799
+ 128
800
+ ]);
801
+ if (head.exitCode !== 0 || parseName(head.stdout) !== branch) return null;
802
+ if ((await git("cat-file", [
803
+ "cat-file",
804
+ "-e",
805
+ `${commit}^{commit}`
806
+ ], [
807
+ 0,
808
+ 1,
809
+ 128
810
+ ])).exitCode !== 0) return null;
811
+ return Number((await git("rev-list", [
812
+ "rev-list",
813
+ "--count",
814
+ `${commit}..HEAD`
815
+ ])).stdout.trim());
816
+ }
817
+ /** HEAD, or the empty tree while the checked-out branch has no commit. */
818
+ async function headOrEmptyTree(git) {
819
+ if ((await git("head", [
820
+ "rev-parse",
821
+ "--verify",
822
+ "--quiet",
823
+ "HEAD"
824
+ ], [0, 1])).exitCode === 0) return "HEAD";
825
+ return (await git("empty-tree", [
826
+ "hash-object",
827
+ "-t",
828
+ "tree",
829
+ "--stdin"
830
+ ])).stdout.trim();
831
+ }
832
+ /** Untracked files Git does not ignore, at most `maxUntrackedFiles`; a nested repository and a file gone since the listing are left out. */
833
+ async function untrackedFiles(ctx, config, git, root, signal) {
834
+ const paths = (await bounded(git("untracked", [
835
+ "ls-files",
836
+ "--others",
837
+ "--exclude-standard",
838
+ "-z"
839
+ ]))).stdout.split("\0").filter((path) => path.length > 0 && !path.endsWith("/"));
840
+ const files = [];
841
+ for (const path of paths.slice(0, config.maxUntrackedFiles)) {
842
+ let content;
843
+ try {
844
+ content = await readFile(ctx, config, root, path, signal);
845
+ } catch (error) {
846
+ if (error instanceof DiffTooLarge) {
847
+ files.push({
848
+ ...untrackedFile(path, new Uint8Array()),
849
+ oversized: true
850
+ });
851
+ continue;
852
+ }
853
+ if (error instanceof FsError) continue;
854
+ throw error;
855
+ }
856
+ files.push(untrackedFile(path, content));
857
+ }
858
+ return {
859
+ files,
860
+ omitted: Math.max(0, paths.length - config.maxUntrackedFiles)
861
+ };
862
+ }
863
+ /** Read one file of the checkout through `ctx.fs`, at most `maxDiffBytes`. */
864
+ async function readFile(ctx, config, root, path, signal) {
865
+ const target = await ctx.fs.resolve(path, {
866
+ cwd: root,
867
+ signal
868
+ });
869
+ try {
870
+ return await ctx.fs.readBytes(target, signal, config.maxDiffBytes);
871
+ } catch (error) {
872
+ if (error instanceof FsError && error.code === "FS_TOO_LARGE") throw new DiffTooLarge(`${path} exceeds maxDiffBytes`, { cause: error });
873
+ throw error;
874
+ }
875
+ }
876
+ /** Translate the runner's output limit into DiffTooLarge. */
877
+ async function bounded(output) {
878
+ try {
879
+ return await output;
880
+ } catch (error) {
881
+ if (error instanceof GitOutputTooLarge) throw new DiffTooLarge(error.message, { cause: error });
882
+ throw error;
883
+ }
884
+ }
885
+ /** Whether a diff path stays inside the checkout: relative, without NUL, and without a `..` segment. */
886
+ function insideCheckout(path) {
887
+ if (path.length === 0 || path.includes("\0") || path.startsWith("/") || /^[A-Za-z]:/.test(path)) return false;
888
+ return path.split(/[/\\]/).every((segment) => segment !== "..");
889
+ }
890
+ //#endregion
891
+ //#region lib/types/worktree.js
892
+ /** The worktree a Session requests before it starts: its record, its projection, and its creation. */
893
+ /** Plugin record type of worktree requests, withdrawals, and creations. */
894
+ const WORKTREE_RECORD = "plugin:client-ui-forge/worktree";
895
+ const EMPTY = {
896
+ request: null,
897
+ created: null,
898
+ locked: false,
899
+ stepped: false,
900
+ restoring: false
901
+ };
902
+ const worktreeSchema = z$1.object({
903
+ path: z$1.string().min(1),
904
+ branch: z$1.string().min(1),
905
+ from: z$1.string().min(1).nullable(),
906
+ baseCommit: z$1.string().min(1),
907
+ repositoryRoot: z$1.string().min(1)
908
+ }).strict();
909
+ const recordSchema = z$1.discriminatedUnion("action", [
910
+ z$1.object({
911
+ action: z$1.literal("request"),
912
+ from: z$1.string().min(1).nullable()
913
+ }).strict(),
914
+ z$1.object({ action: z$1.literal("cancel") }).strict(),
915
+ z$1.object({
916
+ action: z$1.literal("created"),
917
+ worktree: worktreeSchema
918
+ }).strict(),
919
+ z$1.object({ action: z$1.literal("restoring") }).strict(),
920
+ z$1.object({ action: z$1.literal("restored") }).strict()
921
+ ]);
922
+ /**
923
+ * The `forgeWorktree` unit. `request` and `cancel` records change the request
924
+ * until the first `turn/start` or creation; the first `created` record of an
925
+ * open request applies until the first `step/start`. Records outside those
926
+ * periods leave the state unchanged. The unit also follows the Session's
927
+ * working directory, so clients, including the Session summary, see the
928
+ * created worktree only while that directory is the worktree or lies below it.
929
+ * Restoration records retain incomplete checkout recovery across reloads.
930
+ */
931
+ const forgeWorktreeProjection = {
932
+ key: "forgeWorktree",
933
+ stateVersion: 4,
934
+ stateSchema: z$1.object({
935
+ request: z$1.object({ from: z$1.string().min(1).nullable() }).strict().nullable(),
936
+ created: worktreeSchema.nullable(),
937
+ locked: z$1.boolean(),
938
+ stepped: z$1.boolean(),
939
+ restoring: z$1.boolean(),
940
+ cwd: z$1.string().min(1).nullable()
941
+ }).strict(),
942
+ init: (header) => ({
943
+ ...EMPTY,
944
+ cwd: header.cwd ?? null
945
+ }),
946
+ apply,
947
+ wire: {
948
+ viewSchema: worktreeSchema.nullable(),
949
+ view: (state) => state.created !== null && state.cwd !== null && within(state.created.path, state.cwd) ? state.created : null
950
+ }
951
+ };
952
+ /**
953
+ * Whether `path` is `root` or lies below it.
954
+ * @param root - absolute directory.
955
+ * @param path - absolute path.
956
+ * @returns whether `root` contains `path`.
957
+ */
958
+ function within(root, path) {
959
+ const rest = relative(root, path);
960
+ return rest === "" || rest !== ".." && !rest.startsWith(`..${sep}`) && !isAbsolute(rest);
961
+ }
962
+ /**
963
+ * The text of a failure: an Error's message, or else the thrown value as a string.
964
+ * @param error - what was thrown.
965
+ * @returns the text.
966
+ */
967
+ function messageOf(error) {
968
+ return error instanceof Error ? error.message : String(error);
969
+ }
970
+ function apply(state, event) {
971
+ switch (event.type) {
972
+ case "turn/start": return state.locked ? state : {
973
+ ...state,
974
+ locked: true
975
+ };
976
+ case "step/start": return state.stepped ? state : {
977
+ ...state,
978
+ stepped: true
979
+ };
980
+ case "working-directory/change": return state.cwd === event.data.cwd ? state : {
981
+ ...state,
982
+ cwd: event.data.cwd
983
+ };
984
+ default: return applyRecord(state, event);
985
+ }
986
+ }
987
+ function applyRecord(state, event) {
988
+ const record = pluginRecordOf(event);
989
+ if (record?.type !== "plugin:client-ui-forge/worktree") return state;
990
+ const parsed = recordSchema.safeParse(record.data);
991
+ if (!parsed.success) return state;
992
+ const data = parsed.data;
993
+ const settled = state.created !== null;
994
+ switch (data.action) {
995
+ case "request": return state.locked || settled ? state : {
996
+ ...state,
997
+ request: { from: data.from }
998
+ };
999
+ case "cancel": return state.locked || settled || state.request === null ? state : {
1000
+ ...state,
1001
+ request: null
1002
+ };
1003
+ case "created": return settled || state.stepped || state.request === null ? state : {
1004
+ ...state,
1005
+ created: data.worktree
1006
+ };
1007
+ case "restoring": return !settled || state.restoring ? state : {
1008
+ ...state,
1009
+ restoring: true
1010
+ };
1011
+ case "restored": return !state.restoring ? state : {
1012
+ ...state,
1013
+ restoring: false
1014
+ };
1015
+ /* v8 ignore next -- closed WorktreeRecord action union exhaustiveness guard */
1016
+ default: return assertNever(data, "WorktreeRecord.action");
1017
+ }
1018
+ }
1019
+ /** A failed attempt to create a requested worktree; `cause` holds the worktree service's error. */
1020
+ var CreationFailure = class extends Error {
1021
+ branch;
1022
+ phase;
1023
+ reason;
1024
+ /**
1025
+ * @param branch - branch the attempt created.
1026
+ * @param phase - step that failed.
1027
+ * @param reason - the worktree service's failure text.
1028
+ * @param cause - the worktree service's error.
1029
+ */
1030
+ constructor(branch, phase, reason, cause) {
1031
+ super(`The requested worktree ${branch} could not be created`, { cause });
1032
+ this.branch = branch;
1033
+ this.phase = phase;
1034
+ this.reason = reason;
1035
+ this.name = "CreationFailure";
1036
+ }
1037
+ };
1038
+ /**
1039
+ * The failure a waiting message shows for an attempt.
1040
+ * @param error - what the attempt threw.
1041
+ * @param from - base branch of the attempt, or null for HEAD.
1042
+ * @returns the failure.
1043
+ */
1044
+ function failureOf(error, from) {
1045
+ return error instanceof CreationFailure ? {
1046
+ branch: error.branch,
1047
+ from,
1048
+ phase: error.phase,
1049
+ message: error.reason
1050
+ } : {
1051
+ branch: null,
1052
+ from,
1053
+ phase: "prepare",
1054
+ message: messageOf(error)
1055
+ };
1056
+ }
1057
+ /**
1058
+ * Creates requested worktrees, one attempt with a new branch name per call,
1059
+ * and holds the turn of a message while the Session's worktree is not ready.
1060
+ * A hold is maintenance of the Session's Agent that starts while the message
1061
+ * enters the inbox, before the delivery wakes the Agent, so the Agent starts
1062
+ * the message's turn once the hold ends. A creation hold makes an attempt
1063
+ * and, after a failed or stopped one, waits for another attempt or for the
1064
+ * request to be withdrawn; a restoration hold checks the worktree the Session
1065
+ * works in and, when it is gone, waits until it is restored or left. The
1066
+ * Agent's cancellation and the panel's stop end a hold. The first step
1067
+ * proposal of a Session whose request is still open also creates the
1068
+ * worktree, which covers a message no hold took. The worktree service enters
1069
+ * the created checkout and queues its directory notice; at a step proposal
1070
+ * the notice joins the proposed messages so the first request already names
1071
+ * the new directory, a proposal without messages leaves it queued, and a
1072
+ * failure ends the turn with the request still open. A failed attempt may
1073
+ * leave its branch or checkout behind.
1074
+ */
1075
+ var WorktreeCreations = class {
1076
+ ctx;
1077
+ states;
1078
+ prefix;
1079
+ changed;
1080
+ live = /* @__PURE__ */ new Map();
1081
+ restorations = /* @__PURE__ */ new Map();
1082
+ failures = /* @__PURE__ */ new Map();
1083
+ holds = /* @__PURE__ */ new Map();
1084
+ lifetime = new AbortController();
1085
+ pending = /* @__PURE__ */ new Set();
1086
+ /** Stop admission, cancel operations, and wait for their final callbacks. */
1087
+ dispose;
1088
+ /**
1089
+ * @param ctx - context that optionally provides `worktrees`.
1090
+ * @param states - reads the `forgeWorktree` projection of a Session.
1091
+ * @param prefix - branch name prefix that `generatedWorktreeName` combines with the Session id and random digits.
1092
+ * @param changed - called after the creation or restoration of a Session starts, changes phase, or ends, after its
1093
+ * failure changes, and after a wait starts or ends.
1094
+ */
1095
+ constructor(ctx, states, prefix, changed) {
1096
+ this.ctx = ctx;
1097
+ this.states = states;
1098
+ this.prefix = prefix;
1099
+ this.changed = changed;
1100
+ ctx.on("agent/pre-step", async ({ agent, step, signal }, next) => await this.preStep(agent, step, signal, next));
1101
+ this.dispose = ctx.effect(() => async () => {
1102
+ this.lifetime.abort(/* @__PURE__ */ new Error("The forge panel stopped"));
1103
+ await Promise.allSettled(this.pending);
1104
+ }, "forge-panel: stop worktree operations");
1105
+ }
1106
+ /**
1107
+ * Read the running creation of a Session.
1108
+ * @param session - Session to read.
1109
+ * @returns the running creation, or null.
1110
+ */
1111
+ of(session) {
1112
+ return this.live.get(session) ?? null;
1113
+ }
1114
+ /**
1115
+ * Read the running restoration of a Session.
1116
+ * @param session - Session to read.
1117
+ * @returns the running restoration, or null.
1118
+ */
1119
+ restorationOf(session) {
1120
+ return this.restorations.get(session) ?? null;
1121
+ }
1122
+ /**
1123
+ * Read the failed attempt that a waiting message saw last.
1124
+ * @param session - Session to read.
1125
+ * @returns the failure, or null.
1126
+ */
1127
+ failureOf(session) {
1128
+ return this.failures.get(session) ?? null;
1129
+ }
1130
+ /**
1131
+ * Read what a message of a Session waits for before its turn starts.
1132
+ * @param session - Session to read.
1133
+ * @returns the creation or restoration the message waits for, or null while none waits.
1134
+ */
1135
+ waitingOf(session) {
1136
+ const hold = this.holds.get(session);
1137
+ if (hold === void 0 || hold.kind === "restoration" && !hold.waiting) return null;
1138
+ return hold.kind;
1139
+ }
1140
+ /**
1141
+ * Forget the failure of a Session whose request changes or is withdrawn; the record of that change republishes the panel.
1142
+ * @param session - Session whose failure no longer applies.
1143
+ */
1144
+ forget(session) {
1145
+ this.failures.delete(session);
1146
+ }
1147
+ /**
1148
+ * Hold the turn of a message entering the inbox of a Session whose
1149
+ * requested worktree does not exist yet, and create the worktree. Call it
1150
+ * while the message enters, before the delivery wakes the Agent. A Session
1151
+ * already held, or an Agent with other work, gets no new hold.
1152
+ * @param agent - Agent of the Session.
1153
+ */
1154
+ holdCreation(agent) {
1155
+ this.hold(agent, {
1156
+ kind: "creation",
1157
+ stop: void 0,
1158
+ next: void 0
1159
+ }, (hold, signal) => this.runCreation(agent, hold, signal));
1160
+ }
1161
+ /**
1162
+ * Hold the turn of a message entering the inbox of a Session that works in
1163
+ * its created worktree while `check` decides whether that worktree is gone,
1164
+ * and until it is restored or left when it is. Call it while the message
1165
+ * enters, before the delivery wakes the Agent. A Session already held, or an
1166
+ * Agent with other work, gets no new hold.
1167
+ * @param agent - Agent of the Session.
1168
+ * @param check - answers whether the worktree is gone; a rejection lets the message go on.
1169
+ */
1170
+ holdRestoration(agent, check) {
1171
+ this.hold(agent, {
1172
+ kind: "restoration",
1173
+ waiting: false,
1174
+ next: void 0
1175
+ }, (hold, signal) => this.runRestoration(agent, hold, check, signal));
1176
+ }
1177
+ /**
1178
+ * Make another attempt for the message that waits after a failed or stopped one.
1179
+ * @param session - Session whose message waits.
1180
+ * @returns whether a waiting message without a running attempt took the decision.
1181
+ */
1182
+ retry(session) {
1183
+ const hold = this.holds.get(session);
1184
+ if (hold?.kind !== "creation" || hold.next === void 0) return false;
1185
+ hold.next.resolve("retry");
1186
+ return true;
1187
+ }
1188
+ /**
1189
+ * Read the request again after it changed; a withdrawn request lets the waiting message go on.
1190
+ * @param session - Session whose request changed.
1191
+ */
1192
+ recheck(session) {
1193
+ const hold = this.holds.get(session);
1194
+ if (hold?.kind === "creation") hold.next?.resolve("recheck");
1195
+ }
1196
+ /**
1197
+ * Run work on the removed worktree a message waits for, inside the wait; its success lets the message go on.
1198
+ * @param session - Session whose message waits.
1199
+ * @param work - restores or leaves the worktree.
1200
+ * @returns the work's outcome, or undefined when no message waits for the removed worktree.
1201
+ */
1202
+ within(session, work) {
1203
+ const hold = this.holds.get(session);
1204
+ if (hold?.kind !== "restoration" || hold.next === void 0) return void 0;
1205
+ const done = Promise.withResolvers();
1206
+ hold.next.resolve({
1207
+ work,
1208
+ done
1209
+ });
1210
+ return done.promise;
1211
+ }
1212
+ /**
1213
+ * Stop the running attempt a message waits for; the message keeps waiting
1214
+ * with the request still open. Without such an attempt the Session is unchanged.
1215
+ * @param session - Session whose attempt stops.
1216
+ */
1217
+ stop(session) {
1218
+ const hold = this.holds.get(session);
1219
+ if (hold?.kind === "creation") hold.stop?.abort(/* @__PURE__ */ new Error("The worktree creation was stopped"));
1220
+ }
1221
+ /**
1222
+ * Check the removed worktree of a Session out again through the worktree
1223
+ * service; the running restoration is readable through
1224
+ * {@link WorktreeCreations.restorationOf} until it ends.
1225
+ * @param agent - Agent of the Session.
1226
+ * @param worktrees - the worktree service.
1227
+ * @param created - the worktree the Session created.
1228
+ * @param signal - cancellation of the restoration.
1229
+ * @param restored - runs once the checkout is back, before the restoration ends, so both changes publish together.
1230
+ * @param available - checks whether the recorded checkout is already usable, within this operation's lifetime.
1231
+ * @throws the worktree service's error when the restoration fails; the cancellation reason once `signal` aborts.
1232
+ */
1233
+ async restore(agent, worktrees, created, signal, restored, available) {
1234
+ await this.run(signal, async (operation) => {
1235
+ const usable = await available(operation);
1236
+ operation.throwIfAborted();
1237
+ if (!usable) await this.restoreCheckout(agent, worktrees, created, operation, restored);
1238
+ });
1239
+ }
1240
+ async restoreCheckout(agent, worktrees, created, signal, restored) {
1241
+ const session = agent.session;
1242
+ appendPluginRecord(session, WORKTREE_RECORD, { action: "restoring" });
1243
+ try {
1244
+ await worktrees.restore(agent, created, signal, (phase) => {
1245
+ this.restorations.set(session, { phase });
1246
+ this.changed(session);
1247
+ });
1248
+ signal.throwIfAborted();
1249
+ appendPluginRecord(session, WORKTREE_RECORD, { action: "restored" });
1250
+ restored();
1251
+ } finally {
1252
+ this.restorations.delete(session);
1253
+ this.changed(session);
1254
+ }
1255
+ }
1256
+ /**
1257
+ * Create a Session's requested worktree with a new branch name, enter it, and
1258
+ * record it on the Session. The running creation is readable through
1259
+ * {@link WorktreeCreations.of} until the record exists or the attempt fails.
1260
+ * @param agent - Agent of the Session.
1261
+ * @param from - base branch, or null for HEAD of the current checkout.
1262
+ * @param signal - cancellation of the attempt.
1263
+ * @param held - whether a message waits for the creation before its turn starts.
1264
+ * @returns the created worktree.
1265
+ * @throws {@link CreationFailure} when the attempt fails; the cancellation reason once `signal` aborts.
1266
+ */
1267
+ async create(agent, from, signal, held) {
1268
+ return await this.run(signal, (operation) => this.createCheckout(agent, from, operation, held));
1269
+ }
1270
+ async createCheckout(agent, from, signal, held) {
1271
+ const worktrees = this.ctx.get("worktrees");
1272
+ if (worktrees === void 0) throw new Error("No worktree service is mounted");
1273
+ const session = agent.session;
1274
+ let status = {
1275
+ phase: "prepare",
1276
+ branch: generatedWorktreeName(this.prefix, session.id),
1277
+ from,
1278
+ held
1279
+ };
1280
+ this.failures.delete(session);
1281
+ this.live.set(session, status);
1282
+ this.changed(session);
1283
+ try {
1284
+ let created;
1285
+ try {
1286
+ created = await worktrees.create(agent, {
1287
+ name: status.branch,
1288
+ ...from === null ? {} : { from }
1289
+ }, signal, (phase) => {
1290
+ status = {
1291
+ ...status,
1292
+ phase
1293
+ };
1294
+ this.live.set(session, status);
1295
+ this.changed(session);
1296
+ });
1297
+ } catch (error) {
1298
+ signal.throwIfAborted();
1299
+ throw new CreationFailure(status.branch, status.phase, messageOf(error), error);
1300
+ }
1301
+ signal.throwIfAborted();
1302
+ appendPluginRecord(session, WORKTREE_RECORD, {
1303
+ action: "created",
1304
+ worktree: {
1305
+ path: created.path,
1306
+ branch: created.branch,
1307
+ from,
1308
+ baseCommit: created.baseCommit,
1309
+ repositoryRoot: created.repositoryRoot
1310
+ }
1311
+ });
1312
+ return created;
1313
+ } finally {
1314
+ this.live.delete(session);
1315
+ this.changed(session);
1316
+ }
1317
+ }
1318
+ /**
1319
+ * Run a hold as maintenance of the Agent, which latches the delivery's wake until the hold ends.
1320
+ * @param agent - Agent of the Session.
1321
+ * @param hold - the hold's state.
1322
+ * @param body - waits for the worktree; settles once the message may go on.
1323
+ */
1324
+ hold(agent, hold, body) {
1325
+ if (this.lifetime.signal.aborted) return;
1326
+ const session = agent.session;
1327
+ if (this.holds.has(session)) return;
1328
+ let task;
1329
+ try {
1330
+ task = agent.runMaintenance(async (maintenance) => {
1331
+ await Promise.resolve();
1332
+ try {
1333
+ await body(hold, AbortSignal.any([maintenance, this.lifetime.signal]));
1334
+ } catch (error) {} finally {
1335
+ this.holds.delete(session);
1336
+ this.failures.delete(session);
1337
+ this.changed(session);
1338
+ }
1339
+ });
1340
+ } catch (error) {
1341
+ return;
1342
+ }
1343
+ this.holds.set(session, hold);
1344
+ this.join(task);
1345
+ }
1346
+ async run(signal, body) {
1347
+ const operation = AbortSignal.any([signal, this.lifetime.signal]);
1348
+ operation.throwIfAborted();
1349
+ return await this.join(body(operation));
1350
+ }
1351
+ async join(task) {
1352
+ this.pending.add(task);
1353
+ try {
1354
+ return await task;
1355
+ } finally {
1356
+ this.pending.delete(task);
1357
+ }
1358
+ }
1359
+ /**
1360
+ * Make attempts until one creates the worktree or the request no longer asks
1361
+ * for one, waiting for a decision after each failed or stopped attempt.
1362
+ */
1363
+ async runCreation(agent, hold, signal) {
1364
+ const session = agent.session;
1365
+ for (;;) {
1366
+ const state = this.states.stateOf(session);
1367
+ if (state.request === null || state.created !== null || state.locked) return;
1368
+ const stop = new AbortController();
1369
+ hold.stop = stop;
1370
+ try {
1371
+ await this.create(agent, state.request.from, AbortSignal.any([signal, stop.signal]), true);
1372
+ return;
1373
+ } catch (error) {
1374
+ signal.throwIfAborted();
1375
+ if (stop.signal.aborted) this.failures.delete(session);
1376
+ else this.failures.set(session, failureOf(error, state.request.from));
1377
+ this.changed(session);
1378
+ } finally {
1379
+ hold.stop = void 0;
1380
+ }
1381
+ while (await this.wait(hold, signal) !== "retry") {
1382
+ const current = this.states.stateOf(session);
1383
+ if (current.request === null || current.created !== null || current.locked) return;
1384
+ }
1385
+ }
1386
+ }
1387
+ /** Check the worktree the Session works in and, when it is gone, run the work that restores or leaves it until one succeeds. */
1388
+ async runRestoration(agent, hold, check, signal) {
1389
+ let missing;
1390
+ try {
1391
+ missing = await check(signal);
1392
+ } catch (error) {
1393
+ signal.throwIfAborted();
1394
+ return;
1395
+ }
1396
+ if (missing === null) return;
1397
+ hold.waiting = true;
1398
+ this.changed(agent.session);
1399
+ for (;;) {
1400
+ const { work, done } = await this.wait(hold, signal);
1401
+ try {
1402
+ await work(agent, signal);
1403
+ } catch (error) {
1404
+ done.reject(error);
1405
+ signal.throwIfAborted();
1406
+ continue;
1407
+ }
1408
+ done.resolve();
1409
+ return;
1410
+ }
1411
+ }
1412
+ /** Wait for the next decision on a hold; rejects with the abort reason once `signal` aborts. */
1413
+ async wait(hold, signal) {
1414
+ signal.throwIfAborted();
1415
+ const next = Promise.withResolvers();
1416
+ hold.next = next;
1417
+ const abort = () => {
1418
+ next.reject(signal.reason);
1419
+ };
1420
+ signal.addEventListener("abort", abort, { once: true });
1421
+ try {
1422
+ return await next.promise;
1423
+ } finally {
1424
+ signal.removeEventListener("abort", abort);
1425
+ hold.next = void 0;
1426
+ }
1427
+ }
1428
+ async preStep(agent, step, signal, next) {
1429
+ const state = this.states.stateOf(agent.session);
1430
+ if (step !== 1 || state.request === null || state.created !== null || state.stepped) return await next();
1431
+ await this.create(agent, state.request.from, signal, false);
1432
+ const decision = await next();
1433
+ if (decision.kind === "reject" || decision.messages.length === 0) return decision;
1434
+ const notices = agent.inbox.nextStep.filter((message) => message.source.kind === "working-directory");
1435
+ for (const notice of notices) agent.inbox.remove(notice.id);
1436
+ return notices.length === 0 ? decision : {
1437
+ ...decision,
1438
+ messages: [...decision.messages, ...notices]
1439
+ };
1440
+ }
1441
+ };
1442
+ //#endregion
1443
+ //#region lib/types/panel.js
1444
+ /** Live panel state of one followed Session, shared by all of its followers. */
1445
+ var __addDisposableResource = function(env, value, async) {
1446
+ if (value !== null && value !== void 0) {
1447
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
1448
+ var dispose, inner;
1449
+ if (async) {
1450
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
1451
+ dispose = value[Symbol.asyncDispose];
1452
+ }
1453
+ if (dispose === void 0) {
1454
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
1455
+ dispose = value[Symbol.dispose];
1456
+ if (async) inner = dispose;
1457
+ }
1458
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
1459
+ if (inner) dispose = function() {
1460
+ try {
1461
+ inner.call(this);
1462
+ } catch (e) {
1463
+ return Promise.reject(e);
1464
+ }
1465
+ };
1466
+ env.stack.push({
1467
+ value,
1468
+ dispose,
1469
+ async
1470
+ });
1471
+ } else if (async) env.stack.push({ async: true });
1472
+ return value;
1473
+ };
1474
+ var __disposeResources = (function(SuppressedError) {
1475
+ return function(env) {
1476
+ function fail(e) {
1477
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
1478
+ env.hasError = true;
1479
+ }
1480
+ var r, s = 0;
1481
+ function next() {
1482
+ while (r = env.stack.pop()) try {
1483
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
1484
+ if (r.dispose) {
1485
+ var result = r.dispose.call(r.value);
1486
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
1487
+ fail(e);
1488
+ return next();
1489
+ });
1490
+ } else s |= 1;
1491
+ } catch (e) {
1492
+ fail(e);
1493
+ }
1494
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
1495
+ if (env.hasError) throw env.error;
1496
+ }
1497
+ return next();
1498
+ };
1499
+ })(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
1500
+ var e = new Error(message);
1501
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
1502
+ });
1503
+ /**
1504
+ * Panel state of one Session. Construction starts the first checkout
1505
+ * inspection and watches the Session's change requests. Inspections run one at
1506
+ * a time; a request that arrives during one runs once more after it, and a
1507
+ * result for a directory the Session has since left is dropped. A sign-in
1508
+ * probe runs when an inspection recognizes a new code host and on `refresh`;
1509
+ * a probe result for a host the panel no longer shows is dropped. Each change
1510
+ * request keeps the latest snapshot a poll returned while later polls fail.
1511
+ * When an inspection finds an upstream branch on a remote a provider
1512
+ * recognizes, the open change requests of that branch are bound as discovered,
1513
+ * once per remote and branch until a refresh. The
1514
+ * worktree facts follow the `forgeWorktree` projection, the live creation, and the last failed one;
1515
+ * each inspection also decides whether the Session's sandbox can write the
1516
+ * checkout and its shared Git directory, which a new worktree needs, whether
1517
+ * the worktree the Session created and works in is gone, and how many lines
1518
+ * the `branch` scope changes; a measurement that fails leaves that null.
1519
+ */
1520
+ var SessionPanel = class {
1521
+ ctx;
1522
+ config;
1523
+ session;
1524
+ worktreeStates;
1525
+ creations;
1526
+ onIdle;
1527
+ current;
1528
+ followers = /* @__PURE__ */ new Set();
1529
+ lifetime = new AbortController();
1530
+ tasks = /* @__PURE__ */ new Set();
1531
+ stopWatching;
1532
+ snapshots = /* @__PURE__ */ new Map();
1533
+ blocker = null;
1534
+ missing = null;
1535
+ inspection = null;
1536
+ rerun = null;
1537
+ /** Remote URL and branch of the upstream that the last branch lookup covered, until a refresh. */
1538
+ discovered = null;
1539
+ /**
1540
+ * @param ctx - service context that provides the forge, Session, directory, and process services.
1541
+ * @param config - Git limits and the sign-in probe deadline.
1542
+ * @param session - Session the panel describes.
1543
+ * @param worktreeStates - reads the `forgeWorktree` projection of the Session.
1544
+ * @param creations - reads the running creation and restoration, the last failed creation, and what a message of the
1545
+ * Session waits for.
1546
+ * @param onIdle - called when the last follower leaves.
1547
+ */
1548
+ constructor(ctx, config, session, worktreeStates, creations, onIdle) {
1549
+ this.ctx = ctx;
1550
+ this.config = config;
1551
+ this.session = session;
1552
+ this.worktreeStates = worktreeStates;
1553
+ this.creations = creations;
1554
+ this.onIdle = onIdle;
1555
+ this.current = {
1556
+ directory: ctx.workingDirectory.get(session),
1557
+ checkout: { state: "pending" },
1558
+ repository: null,
1559
+ readiness: null,
1560
+ changeRequests: this.entries(ctx.forgeSession.changeRequests(session)),
1561
+ worktree: this.worktree(),
1562
+ local: null
1563
+ };
1564
+ this.stopWatching = ctx.forgeSession.watch(session, (changeRequests) => {
1565
+ this.publish({
1566
+ ...this.current,
1567
+ changeRequests: this.entries(changeRequests)
1568
+ });
1569
+ });
1570
+ this.inspect();
1571
+ }
1572
+ /**
1573
+ * Yield the current state, then each later state, until `signal` aborts or the panel stops.
1574
+ * @param signal - ends this follower.
1575
+ * @returns the states in publication order; intermediate states may be skipped.
1576
+ */
1577
+ async *follow(signal) {
1578
+ let wake;
1579
+ const notify = () => {
1580
+ wake?.();
1581
+ };
1582
+ this.followers.add(notify);
1583
+ signal.addEventListener("abort", notify);
1584
+ this.lifetime.signal.addEventListener("abort", notify);
1585
+ try {
1586
+ let delivered;
1587
+ while (!signal.aborted && !this.lifetime.signal.aborted) {
1588
+ if (this.current !== delivered) {
1589
+ delivered = this.current;
1590
+ yield delivered;
1591
+ continue;
1592
+ }
1593
+ await new Promise((resolve) => {
1594
+ wake = resolve;
1595
+ });
1596
+ wake = void 0;
1597
+ }
1598
+ } finally {
1599
+ this.followers.delete(notify);
1600
+ signal.removeEventListener("abort", notify);
1601
+ this.lifetime.signal.removeEventListener("abort", notify);
1602
+ if (this.followers.size === 0) this.onIdle();
1603
+ }
1604
+ }
1605
+ /**
1606
+ * React to one committed event of the Session: a directory change moves the
1607
+ * panel and reinspects; a tool result reinspects, because the tool may have
1608
+ * changed the checkout; a sandbox mode change reinspects, because it decides
1609
+ * whether a worktree can be created; the first turn, the first step, and
1610
+ * worktree records update the worktree facts.
1611
+ * @param event - committed Session event.
1612
+ */
1613
+ observe(event) {
1614
+ if (event.type === "working-directory/change") {
1615
+ this.publish({
1616
+ ...this.current,
1617
+ directory: event.data.cwd,
1618
+ checkout: { state: "pending" },
1619
+ local: null
1620
+ });
1621
+ this.inspect();
1622
+ } else if (event.type === "tool/result" || event.type === "sandbox/mode") this.inspect();
1623
+ else if (event.type === "turn/start" || event.type === "step/start" || pluginRecordOf(event)?.type === "plugin:client-ui-forge/worktree") this.updateWorktree();
1624
+ }
1625
+ /** Republish the worktree facts when they changed, such as after a creation moved or a worktree service appeared or left. */
1626
+ updateWorktree() {
1627
+ const worktree = this.worktree();
1628
+ if (!sameWorktree(worktree, this.current.worktree)) this.publish({
1629
+ ...this.current,
1630
+ worktree
1631
+ });
1632
+ }
1633
+ /** Forget the missing worktree once it was checked out again; the next publication carries it, and a reinspection confirms it. */
1634
+ restored() {
1635
+ this.missing = null;
1636
+ }
1637
+ /** Reinspect the checkout, such as after its directory was created again. */
1638
+ reinspect() {
1639
+ this.inspect();
1640
+ }
1641
+ /**
1642
+ * Check again whether the worktree the Session works in is gone, publishing a change.
1643
+ * @param signal - cancellation of the check.
1644
+ * @returns the unavailable worktree, or null for a valid checkout or a Session working elsewhere.
1645
+ * @throws when the filesystem or Git cannot answer.
1646
+ */
1647
+ async checkMissing(signal) {
1648
+ const directory = this.current.directory;
1649
+ const state = this.worktreeStates.stateOf(this.session);
1650
+ const missing = await missingWorktree(this.ctx, this.config, this.session, state, directory, AbortSignal.any([signal, this.lifetime.signal]));
1651
+ if (this.current.directory === directory) {
1652
+ this.missing = missing;
1653
+ this.updateWorktree();
1654
+ }
1655
+ return missing;
1656
+ }
1657
+ /**
1658
+ * Reinspect the checkout, probe sign-in again, and poll every bound change request at once.
1659
+ * @returns settles once the inspection and the sign-in probe have ended; each poll publishes its result when it finishes.
1660
+ */
1661
+ async refresh() {
1662
+ this.discovered = null;
1663
+ const inspection = this.inspect();
1664
+ const probe = this.current.repository === null ? void 0 : this.probe(this.current.repository);
1665
+ for (const entry of this.current.changeRequests) if (entry.ref !== null) this.ctx.forge.refresh(entry.ref);
1666
+ await Promise.all([inspection, probe]);
1667
+ }
1668
+ /**
1669
+ * Stop inspections, probes, and the change request watch, end every follower, and wait for pending work.
1670
+ * @returns settles once pending inspections and probes have ended.
1671
+ */
1672
+ async dispose() {
1673
+ this.lifetime.abort();
1674
+ this.stopWatching();
1675
+ await Promise.allSettled(this.tasks);
1676
+ }
1677
+ entries(changeRequests) {
1678
+ return changeRequests.map((entry) => {
1679
+ if (entry.update !== null && "snapshot" in entry.update) this.snapshots.set(entry.url, entry.update.snapshot);
1680
+ return {
1681
+ ...entry,
1682
+ snapshot: this.snapshots.get(entry.url) ?? null
1683
+ };
1684
+ });
1685
+ }
1686
+ worktree() {
1687
+ const state = this.worktreeStates.stateOf(this.session);
1688
+ return {
1689
+ editable: this.ctx.get("worktrees") !== void 0 && !state.locked && state.created === null,
1690
+ blocker: this.blocker,
1691
+ request: state.request,
1692
+ created: state.created,
1693
+ creation: this.creations.of(this.session),
1694
+ failure: this.creations.failureOf(this.session),
1695
+ missing: this.missing,
1696
+ restoration: this.creations.restorationOf(this.session),
1697
+ waiting: this.creations.waitingOf(this.session)
1698
+ };
1699
+ }
1700
+ publish(next) {
1701
+ this.current = next;
1702
+ for (const notify of [...this.followers]) notify();
1703
+ }
1704
+ /** Track `task` for disposal; the returned promise resolves once it settles, whatever the outcome. */
1705
+ run(task) {
1706
+ this.tasks.add(task);
1707
+ const settle = () => {
1708
+ this.tasks.delete(task);
1709
+ };
1710
+ return task.then(settle, settle);
1711
+ }
1712
+ /**
1713
+ * Inspect the checkout, one inspection at a time; calls during an
1714
+ * inspection share one trailing rerun.
1715
+ * @returns resolves once an inspection that started after the call has ended.
1716
+ */
1717
+ inspect() {
1718
+ if (this.lifetime.signal.aborted) return Promise.resolve();
1719
+ if (this.inspection === null) {
1720
+ this.inspection = this.run(this.inspectOnce()).then(() => {
1721
+ this.inspection = null;
1722
+ });
1723
+ return this.inspection;
1724
+ }
1725
+ this.rerun ??= this.inspection.then(() => {
1726
+ this.rerun = null;
1727
+ return this.inspect();
1728
+ });
1729
+ return this.rerun;
1730
+ }
1731
+ async inspectOnce() {
1732
+ const directory = this.current.directory;
1733
+ let checkout;
1734
+ let repository = null;
1735
+ let blocker = null;
1736
+ let local = null;
1737
+ let upstream = null;
1738
+ try {
1739
+ const inspection = await inspectCheckout(this.ctx, this.config, this.session, directory, this.lifetime.signal);
1740
+ if (inspection === void 0) {
1741
+ checkout = { state: "none" };
1742
+ blocker = "not-git";
1743
+ } else {
1744
+ checkout = {
1745
+ state: "ready",
1746
+ checkout: inspection.checkout
1747
+ };
1748
+ repository = this.recognize(inspection);
1749
+ upstream = this.upstreamOf(inspection);
1750
+ blocker = await this.blockerOf(inspection);
1751
+ local = await this.localOf(inspection);
1752
+ }
1753
+ } catch (error) {
1754
+ checkout = {
1755
+ state: "failed",
1756
+ message: error instanceof Error ? error.message : String(error)
1757
+ };
1758
+ }
1759
+ let missing = null;
1760
+ try {
1761
+ const state = this.worktreeStates.stateOf(this.session);
1762
+ missing = await missingWorktree(this.ctx, this.config, this.session, state, directory, this.lifetime.signal);
1763
+ } catch (_error) {}
1764
+ if (this.lifetime.signal.aborted || this.current.directory !== directory) return;
1765
+ this.blocker = blocker;
1766
+ this.missing = missing;
1767
+ const sameHost = repository !== null && this.current.repository !== null && hostOf(repository) === hostOf(this.current.repository);
1768
+ const readiness = sameHost ? this.current.readiness : null;
1769
+ this.publish({
1770
+ ...this.current,
1771
+ checkout,
1772
+ repository,
1773
+ readiness,
1774
+ worktree: this.worktree(),
1775
+ local
1776
+ });
1777
+ if (repository !== null && !sameHost) this.probe(repository);
1778
+ if (upstream !== null) this.discover(upstream);
1779
+ }
1780
+ /** The upstream branch with the first URL of its remote that a provider recognizes. */
1781
+ upstreamOf(inspection) {
1782
+ const tracked = upstreamBranch(inspection.upstream, inspection.remotes);
1783
+ const remote = tracked?.remote.urls.find((url) => this.ctx.forge.repositoryFromRemote(url) !== void 0);
1784
+ return tracked === void 0 || remote === void 0 ? null : {
1785
+ remote,
1786
+ branch: tracked.branch
1787
+ };
1788
+ }
1789
+ /** Bind the open change requests of the upstream branch, once per remote and branch until a refresh. */
1790
+ discover(upstream) {
1791
+ const key = `${upstream.remote}\n${upstream.branch}`;
1792
+ if (this.discovered === key) return;
1793
+ this.discovered = key;
1794
+ this.run(this.ctx.forgeSession.discoverBranch(this.session, upstream.remote, upstream.branch));
1795
+ }
1796
+ /** Decide whether the Session's sandbox lets the worktree service write the checkout and its shared Git directory. */
1797
+ async blockerOf(inspection) {
1798
+ const policy = this.ctx.sandboxPolicy.resolve({ session: this.session });
1799
+ if (policy.mode === "danger-full-access") return null;
1800
+ if (policy.mode === "read-only") return "read-only";
1801
+ const root = this.ctx.fs.processPath(await this.ctx.fs.resolve(policy.workspaceRoot, { signal: this.lifetime.signal }));
1802
+ return within(root, inspection.checkout.root) && within(root, inspection.commonDir) ? null : "outside-writable-root";
1803
+ }
1804
+ /** Measure the `branch` scope; a failed measurement only hides the local changes card. */
1805
+ async localOf(inspection) {
1806
+ try {
1807
+ return await localChanges(this.ctx, this.config, this.session, inspection, this.worktree().created, this.lifetime.signal);
1808
+ } catch (_error) {
1809
+ return null;
1810
+ }
1811
+ }
1812
+ recognize(inspection) {
1813
+ for (const remote of remoteOrder(inspection.upstream, inspection.remotes)) for (const url of remote.urls) {
1814
+ const repository = this.ctx.forge.repositoryFromRemote(url);
1815
+ if (repository !== void 0) return repository;
1816
+ }
1817
+ return null;
1818
+ }
1819
+ /** Probe sign-in on the repository's host; resolves once the probe has ended. */
1820
+ probe(repository) {
1821
+ const host = hostOf(repository);
1822
+ return this.run((async () => {
1823
+ let readiness;
1824
+ try {
1825
+ const env_1 = {
1826
+ stack: [],
1827
+ error: void 0,
1828
+ hasError: false
1829
+ };
1830
+ try {
1831
+ const timeout = __addDisposableResource(env_1, deadline(this.lifetime.signal, this.config.readinessTimeoutMs, "FORGE_PANEL_READINESS_TIMEOUT"), false);
1832
+ readiness = await this.ctx.forge.readiness(repository, timeout.signal);
1833
+ } catch (e_1) {
1834
+ env_1.error = e_1;
1835
+ env_1.hasError = true;
1836
+ } finally {
1837
+ __disposeResources(env_1);
1838
+ }
1839
+ } catch (error) {
1840
+ readiness = {
1841
+ state: "unavailable",
1842
+ message: error instanceof Error ? error.message : String(error)
1843
+ };
1844
+ }
1845
+ if (this.lifetime.signal.aborted || this.current.repository === null || hostOf(this.current.repository) !== host) return;
1846
+ this.publish({
1847
+ ...this.current,
1848
+ readiness
1849
+ });
1850
+ })());
1851
+ }
1852
+ };
1853
+ function sameWorktree(left, right) {
1854
+ return left.editable === right.editable && left.blocker === right.blocker && left.request === right.request && left.created === right.created && left.creation === right.creation && left.failure === right.failure && left.missing?.branchExists === right.missing?.branchExists && left.restoration === right.restoration && left.waiting === right.waiting;
1855
+ }
1856
+ /**
1857
+ * Decide whether a Session works inside the worktree it created while that
1858
+ * worktree's directory is gone, and whether its branch still exists.
1859
+ * @param ctx - context that provides `fs` and the process services.
1860
+ * @param config - Git limits.
1861
+ * @param session - Session to check.
1862
+ * @param state - recorded worktree and restoration completion.
1863
+ * @param directory - the Session's current directory.
1864
+ * @param signal - cancellation of the check.
1865
+ * @returns the unavailable worktree, or null for a valid checkout or a Session working elsewhere.
1866
+ * @throws when the filesystem or Git cannot answer.
1867
+ */
1868
+ async function missingWorktree(ctx, config, session, state, directory, signal) {
1869
+ const created = state.created;
1870
+ if (created === null || !within(created.path, directory)) return null;
1871
+ if (!state.restoring && await ctx.fs.lstat(created.path, void 0, signal) !== void 0) {
1872
+ const git = gitIn(ctx, config, session, created.path, signal);
1873
+ const top = await git("rev-parse", ["rev-parse", "--show-toplevel"], [0, 128]);
1874
+ if (top.exitCode === 0) {
1875
+ const target = await ctx.fs.resolve(created.path, { signal });
1876
+ if ((await ctx.fs.resolve(top.stdout.replace(/\r?\n$/, ""), {
1877
+ cwd: created.path,
1878
+ signal
1879
+ })).targetKey === target.targetKey) {
1880
+ const common = await git("common-dir", [
1881
+ "rev-parse",
1882
+ "--path-format=absolute",
1883
+ "--git-common-dir"
1884
+ ]);
1885
+ const source = await gitIn(ctx, config, session, created.repositoryRoot, signal)("common-dir", [
1886
+ "rev-parse",
1887
+ "--path-format=absolute",
1888
+ "--git-common-dir"
1889
+ ]);
1890
+ const actual = await ctx.fs.resolve(common.stdout.replace(/\r?\n$/, ""), {
1891
+ cwd: created.path,
1892
+ signal
1893
+ });
1894
+ const expected = await ctx.fs.resolve(source.stdout.replace(/\r?\n$/, ""), {
1895
+ cwd: created.repositoryRoot,
1896
+ signal
1897
+ });
1898
+ if (actual.targetKey === expected.targetKey) return null;
1899
+ }
1900
+ }
1901
+ }
1902
+ try {
1903
+ return { branchExists: await hasBranch(ctx, config, session, created.repositoryRoot, created.branch, signal) };
1904
+ } catch (error) {
1905
+ signal.throwIfAborted();
1906
+ if (!state.restoring) throw error;
1907
+ return { branchExists: null };
1908
+ }
1909
+ }
1910
+ function hostOf(repository) {
1911
+ return `${repository.forge}:${repository.host}`;
1912
+ }
1913
+ //#endregion
1914
+ //#region lib/types/patch-cache.js
1915
+ /**
1916
+ * Change request patches kept in memory, so the changes view reads no diff
1917
+ * from the code host again while a change request keeps its head commit and base branch.
1918
+ */
1919
+ /**
1920
+ * One patch per change request, bounded by the total UTF-8 size of the kept
1921
+ * patches; the least recently read patch leaves first.
1922
+ */
1923
+ var PatchCache = class {
1924
+ maxBytes;
1925
+ entries = /* @__PURE__ */ new Map();
1926
+ bytes = 0;
1927
+ /** @param maxBytes - most UTF-8 bytes kept together; a larger patch is not kept. */
1928
+ constructor(maxBytes) {
1929
+ this.maxBytes = maxBytes;
1930
+ }
1931
+ /**
1932
+ * Read the patch kept for a change request at a version, marking it the most recently read.
1933
+ * @param key - the change request.
1934
+ * @param version - head commit and base branch the reader needs.
1935
+ * @returns the patch, or undefined when none is kept at that version.
1936
+ */
1937
+ get(key, version) {
1938
+ const entry = this.entries.get(key);
1939
+ if (entry?.version !== version) return void 0;
1940
+ this.entries.delete(key);
1941
+ this.entries.set(key, entry);
1942
+ return entry.patch;
1943
+ }
1944
+ /**
1945
+ * Keep a patch in place of the change request's earlier one, then drop the
1946
+ * least recently read patches until the rest fit within `maxBytes`.
1947
+ * @param key - the change request.
1948
+ * @param version - head commit and base branch the patch was read at.
1949
+ * @param patch - the patch.
1950
+ */
1951
+ set(key, version, patch) {
1952
+ this.remove(key);
1953
+ const bytes = Buffer.byteLength(patch);
1954
+ if (bytes > this.maxBytes) return;
1955
+ this.entries.set(key, {
1956
+ version,
1957
+ patch,
1958
+ bytes
1959
+ });
1960
+ this.bytes += bytes;
1961
+ for (const oldest of this.entries.keys()) {
1962
+ if (this.bytes <= this.maxBytes) break;
1963
+ this.remove(oldest);
1964
+ }
1965
+ }
1966
+ remove(key) {
1967
+ const entry = this.entries.get(key);
1968
+ if (entry === void 0) return;
1969
+ this.entries.delete(key);
1970
+ this.bytes -= entry.bytes;
1971
+ }
1972
+ };
1973
+ /** Stored states keyed by {@link storedKey}; derived data, so a record that fails its schema is set aside. */
1974
+ const changeRequestStatesDomain = defineDomain({
1975
+ name: "forge_panel_change_requests",
1976
+ version: 1,
1977
+ layout: "per-record",
1978
+ invalidRecords: "backup-and-skip",
1979
+ tables: { states: { valueSchema: z$1.object({
1980
+ url: z$1.string().min(1),
1981
+ number: z$1.number().int().positive(),
1982
+ title: z$1.string(),
1983
+ state: z$1.enum([
1984
+ "open",
1985
+ "merged",
1986
+ "closed"
1987
+ ]),
1988
+ draft: z$1.boolean(),
1989
+ checksRollup: z$1.enum([
1990
+ "none",
1991
+ "pending",
1992
+ "success",
1993
+ "failure"
1994
+ ]),
1995
+ reviewDecision: z$1.enum([
1996
+ "none",
1997
+ "review-required",
1998
+ "approved",
1999
+ "changes-requested"
2000
+ ]),
2001
+ mergeability: z$1.enum([
2002
+ "mergeable",
2003
+ "conflicting",
2004
+ "unknown"
2005
+ ])
2006
+ }).strict() } }
2007
+ });
2008
+ /**
2009
+ * The storage key of a change request's state: a path-safe digest of its URL.
2010
+ * @param url - the change request's web URL.
2011
+ * @returns the key.
2012
+ */
2013
+ function storedKey(url) {
2014
+ return createHash("sha256").update(url).digest("hex");
2015
+ }
2016
+ function listStateOf(snapshot, url) {
2017
+ return {
2018
+ url,
2019
+ number: snapshot.ref.number,
2020
+ title: snapshot.title,
2021
+ state: snapshot.state,
2022
+ draft: snapshot.draft,
2023
+ checksRollup: snapshot.checksRollup,
2024
+ reviewDecision: snapshot.reviewDecision,
2025
+ mergeability: snapshot.mergeability
2026
+ };
2027
+ }
2028
+ function sameState(left, right) {
2029
+ return left !== void 0 && left.title === right.title && left.state === right.state && left.draft === right.draft && left.checksRollup === right.checksRollup && left.reviewDecision === right.reviewDecision && left.mergeability === right.mergeability && left.number === right.number;
2030
+ }
2031
+ /** One stream's pending changes, coalesced by URL until the reader takes them. */
2032
+ var Reader = class {
2033
+ allowed;
2034
+ signal;
2035
+ pending = /* @__PURE__ */ new Map();
2036
+ wake;
2037
+ ended = false;
2038
+ completion = Promise.withResolvers();
2039
+ closed = this.completion.promise;
2040
+ abort = () => {
2041
+ this.end();
2042
+ };
2043
+ constructor(allowed, signal) {
2044
+ this.allowed = allowed;
2045
+ this.signal = signal;
2046
+ signal.addEventListener("abort", this.abort, { once: true });
2047
+ }
2048
+ push(state) {
2049
+ if (this.ended) return;
2050
+ this.pending.set(state.url, state);
2051
+ this.wake?.();
2052
+ }
2053
+ end() {
2054
+ this.ended = true;
2055
+ this.pending.clear();
2056
+ this.completion.resolve(void 0);
2057
+ this.wake?.();
2058
+ }
2059
+ dispose() {
2060
+ this.signal.removeEventListener("abort", this.abort);
2061
+ this.end();
2062
+ }
2063
+ async *frames() {
2064
+ while (!this.ended) {
2065
+ if (this.pending.size === 0) {
2066
+ await new Promise((resolve) => {
2067
+ this.wake = resolve;
2068
+ });
2069
+ this.wake = void 0;
2070
+ continue;
2071
+ }
2072
+ const states = [...this.pending.values()];
2073
+ this.pending = /* @__PURE__ */ new Map();
2074
+ yield {
2075
+ type: "changed",
2076
+ states
2077
+ };
2078
+ }
2079
+ }
2080
+ };
2081
+ /** The state of one run: stored and polled states, who binds what, and the polls. */
2082
+ var Run = class {
2083
+ sources;
2084
+ readers;
2085
+ storage;
2086
+ states = /* @__PURE__ */ new Map();
2087
+ sessionsOf = /* @__PURE__ */ new Map();
2088
+ urlsOf = /* @__PURE__ */ new Map();
2089
+ polls = /* @__PURE__ */ new Map();
2090
+ stopBindings;
2091
+ startupBindings = /* @__PURE__ */ new Map();
2092
+ starting = true;
2093
+ writes = /* @__PURE__ */ new Set();
2094
+ stopped = false;
2095
+ constructor(sources, readers, storage) {
2096
+ this.sources = sources;
2097
+ this.readers = readers;
2098
+ this.storage = storage;
2099
+ for (const [, state] of storage.table.entries()) this.states.set(state.url, state);
2100
+ this.stopBindings = sources.onBindings((id, urls) => {
2101
+ if (this.starting) this.startupBindings.set(id, urls);
2102
+ else this.bind(id, urls);
2103
+ });
2104
+ }
2105
+ async start(signal) {
2106
+ const listed = await this.sources.sessions(signal);
2107
+ signal.throwIfAborted();
2108
+ const updates = this.startupBindings;
2109
+ for (const session of listed) {
2110
+ const urls = updates.get(session.id) ?? session.urls;
2111
+ if (urls !== void 0) this.bind(session.id, urls);
2112
+ }
2113
+ this.starting = false;
2114
+ for (const [id, urls] of updates) this.bind(id, urls);
2115
+ updates.clear();
2116
+ if (listed.some((session) => session.urls === void 0)) return;
2117
+ for (const [url] of this.states) {
2118
+ if (this.sessionsOf.has(url)) continue;
2119
+ this.states.delete(url);
2120
+ this.write(() => this.storage.table.delete(storedKey(url)));
2121
+ }
2122
+ }
2123
+ /** The known states of every change request that some Session the reader may read binds. */
2124
+ visible(allowed) {
2125
+ const states = [];
2126
+ for (const [url, ids] of this.sessionsOf) {
2127
+ const state = this.states.get(url);
2128
+ if (state !== void 0 && [...ids].some(allowed)) states.push(state);
2129
+ }
2130
+ return states;
2131
+ }
2132
+ async stop() {
2133
+ this.stopped = true;
2134
+ this.stopBindings();
2135
+ for (const stop of this.polls.values()) stop();
2136
+ this.polls.clear();
2137
+ try {
2138
+ await Promise.all(this.writes);
2139
+ } finally {
2140
+ await this.storage.close();
2141
+ }
2142
+ }
2143
+ bind(id, urls) {
2144
+ const next = new Set(urls);
2145
+ const previous = this.urlsOf.get(id) ?? /* @__PURE__ */ new Set();
2146
+ this.urlsOf.set(id, next);
2147
+ for (const url of previous) {
2148
+ if (next.has(url)) continue;
2149
+ const ids = this.sessionsOf.get(url);
2150
+ ids?.delete(id);
2151
+ if (ids !== void 0 && ids.size === 0) {
2152
+ this.sessionsOf.delete(url);
2153
+ this.polls.get(url)?.();
2154
+ this.polls.delete(url);
2155
+ }
2156
+ }
2157
+ for (const url of next) {
2158
+ if (previous.has(url)) continue;
2159
+ const ids = this.sessionsOf.get(url) ?? /* @__PURE__ */ new Set();
2160
+ ids.add(id);
2161
+ this.sessionsOf.set(url, ids);
2162
+ const state = this.states.get(url);
2163
+ if (state !== void 0) {
2164
+ for (const reader of this.readers) if (reader.allowed(id)) reader.push(state);
2165
+ }
2166
+ this.poll(url, ids);
2167
+ }
2168
+ }
2169
+ /** Poll a bound change request; `ids` is the live set of Sessions binding it, which outlives the poll. */
2170
+ poll(url, ids) {
2171
+ if (this.polls.has(url)) return;
2172
+ const ref = this.sources.refOf(url);
2173
+ if (ref === void 0) return;
2174
+ this.polls.set(url, this.sources.watch(ref, (update) => {
2175
+ if ("failure" in update) return;
2176
+ this.settle(url, ids, listStateOf(update.snapshot, url));
2177
+ }));
2178
+ }
2179
+ settle(url, ids, state) {
2180
+ if (this.stopped || sameState(this.states.get(url), state)) return;
2181
+ this.states.set(url, state);
2182
+ this.write(() => this.storage.table.put(storedKey(url), state));
2183
+ for (const reader of this.readers) if ([...ids].some(reader.allowed)) reader.push(state);
2184
+ }
2185
+ write(operation) {
2186
+ const task = operation().then(() => void 0, (error) => {
2187
+ this.sources.report(error);
2188
+ });
2189
+ this.writes.add(task);
2190
+ task.then(() => {
2191
+ this.writes.delete(task);
2192
+ });
2193
+ }
2194
+ };
2195
+ /** Streams the states of the change requests that listed Sessions bind. */
2196
+ var ChangeRequestStates = class {
2197
+ sources;
2198
+ readers = /* @__PURE__ */ new Set();
2199
+ stopping = Promise.resolve();
2200
+ disposed = false;
2201
+ run;
2202
+ /** @param sources - what runs read, follow, and poll. */
2203
+ constructor(sources) {
2204
+ this.sources = sources;
2205
+ }
2206
+ /**
2207
+ * Stream the known states the reader may see first, then each state that
2208
+ * changes or becomes visible to it. The stream ends when `signal` aborts or
2209
+ * the hub is disposed.
2210
+ * @param allowed - whether the reader may read a Session.
2211
+ * @param signal - ends the stream.
2212
+ * @returns frames in publication order.
2213
+ */
2214
+ async *stream(allowed, signal) {
2215
+ signal.throwIfAborted();
2216
+ if (this.disposed) return;
2217
+ const reader = new Reader(allowed, signal);
2218
+ this.readers.add(reader);
2219
+ try {
2220
+ const run = await Promise.race([this.ensureRun(), reader.closed.then(() => void 0)]);
2221
+ if (run === void 0) return;
2222
+ yield {
2223
+ type: "baseline",
2224
+ states: run.visible(allowed)
2225
+ };
2226
+ yield* reader.frames();
2227
+ } finally {
2228
+ reader.dispose();
2229
+ this.readers.delete(reader);
2230
+ if (this.readers.size === 0) await this.stopRun();
2231
+ }
2232
+ }
2233
+ /**
2234
+ * End every stream, stop polling, and wait for initialization, accepted writes, and storage closure.
2235
+ * @returns settles after all owned work stops; subsequent streams end without starting work.
2236
+ */
2237
+ async dispose() {
2238
+ this.disposed = true;
2239
+ for (const reader of this.readers) reader.end();
2240
+ await this.stopRun();
2241
+ }
2242
+ ensureRun() {
2243
+ if (this.run !== void 0) return this.run.started;
2244
+ const aborter = new AbortController();
2245
+ const preceding = this.stopping;
2246
+ const started = (async () => {
2247
+ await preceding;
2248
+ aborter.signal.throwIfAborted();
2249
+ const storage = await this.sources.open();
2250
+ let run;
2251
+ try {
2252
+ aborter.signal.throwIfAborted();
2253
+ run = new Run(this.sources, this.readers, storage);
2254
+ await run.start(aborter.signal);
2255
+ return run;
2256
+ } catch (error) {
2257
+ if (run === void 0) await storage.close();
2258
+ else await run.stop();
2259
+ throw error;
2260
+ }
2261
+ })();
2262
+ this.run = {
2263
+ started,
2264
+ aborter
2265
+ };
2266
+ return started;
2267
+ }
2268
+ stopRun() {
2269
+ const current = this.run;
2270
+ if (current === void 0) return this.stopping;
2271
+ this.run = void 0;
2272
+ current.aborter.abort();
2273
+ this.stopping = current.started.then((run) => run?.stop(), (error) => {});
2274
+ return this.stopping;
2275
+ }
2276
+ };
2277
+ //#endregion
2278
+ //#region lib/types/index.js
2279
+ /** Host Remote of the forge panel: checkout, code host, sign-in, change request, and worktree state per Session. */
2280
+ var __runInitializers = function(thisArg, initializers, value) {
2281
+ var useValue = arguments.length > 2;
2282
+ for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
2283
+ return useValue ? value : void 0;
2284
+ };
2285
+ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
2286
+ function accept(f) {
2287
+ if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
2288
+ return f;
2289
+ }
2290
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
2291
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
2292
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
2293
+ var _, done = false;
2294
+ for (var i = decorators.length - 1; i >= 0; i--) {
2295
+ var context = {};
2296
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
2297
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
2298
+ context.addInitializer = function(f) {
2299
+ if (done) throw new TypeError("Cannot add initializers after decoration has completed");
2300
+ extraInitializers.push(accept(f || null));
2301
+ };
2302
+ var result = (0, decorators[i])(kind === "accessor" ? {
2303
+ get: descriptor.get,
2304
+ set: descriptor.set
2305
+ } : descriptor[key], context);
2306
+ if (kind === "accessor") {
2307
+ if (result === void 0) continue;
2308
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
2309
+ if (_ = accept(result.get)) descriptor.get = _;
2310
+ if (_ = accept(result.set)) descriptor.set = _;
2311
+ if (_ = accept(result.init)) initializers.unshift(_);
2312
+ } else if (_ = accept(result)) if (kind === "field") initializers.unshift(_);
2313
+ else descriptor[key] = _;
2314
+ }
2315
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
2316
+ done = true;
2317
+ };
2318
+ /**
2319
+ * Serves the forge panel. Following a Session inspects the checkout of its
2320
+ * current directory using the Session's permissions and execution world, recognizes the
2321
+ * code host of the upstream remote or `origin`, probes sign-in for that host,
2322
+ * and polls the Session's bound change requests through `ctx.forge` until the
2323
+ * last follower of that Session leaves. A worktree request makes the panel
2324
+ * create a branch and checkout through an optional `ctx.worktrees` and enter
2325
+ * it while the Session's first message waits in the inbox, or at the first
2326
+ * step proposal of a message no wait took. After a failed or stopped attempt
2327
+ * the message keeps waiting for another attempt or for the request to be
2328
+ * withdrawn. A message also waits while the worktree the Session works in is
2329
+ * gone, until that worktree is checked out again or left for the source
2330
+ * checkout. Changes to a Session's checkout run as Agent maintenance, so a
2331
+ * message that arrives meanwhile waits for them.
2332
+ */
2333
+ let ForgePanelController = (() => {
2334
+ let _classSuper = TypertRemoteService;
2335
+ let _instanceExtraInitializers = [];
2336
+ let _follow_decorators;
2337
+ let _changeRequestStates_decorators;
2338
+ let _branches_decorators;
2339
+ let _track_decorators;
2340
+ let _dismiss_decorators;
2341
+ let _refresh_decorators;
2342
+ let _localDiff_decorators;
2343
+ let _localText_decorators;
2344
+ let _changeRequestChecks_decorators;
2345
+ let _changeRequestDiff_decorators;
2346
+ let _changeRequestText_decorators;
2347
+ let _submitReview_decorators;
2348
+ let _markReady_decorators;
2349
+ let _merge_decorators;
2350
+ let _requestWorktree_decorators;
2351
+ let _cancelWorktree_decorators;
2352
+ let _stopCreation_decorators;
2353
+ let _retryWorktree_decorators;
2354
+ let _restoreWorktree_decorators;
2355
+ let _continueInProject_decorators;
2356
+ return class ForgePanelController extends _classSuper {
2357
+ static {
2358
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
2359
+ _follow_decorators = [Remote({ mode: "stream" })];
2360
+ _changeRequestStates_decorators = [Remote({ mode: "stream" })];
2361
+ _branches_decorators = [Remote];
2362
+ _track_decorators = [Remote];
2363
+ _dismiss_decorators = [Remote];
2364
+ _refresh_decorators = [Remote];
2365
+ _localDiff_decorators = [Remote];
2366
+ _localText_decorators = [Remote];
2367
+ _changeRequestChecks_decorators = [Remote];
2368
+ _changeRequestDiff_decorators = [Remote];
2369
+ _changeRequestText_decorators = [Remote];
2370
+ _submitReview_decorators = [Remote];
2371
+ _markReady_decorators = [Remote];
2372
+ _merge_decorators = [Remote];
2373
+ _requestWorktree_decorators = [Remote];
2374
+ _cancelWorktree_decorators = [Remote];
2375
+ _stopCreation_decorators = [Remote];
2376
+ _retryWorktree_decorators = [Remote];
2377
+ _restoreWorktree_decorators = [Remote];
2378
+ _continueInProject_decorators = [Remote];
2379
+ __esDecorate(this, null, _follow_decorators, {
2380
+ kind: "method",
2381
+ name: "follow",
2382
+ static: false,
2383
+ private: false,
2384
+ access: {
2385
+ has: (obj) => "follow" in obj,
2386
+ get: (obj) => obj.follow
2387
+ },
2388
+ metadata: _metadata
2389
+ }, null, _instanceExtraInitializers);
2390
+ __esDecorate(this, null, _changeRequestStates_decorators, {
2391
+ kind: "method",
2392
+ name: "changeRequestStates",
2393
+ static: false,
2394
+ private: false,
2395
+ access: {
2396
+ has: (obj) => "changeRequestStates" in obj,
2397
+ get: (obj) => obj.changeRequestStates
2398
+ },
2399
+ metadata: _metadata
2400
+ }, null, _instanceExtraInitializers);
2401
+ __esDecorate(this, null, _branches_decorators, {
2402
+ kind: "method",
2403
+ name: "branches",
2404
+ static: false,
2405
+ private: false,
2406
+ access: {
2407
+ has: (obj) => "branches" in obj,
2408
+ get: (obj) => obj.branches
2409
+ },
2410
+ metadata: _metadata
2411
+ }, null, _instanceExtraInitializers);
2412
+ __esDecorate(this, null, _track_decorators, {
2413
+ kind: "method",
2414
+ name: "track",
2415
+ static: false,
2416
+ private: false,
2417
+ access: {
2418
+ has: (obj) => "track" in obj,
2419
+ get: (obj) => obj.track
2420
+ },
2421
+ metadata: _metadata
2422
+ }, null, _instanceExtraInitializers);
2423
+ __esDecorate(this, null, _dismiss_decorators, {
2424
+ kind: "method",
2425
+ name: "dismiss",
2426
+ static: false,
2427
+ private: false,
2428
+ access: {
2429
+ has: (obj) => "dismiss" in obj,
2430
+ get: (obj) => obj.dismiss
2431
+ },
2432
+ metadata: _metadata
2433
+ }, null, _instanceExtraInitializers);
2434
+ __esDecorate(this, null, _refresh_decorators, {
2435
+ kind: "method",
2436
+ name: "refresh",
2437
+ static: false,
2438
+ private: false,
2439
+ access: {
2440
+ has: (obj) => "refresh" in obj,
2441
+ get: (obj) => obj.refresh
2442
+ },
2443
+ metadata: _metadata
2444
+ }, null, _instanceExtraInitializers);
2445
+ __esDecorate(this, null, _localDiff_decorators, {
2446
+ kind: "method",
2447
+ name: "localDiff",
2448
+ static: false,
2449
+ private: false,
2450
+ access: {
2451
+ has: (obj) => "localDiff" in obj,
2452
+ get: (obj) => obj.localDiff
2453
+ },
2454
+ metadata: _metadata
2455
+ }, null, _instanceExtraInitializers);
2456
+ __esDecorate(this, null, _localText_decorators, {
2457
+ kind: "method",
2458
+ name: "localText",
2459
+ static: false,
2460
+ private: false,
2461
+ access: {
2462
+ has: (obj) => "localText" in obj,
2463
+ get: (obj) => obj.localText
2464
+ },
2465
+ metadata: _metadata
2466
+ }, null, _instanceExtraInitializers);
2467
+ __esDecorate(this, null, _changeRequestChecks_decorators, {
2468
+ kind: "method",
2469
+ name: "changeRequestChecks",
2470
+ static: false,
2471
+ private: false,
2472
+ access: {
2473
+ has: (obj) => "changeRequestChecks" in obj,
2474
+ get: (obj) => obj.changeRequestChecks
2475
+ },
2476
+ metadata: _metadata
2477
+ }, null, _instanceExtraInitializers);
2478
+ __esDecorate(this, null, _changeRequestDiff_decorators, {
2479
+ kind: "method",
2480
+ name: "changeRequestDiff",
2481
+ static: false,
2482
+ private: false,
2483
+ access: {
2484
+ has: (obj) => "changeRequestDiff" in obj,
2485
+ get: (obj) => obj.changeRequestDiff
2486
+ },
2487
+ metadata: _metadata
2488
+ }, null, _instanceExtraInitializers);
2489
+ __esDecorate(this, null, _changeRequestText_decorators, {
2490
+ kind: "method",
2491
+ name: "changeRequestText",
2492
+ static: false,
2493
+ private: false,
2494
+ access: {
2495
+ has: (obj) => "changeRequestText" in obj,
2496
+ get: (obj) => obj.changeRequestText
2497
+ },
2498
+ metadata: _metadata
2499
+ }, null, _instanceExtraInitializers);
2500
+ __esDecorate(this, null, _submitReview_decorators, {
2501
+ kind: "method",
2502
+ name: "submitReview",
2503
+ static: false,
2504
+ private: false,
2505
+ access: {
2506
+ has: (obj) => "submitReview" in obj,
2507
+ get: (obj) => obj.submitReview
2508
+ },
2509
+ metadata: _metadata
2510
+ }, null, _instanceExtraInitializers);
2511
+ __esDecorate(this, null, _markReady_decorators, {
2512
+ kind: "method",
2513
+ name: "markReady",
2514
+ static: false,
2515
+ private: false,
2516
+ access: {
2517
+ has: (obj) => "markReady" in obj,
2518
+ get: (obj) => obj.markReady
2519
+ },
2520
+ metadata: _metadata
2521
+ }, null, _instanceExtraInitializers);
2522
+ __esDecorate(this, null, _merge_decorators, {
2523
+ kind: "method",
2524
+ name: "merge",
2525
+ static: false,
2526
+ private: false,
2527
+ access: {
2528
+ has: (obj) => "merge" in obj,
2529
+ get: (obj) => obj.merge
2530
+ },
2531
+ metadata: _metadata
2532
+ }, null, _instanceExtraInitializers);
2533
+ __esDecorate(this, null, _requestWorktree_decorators, {
2534
+ kind: "method",
2535
+ name: "requestWorktree",
2536
+ static: false,
2537
+ private: false,
2538
+ access: {
2539
+ has: (obj) => "requestWorktree" in obj,
2540
+ get: (obj) => obj.requestWorktree
2541
+ },
2542
+ metadata: _metadata
2543
+ }, null, _instanceExtraInitializers);
2544
+ __esDecorate(this, null, _cancelWorktree_decorators, {
2545
+ kind: "method",
2546
+ name: "cancelWorktree",
2547
+ static: false,
2548
+ private: false,
2549
+ access: {
2550
+ has: (obj) => "cancelWorktree" in obj,
2551
+ get: (obj) => obj.cancelWorktree
2552
+ },
2553
+ metadata: _metadata
2554
+ }, null, _instanceExtraInitializers);
2555
+ __esDecorate(this, null, _stopCreation_decorators, {
2556
+ kind: "method",
2557
+ name: "stopCreation",
2558
+ static: false,
2559
+ private: false,
2560
+ access: {
2561
+ has: (obj) => "stopCreation" in obj,
2562
+ get: (obj) => obj.stopCreation
2563
+ },
2564
+ metadata: _metadata
2565
+ }, null, _instanceExtraInitializers);
2566
+ __esDecorate(this, null, _retryWorktree_decorators, {
2567
+ kind: "method",
2568
+ name: "retryWorktree",
2569
+ static: false,
2570
+ private: false,
2571
+ access: {
2572
+ has: (obj) => "retryWorktree" in obj,
2573
+ get: (obj) => obj.retryWorktree
2574
+ },
2575
+ metadata: _metadata
2576
+ }, null, _instanceExtraInitializers);
2577
+ __esDecorate(this, null, _restoreWorktree_decorators, {
2578
+ kind: "method",
2579
+ name: "restoreWorktree",
2580
+ static: false,
2581
+ private: false,
2582
+ access: {
2583
+ has: (obj) => "restoreWorktree" in obj,
2584
+ get: (obj) => obj.restoreWorktree
2585
+ },
2586
+ metadata: _metadata
2587
+ }, null, _instanceExtraInitializers);
2588
+ __esDecorate(this, null, _continueInProject_decorators, {
2589
+ kind: "method",
2590
+ name: "continueInProject",
2591
+ static: false,
2592
+ private: false,
2593
+ access: {
2594
+ has: (obj) => "continueInProject" in obj,
2595
+ get: (obj) => obj.continueInProject
2596
+ },
2597
+ metadata: _metadata
2598
+ }, null, _instanceExtraInitializers);
2599
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, {
2600
+ enumerable: true,
2601
+ configurable: true,
2602
+ writable: true,
2603
+ value: _metadata
2604
+ });
2605
+ }
2606
+ static inject = [
2607
+ "forge",
2608
+ "forgeSession",
2609
+ "sessionProjections",
2610
+ "workingDirectory",
2611
+ "fs",
2612
+ "subprocess",
2613
+ "sandbox",
2614
+ "sandboxPolicy",
2615
+ "agents"
2616
+ ];
2617
+ static Config = z.object({
2618
+ gitCommand: z.string().default("git"),
2619
+ gitTimeoutMs: z.number().default(1e4),
2620
+ graceMs: z.number().default(1e3),
2621
+ maxOutputBytes: z.number().default(1048576),
2622
+ readinessTimeoutMs: z.number().default(15e3),
2623
+ maxBranches: z.number().default(50),
2624
+ worktreePrefix: z.string().default("dsh-"),
2625
+ maxDiffBytes: z.number().default(8388608),
2626
+ maxUntrackedFiles: z.number().default(100),
2627
+ maxCachedDiffBytes: z.number().default(16777216)
2628
+ });
2629
+ home = __runInitializers(this, _instanceExtraInitializers);
2630
+ config;
2631
+ panels = /* @__PURE__ */ new Map();
2632
+ worktreeStates;
2633
+ creations;
2634
+ patches;
2635
+ states;
2636
+ stopPanels;
2637
+ stopStates;
2638
+ constructor(ctx, config) {
2639
+ super(ctx, "forgePanel");
2640
+ const resolved = config;
2641
+ if (resolved.gitCommand.trim().length === 0) throw new Error("forge-panel: gitCommand must not be empty");
2642
+ for (const key of [
2643
+ "gitTimeoutMs",
2644
+ "graceMs",
2645
+ "maxOutputBytes",
2646
+ "readinessTimeoutMs"
2647
+ ]) {
2648
+ const value = resolved[key];
2649
+ if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) throw new Error(`forge-panel: ${key} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`);
2650
+ }
2651
+ for (const key of [
2652
+ "maxBranches",
2653
+ "maxDiffBytes",
2654
+ "maxUntrackedFiles",
2655
+ "maxCachedDiffBytes"
2656
+ ]) if (!Number.isSafeInteger(resolved[key]) || resolved[key] <= 0) throw new Error(`forge-panel: ${key} must be a positive integer`);
2657
+ if (!branchPrefix(resolved.worktreePrefix)) throw new Error("forge-panel: worktreePrefix must start a valid Git branch name");
2658
+ this.home = ctx;
2659
+ this.config = resolved;
2660
+ this.patches = new PatchCache(resolved.maxCachedDiffBytes);
2661
+ this.stopPanels = ctx.effect(() => async () => {
2662
+ const panels = [...this.panels.values()];
2663
+ this.panels.clear();
2664
+ await Promise.allSettled(panels.map((panel) => panel.dispose()));
2665
+ }, "forge-panel: stop panels");
2666
+ this.stopStates = ctx.effect(() => async () => {
2667
+ await this.states?.dispose();
2668
+ }, "forge-panel: stop change request states");
2669
+ }
2670
+ async [Service.init]() {
2671
+ const ctx = this.home;
2672
+ const stateOwner = ctx.plugin({
2673
+ name: "forge-panel-state",
2674
+ inject: ["sessionProjections"],
2675
+ apply: async (scope) => {
2676
+ this.worktreeStates = await scope.sessionProjections.register(forgeWorktreeProjection);
2677
+ }
2678
+ });
2679
+ await stateOwner;
2680
+ this.creations = new WorktreeCreations(ctx, this.worktreeStates, this.config.worktreePrefix, (session) => {
2681
+ this.panels.get(session)?.updateWorktree();
2682
+ });
2683
+ const inbox = ctx.on("agent/inbox/inserted", ({ agent }) => {
2684
+ this.holdTurn(agent);
2685
+ });
2686
+ const events = ctx.on("session/event", (session, event) => {
2687
+ this.panels.get(session)?.observe(event);
2688
+ });
2689
+ const services = ctx.on("internal/service", (name) => {
2690
+ if (name !== "worktrees") return;
2691
+ for (const panel of this.panels.values()) panel.updateWorktree();
2692
+ });
2693
+ const { creations, stopPanels, stopStates } = this;
2694
+ ctx.effect(function* () {
2695
+ yield stateOwner.dispose;
2696
+ yield inbox;
2697
+ yield events;
2698
+ yield services;
2699
+ yield stopPanels;
2700
+ yield stopStates;
2701
+ yield creations.dispose;
2702
+ }, "forge-panel: drain before releasing state");
2703
+ }
2704
+ /**
2705
+ * Stream the panel state of a Session: the current state first, then each
2706
+ * later state. Intermediate states may be skipped when the reader is slower
2707
+ * than the updates. The stream ends when `signal` aborts or the Remote stops.
2708
+ * @param session - Session to follow.
2709
+ * @param signal - ends the stream.
2710
+ * @returns panel states in publication order.
2711
+ */
2712
+ async *follow(session, signal) {
2713
+ yield* this.panelOf(session).follow(signal);
2714
+ }
2715
+ /**
2716
+ * Stream the last known state of every change request that a listed
2717
+ * Session the caller may read binds: the stored states first, then each
2718
+ * state a poll changes or a new binding reveals. Listing reads no Session
2719
+ * log: loaded Sessions report their bindings, others the cached Session
2720
+ * summary, and a Session without one shows nothing until it is opened.
2721
+ * Closed requests are polled at the idle cadence so reopening is observed. The
2722
+ * stream ends when `signal` aborts or the Remote stops.
2723
+ * @param signal - ends the stream.
2724
+ * @returns frames in publication order.
2725
+ * @throws when the Host lacks the Session query, projection cache, storage, or Session store service.
2726
+ */
2727
+ async *changeRequestStates(signal) {
2728
+ const access = this.ctx.get("remoteAccess");
2729
+ yield* this.changeRequestStatesHub().stream((id) => access === void 0 || access.allows({
2730
+ kind: "session",
2731
+ id
2732
+ }), signal);
2733
+ }
2734
+ /**
2735
+ * List the local branches that can start a worktree of the Session's current checkout.
2736
+ * @param session - Session whose current directory is listed.
2737
+ * @param signal - request cancellation.
2738
+ * @returns the checked-out branch and at most `maxBranches` local branches, the checked-out one first.
2739
+ * @throws when the current directory is not inside a checkout or Git fails.
2740
+ */
2741
+ async branches(session, signal) {
2742
+ return await listBranches(this.home, this.config, session, this.home.workingDirectory.get(session), this.config.maxBranches, signal);
2743
+ }
2744
+ /**
2745
+ * Bind a change request to a Session by URL, or restore a dismissed binding.
2746
+ * @param session - Session that receives the binding.
2747
+ * @param url - web URL of the change request.
2748
+ * @param signal - request cancellation.
2749
+ * @throws `forgePanel/unrecognized-url` when no registered provider serves the URL.
2750
+ */
2751
+ async track(session, url, signal) {
2752
+ signal.throwIfAborted();
2753
+ if (this.home.forge.changeRequestFromUrl(url) === void 0) throw new RemoteError("forgePanel/unrecognized-url", "No code host provider recognizes the change request URL", {});
2754
+ this.home.forgeSession.track(session, url);
2755
+ }
2756
+ /**
2757
+ * Hide a visible binding of a Session; other URLs leave the Session unchanged.
2758
+ * @param session - Session that owns the binding.
2759
+ * @param url - web URL of the change request.
2760
+ * @param signal - request cancellation.
2761
+ */
2762
+ async dismiss(session, url, signal) {
2763
+ signal.throwIfAborted();
2764
+ this.home.forgeSession.dismiss(session, url);
2765
+ }
2766
+ /**
2767
+ * Reinspect a followed Session's checkout, probe sign-in again, and poll its
2768
+ * change requests at once; an unfollowed Session is unchanged.
2769
+ * @param session - Session whose panel refreshes.
2770
+ * @param signal - request cancellation.
2771
+ * @returns settles once the inspection and the sign-in probe have ended; poll results reach the panel stream later.
2772
+ */
2773
+ async refresh(session, signal) {
2774
+ signal.throwIfAborted();
2775
+ await this.panels.get(session)?.refresh();
2776
+ }
2777
+ /**
2778
+ * Read the changes one scope covers in the checkout of the Session's
2779
+ * current directory, with Git using the Session's sandbox policy.
2780
+ * @param session - Session whose checkout is read.
2781
+ * @param scope - which changes to read.
2782
+ * @param signal - request cancellation.
2783
+ * @returns the files, the base of the `branch` scope, and the untracked files left out.
2784
+ * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/no-fork-point` for the `branch`
2785
+ * scope of a checkout without one; `forgePanel/filter-config-failed` when filter configuration cannot be read
2786
+ * completely, before any comparison; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails.
2787
+ */
2788
+ async localDiff(session, scope, signal) {
2789
+ signal.throwIfAborted();
2790
+ const inspection = await this.checkoutOf(session, signal);
2791
+ const root = inspection.checkout.root;
2792
+ const fork = scope.kind === "branch" ? await forkPoint(gitIn(this.home, this.config, session, root, signal), inspection, this.worktreeState(session).created) : null;
2793
+ return await diffing(localDiff(this.home, this.config, session, root, fork, scope, signal));
2794
+ }
2795
+ /**
2796
+ * Read the new side of one changed file in a scope, which supplies the
2797
+ * unchanged lines between its hunks: the index for `staged`, else the working tree.
2798
+ * @param session - Session whose checkout is read.
2799
+ * @param scope - the scope the diff was read in.
2800
+ * @param path - path relative to the checkout's top-level directory, as the diff names it.
2801
+ * @param signal - request cancellation.
2802
+ * @returns the text.
2803
+ * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/diff-too-large` over `maxDiffBytes`;
2804
+ * an error for a path outside the checkout, binary content, or a failed read.
2805
+ */
2806
+ async localText(session, scope, path, signal) {
2807
+ signal.throwIfAborted();
2808
+ const inspection = await this.checkoutOf(session, signal);
2809
+ return await diffing(localText(this.home, this.config, session, inspection.checkout.root, scope, path, signal));
2810
+ }
2811
+ /**
2812
+ * Load complete checks for the change request whose list the user opened.
2813
+ * @param session - Session retaining the visible binding.
2814
+ * @param url - binding URL.
2815
+ * @param headSha - commit displayed by the card; a different head rejects the result.
2816
+ * @param signal - cancellation when the list closes.
2817
+ * @returns the complete snapshot at the displayed head.
2818
+ * @throws `forgePanel/not-bound` for an invisible binding, `forgePanel/stale-head` for a moved head,
2819
+ * or `forgePanel/checks-failed` when the provider cannot complete the read.
2820
+ */
2821
+ async changeRequestChecks(session, url, headSha, signal) {
2822
+ signal.throwIfAborted();
2823
+ const result = await this.home.forge.fetchChecks(this.bound(session, url).ref, signal);
2824
+ this.bound(session, url);
2825
+ if ("code" in result) throw new RemoteError("forgePanel/checks-failed", result.message, {});
2826
+ if (result.headSha !== headSha) throw new RemoteError("forgePanel/stale-head", "The change request head changed; refresh its status", {});
2827
+ return result;
2828
+ }
2829
+ /**
2830
+ * Read the changes of a change request of a Session as its code host
2831
+ * computes them, at the head commit of its latest status. A patch already
2832
+ * read at the same head commit and base branch is reused from memory, for
2833
+ * any Session, until `maxCachedDiffBytes` evicts it.
2834
+ * @param session - Session the change request is bound to.
2835
+ * @param url - binding URL of the change request.
2836
+ * @param signal - request cancellation.
2837
+ * @returns the files, the head commit, each file's web page, and the commits the checkout has beyond the head.
2838
+ * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
2839
+ * `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit;
2840
+ * `forgePanel/diff-failed` before its status was read and when the host does not provide the diff otherwise.
2841
+ */
2842
+ async changeRequestDiff(session, url, signal) {
2843
+ signal.throwIfAborted();
2844
+ const { ref, update } = this.bound(session, url);
2845
+ if (update === null || !("snapshot" in update)) throw new RemoteError("forgePanel/diff-failed", "The change request status has not been read yet", {});
2846
+ const { headSha, headBranch, baseBranch } = update.snapshot;
2847
+ const key = changeRequestKey(ref);
2848
+ const version = JSON.stringify([headSha, baseBranch]);
2849
+ let patch = this.patches.get(key, version);
2850
+ if (patch === void 0) {
2851
+ const read = await this.home.forge.diff(ref, signal);
2852
+ if (typeof read !== "string") throw readError(read);
2853
+ this.patches.set(key, version, read);
2854
+ patch = read;
2855
+ }
2856
+ const files = parseDiff(patch);
2857
+ let localAhead = null;
2858
+ try {
2859
+ localAhead = await aheadOf(gitIn(this.home, this.config, session, this.home.workingDirectory.get(session), signal), headBranch, headSha);
2860
+ } catch (_error) {}
2861
+ signal.throwIfAborted();
2862
+ const fileUrls = Object.fromEntries(files.map((file) => [file.path, this.home.forge.fileUrl(ref, headSha, file.path)]));
2863
+ return {
2864
+ headSha,
2865
+ files,
2866
+ localAhead,
2867
+ fileUrls
2868
+ };
2869
+ }
2870
+ /**
2871
+ * Read one file of a change request's repository at a commit, which
2872
+ * supplies the unchanged lines between the hunks of its diff.
2873
+ * @param session - Session the change request is bound to.
2874
+ * @param url - binding URL of the change request.
2875
+ * @param commit - commit object name, the head commit of the diff.
2876
+ * @param path - path relative to the repository root.
2877
+ * @param signal - request cancellation.
2878
+ * @returns the text.
2879
+ * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
2880
+ * `forgePanel/diff-too-large` when the host or its provider refuses the file over a size limit;
2881
+ * `forgePanel/diff-failed` when the host does not provide the file otherwise.
2882
+ */
2883
+ async changeRequestText(session, url, commit, path, signal) {
2884
+ signal.throwIfAborted();
2885
+ const text = await this.home.forge.fileText(this.bound(session, url).ref, commit, path, signal);
2886
+ if (typeof text !== "string") throw readError(text);
2887
+ return text;
2888
+ }
2889
+ /**
2890
+ * Publish a line comment or completed review for a visible Session binding.
2891
+ * @param session - Session retaining the change request.
2892
+ * @param url - visible binding URL.
2893
+ * @param request - reviewed head, ranges, plain text, and explicit review decision.
2894
+ * @param signal - request cancellation.
2895
+ * @returns confirmed publication or a typed failure; unknown outcomes require reconciliation on the host.
2896
+ */
2897
+ async submitReview(session, url, request, signal) {
2898
+ signal.throwIfAborted();
2899
+ const { ref } = this.bound(session, url);
2900
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(request.headSha)) throw new Error("Invalid review commit");
2901
+ const comments = request.kind === "comment" ? [request.comment] : request.comments;
2902
+ if (comments.some((comment) => comment.path === "" || comment.body.trim() === "" || !Number.isSafeInteger(comment.startLine) || comment.startLine < 1 || !Number.isSafeInteger(comment.endLine) || comment.endLine < comment.startLine)) throw new Error("Invalid review comment");
2903
+ if (request.kind === "review" && request.event !== "approve" && request.body.trim() === "" && comments.length === 0) throw new Error("Empty review");
2904
+ return await this.home.forge.submitReview(ref, request, signal);
2905
+ }
2906
+ /**
2907
+ * Mark a draft change request of a Session ready for review; the change
2908
+ * request is polled again once the host answers.
2909
+ * @param session - Session the change request is bound to.
2910
+ * @param url - binding URL of the change request.
2911
+ * @param signal - request cancellation.
2912
+ * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
2913
+ * `forgePanel/action-failed` with the host's reason when the host refuses or cannot be reached.
2914
+ */
2915
+ async markReady(session, url, signal) {
2916
+ signal.throwIfAborted();
2917
+ const failure = await this.home.forge.markReady(this.bound(session, url).ref, signal);
2918
+ if (failure !== void 0) throw actionError(failure);
2919
+ }
2920
+ /**
2921
+ * Merge a change request of a Session while its head is still `headSha`;
2922
+ * the change request is polled again once the host answers.
2923
+ * @param session - Session the change request is bound to.
2924
+ * @param url - binding URL of the change request.
2925
+ * @param method - how the commits enter the base branch.
2926
+ * @param headSha - head commit the user confirmed.
2927
+ * @param signal - request cancellation.
2928
+ * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
2929
+ * `forgePanel/stale-head` when the head moved past `headSha`; `forgePanel/action-failed` with the host's
2930
+ * reason when the host refuses otherwise or cannot be reached.
2931
+ */
2932
+ async merge(session, url, method, headSha, signal) {
2933
+ signal.throwIfAborted();
2934
+ const failure = await this.home.forge.merge(this.bound(session, url).ref, {
2935
+ method,
2936
+ headSha
2937
+ }, signal);
2938
+ if (failure !== void 0) throw actionError(failure);
2939
+ }
2940
+ /**
2941
+ * Ask for a new worktree before the Session's first turn, replacing an open
2942
+ * request and forgetting a failed attempt. While the next message waits in
2943
+ * the inbox, or else at the first step proposal, the panel creates a branch
2944
+ * from `from`, or from HEAD of the current checkout when it is null, and
2945
+ * enters its checkout. A message that already waits after a failed or
2946
+ * stopped attempt keeps waiting for {@link ForgePanelController.retryWorktree}.
2947
+ * @param session - Session without a started turn.
2948
+ * @param from - local base branch, or null for HEAD.
2949
+ * @param signal - request cancellation.
2950
+ * @throws `forgePanel/invalid-branch` when `from` is empty; `forgePanel/worktrees-unavailable`
2951
+ * without a worktree service; `forgePanel/session-started` after a turn started or the worktree was
2952
+ * created; `forgePanel/session-busy` while the worktree is being created.
2953
+ */
2954
+ async requestWorktree(session, from, signal) {
2955
+ signal.throwIfAborted();
2956
+ if (from?.length === 0) throw new RemoteError("forgePanel/invalid-branch", "The base branch name is empty", {});
2957
+ this.editableWorktree(session);
2958
+ this.creations.forget(session);
2959
+ appendPluginRecord(session, WORKTREE_RECORD, {
2960
+ action: "request",
2961
+ from
2962
+ });
2963
+ this.creations.recheck(session);
2964
+ }
2965
+ /**
2966
+ * Withdraw the open worktree request of a Session with its failed attempt,
2967
+ * which lets a waiting message go on in the current checkout; without a
2968
+ * request the Session is unchanged. Withdrawing needs no worktree service.
2969
+ * @param session - Session without a started turn.
2970
+ * @param signal - request cancellation.
2971
+ * @throws `forgePanel/session-started` after a turn started or the worktree was created;
2972
+ * `forgePanel/session-busy` while the worktree is being created.
2973
+ */
2974
+ async cancelWorktree(session, signal) {
2975
+ signal.throwIfAborted();
2976
+ if (this.changeableWorktree(session).request === null) return;
2977
+ this.creations.forget(session);
2978
+ appendPluginRecord(session, WORKTREE_RECORD, { action: "cancel" });
2979
+ this.creations.recheck(session);
2980
+ }
2981
+ /**
2982
+ * Stop the creation a message of the Session waits for; the message keeps
2983
+ * waiting with the request still open. Without such a creation the Session
2984
+ * is unchanged.
2985
+ * @param session - Session whose creation stops.
2986
+ * @param signal - request cancellation.
2987
+ */
2988
+ async stopCreation(session, signal) {
2989
+ signal.throwIfAborted();
2990
+ this.creations.stop(session);
2991
+ }
2992
+ /**
2993
+ * Make another attempt, with a new branch name, at the worktree a message
2994
+ * of the Session waits for after a failed or stopped attempt.
2995
+ * @param session - Session whose message waits.
2996
+ * @param signal - request cancellation.
2997
+ * @throws `forgePanel/not-waiting` when no message waits or an attempt is running.
2998
+ */
2999
+ async retryWorktree(session, signal) {
3000
+ signal.throwIfAborted();
3001
+ if (!this.creations.retry(session)) throw new RemoteError("forgePanel/not-waiting", "No message waits for another attempt at the worktree", {});
3002
+ }
3003
+ /**
3004
+ * Check out the created worktree of a Session again at its recorded
3005
+ * directory when that directory is gone: its branch when the branch still
3006
+ * exists, otherwise a new branch of the same name at the base commit. Changes
3007
+ * that were never committed are not recovered. Runs as Agent maintenance,
3008
+ * inside the wait of a message that waits for the restoration, which then
3009
+ * goes on; without a missing directory the Session is unchanged.
3010
+ * @param session - Session whose worktree directory is gone.
3011
+ * @param signal - request cancellation, which aborts the restoration.
3012
+ * @throws `forgePanel/no-worktree` without a created worktree; `forgePanel/worktrees-unavailable`
3013
+ * without a worktree service; `forgePanel/session-busy` while the Session's Agent is not loaded or
3014
+ * has other work; `forgePanel/restore-failed` when Git fails.
3015
+ */
3016
+ async restoreWorktree(session, signal) {
3017
+ signal.throwIfAborted();
3018
+ const created = this.createdWorktree(session);
3019
+ const worktrees = this.home.get("worktrees");
3020
+ if (worktrees === void 0) throw new RemoteError("forgePanel/worktrees-unavailable", "No worktree service is mounted", {});
3021
+ await this.maintain(session, async (agent, work) => {
3022
+ const operation = AbortSignal.any([signal, work]);
3023
+ try {
3024
+ await this.creations.restore(agent, worktrees, created, operation, () => {
3025
+ this.panels.get(session)?.restored();
3026
+ }, async (check) => await missingWorktree(this.home, this.config, session, this.worktreeState(session), created.path, check) === null);
3027
+ } catch (error) {
3028
+ operation.throwIfAborted();
3029
+ throw new RemoteError("forgePanel/restore-failed", messageOf(error), {}, { cause: error });
3030
+ }
3031
+ });
3032
+ this.panels.get(session)?.reinspect();
3033
+ }
3034
+ /**
3035
+ * Move a Session from its created worktree to the source checkout the
3036
+ * worktree came from; the Session no longer works in the worktree. Runs as
3037
+ * Agent maintenance, inside the wait of a message that waits for the removed
3038
+ * worktree, which then goes on.
3039
+ * @param session - Session with a created worktree.
3040
+ * @param signal - request cancellation.
3041
+ * @throws `forgePanel/no-worktree` without a created worktree; `forgePanel/session-busy` while the
3042
+ * Session's Agent is not loaded or has other work; the working-directory service's error when the
3043
+ * source checkout no longer exists.
3044
+ */
3045
+ async continueInProject(session, signal) {
3046
+ signal.throwIfAborted();
3047
+ const created = this.createdWorktree(session);
3048
+ await this.maintain(session, async (agent, work) => {
3049
+ await this.home.workingDirectory.set(agent, created.repositoryRoot, AbortSignal.any([signal, work]));
3050
+ });
3051
+ }
3052
+ /**
3053
+ * Hold the turn of a message entering a Session's inbox while the
3054
+ * Session's requested worktree does not exist yet, or while the worktree
3055
+ * the Session works in is checked and, when gone, until it is restored or
3056
+ * left. The message is entering, so the delivery has not woken the Agent
3057
+ * yet and its wake waits for the hold.
3058
+ * @param agent - Agent whose inbox receives the message.
3059
+ */
3060
+ holdTurn(agent) {
3061
+ const session = agent.session;
3062
+ const state = this.worktreeState(session);
3063
+ if (state.request !== null && state.created === null && !state.locked) this.creations.holdCreation(agent);
3064
+ else if (state.created !== null && within(state.created.path, this.home.workingDirectory.get(session))) this.creations.holdRestoration(agent, (signal) => this.missingFor(session, signal));
3065
+ }
3066
+ /**
3067
+ * Check whether the worktree the Session works in is gone.
3068
+ * @param session - Session to check.
3069
+ * @param signal - cancellation of the check.
3070
+ * @returns the missing worktree, or null while it exists or once the Session works elsewhere.
3071
+ * @throws when the filesystem or Git cannot answer.
3072
+ */
3073
+ async missingFor(session, signal) {
3074
+ const directory = this.home.workingDirectory.get(session);
3075
+ const panel = this.panels.get(session);
3076
+ const missing = panel === void 0 ? await missingWorktree(this.home, this.config, session, this.worktreeState(session), directory, signal) : await panel.checkMissing(signal);
3077
+ return this.home.workingDirectory.get(session) === directory ? missing : null;
3078
+ }
3079
+ /** Inspect the checkout of the Session's current directory, which local diffs read. */
3080
+ async checkoutOf(session, signal) {
3081
+ const inspection = await inspectCheckout(this.home, this.config, session, this.home.workingDirectory.get(session), signal);
3082
+ if (inspection === void 0) throw new RemoteError("forgePanel/not-a-checkout", "The Session's directory is not inside a Git checkout", {});
3083
+ return inspection;
3084
+ }
3085
+ /** A visible binding of the Session that a provider serves, which actions and reads are limited to. */
3086
+ bound(session, url) {
3087
+ const entry = this.home.forgeSession.changeRequests(session).find((candidate) => candidate.url === url);
3088
+ if (entry?.ref == null) throw new RemoteError("forgePanel/not-bound", "The change request is not bound to this Session or no provider serves it", {});
3089
+ return {
3090
+ ...entry,
3091
+ ref: entry.ref
3092
+ };
3093
+ }
3094
+ worktreeState(session) {
3095
+ return this.worktreeStates.stateOf(session);
3096
+ }
3097
+ editableWorktree(session) {
3098
+ if (this.home.get("worktrees") === void 0) throw new RemoteError("forgePanel/worktrees-unavailable", "No worktree service is mounted", {});
3099
+ return this.changeableWorktree(session);
3100
+ }
3101
+ /** The worktree state of a Session whose request may still change: no turn started, nothing created, and no attempt running. */
3102
+ changeableWorktree(session) {
3103
+ const state = this.worktreeState(session);
3104
+ if (state.locked || state.created !== null) throw new RemoteError("forgePanel/session-started", "The Session has started, so its worktree can no longer change", {});
3105
+ if (this.creations.of(session) !== null) throw new RemoteError("forgePanel/session-busy", "The worktree is being created", {});
3106
+ return state;
3107
+ }
3108
+ createdWorktree(session) {
3109
+ const created = this.worktreeState(session).created;
3110
+ if (created === null) throw new RemoteError("forgePanel/no-worktree", "The Session has no created worktree", {});
3111
+ return created;
3112
+ }
3113
+ /**
3114
+ * Run work as maintenance of the Session's Agent: inside the wait of a
3115
+ * message that waits for the removed worktree, else as a task of its own,
3116
+ * which the Agent refuses while it has other work.
3117
+ */
3118
+ async maintain(session, work) {
3119
+ const held = this.creations.within(session, work);
3120
+ if (held !== void 0) {
3121
+ await held;
3122
+ return;
3123
+ }
3124
+ const agent = this.home.agents.get(session.id);
3125
+ if (agent === void 0) throw new RemoteError("forgePanel/session-busy", "The Session is not loaded", {});
3126
+ let running;
3127
+ try {
3128
+ running = agent.runMaintenance(async (signal) => {
3129
+ await work(agent, signal);
3130
+ });
3131
+ } catch (error) {
3132
+ throw new RemoteError("forgePanel/session-busy", "The Session is busy", {}, { cause: error });
3133
+ }
3134
+ await running;
3135
+ }
3136
+ /** The shared change request states, created for the first stream from the Host's Session and storage services. */
3137
+ changeRequestStatesHub() {
3138
+ if (this.states !== void 0) return this.states;
3139
+ const ctx = this.home;
3140
+ const query = ctx.get("sessionQuery");
3141
+ const cache = ctx.get("sessionProjectionCache");
3142
+ const storage = ctx.get("storageDomain");
3143
+ const store = ctx.get("sessions");
3144
+ if (query === void 0 || cache === void 0 || storage === void 0 || store === void 0) throw new Error("forge-panel: change request states need the sessionQuery, sessionProjectionCache, storageDomain, and sessions services");
3145
+ const visibleUrls = (session) => ctx.forgeSession.changeRequests(session).map((entry) => entry.url);
3146
+ this.states = new ChangeRequestStates({
3147
+ sessions: async (signal) => (await query.listSessions(signal)).map(({ header }) => {
3148
+ const live = store.get(header.id);
3149
+ if (live !== void 0) return {
3150
+ id: header.id,
3151
+ urls: visibleUrls(live)
3152
+ };
3153
+ const bindings = cache.cachedSnapshot(header, ["forgeChangeRequests"])?.values.forgeChangeRequests;
3154
+ return {
3155
+ id: header.id,
3156
+ urls: bindings?.filter((binding) => !binding.dismissed).map((binding) => binding.url)
3157
+ };
3158
+ }),
3159
+ onBindings: (listener) => ctx.sessionProjections.onChanged((session, key) => {
3160
+ if (key === "forgeChangeRequests") listener(session.id, visibleUrls(session));
3161
+ }),
3162
+ refOf: (url) => ctx.forge.changeRequestFromUrl(url),
3163
+ watch: (ref, listener) => ctx.forge.watch(ref, listener),
3164
+ open: async () => {
3165
+ const domain = await storage.open(changeRequestStatesDomain);
3166
+ return {
3167
+ table: domain.table("states"),
3168
+ close: () => domain.close()
3169
+ };
3170
+ },
3171
+ report: (error) => {
3172
+ ctx.logger.warn(`forge-panel: storing a change request state failed: ${String(error)}`);
3173
+ }
3174
+ });
3175
+ return this.states;
3176
+ }
3177
+ panelOf(session) {
3178
+ let panel = this.panels.get(session);
3179
+ if (panel === void 0) {
3180
+ const created = new SessionPanel(this.home, this.config, session, this.worktreeStates, this.creations, () => {
3181
+ if (this.panels.get(session) !== created) return;
3182
+ this.panels.delete(session);
3183
+ created.dispose();
3184
+ });
3185
+ panel = created;
3186
+ this.panels.set(session, panel);
3187
+ }
3188
+ return panel;
3189
+ }
3190
+ };
3191
+ })();
3192
+ /**
3193
+ * Report why the code host did not provide a diff or file.
3194
+ * @param failure - the provider's failure.
3195
+ * @returns `forgePanel/diff-too-large` for an answer over a size limit, else `forgePanel/diff-failed`, with the provider's message.
3196
+ */
3197
+ function readError(failure) {
3198
+ return new RemoteError(failure.code === "too-large" ? "forgePanel/diff-too-large" : "forgePanel/diff-failed", failure.message, {});
3199
+ }
3200
+ /**
3201
+ * Settle a diff read, reporting its size limit and a missing fork point as Remote errors.
3202
+ * @param reading - the read.
3203
+ * @returns what the read returned.
3204
+ */
3205
+ async function diffing(reading) {
3206
+ try {
3207
+ return await reading;
3208
+ } catch (error) {
3209
+ if (error instanceof FilterConfigError) throw new RemoteError("forgePanel/filter-config-failed", error.message, {}, { cause: error });
3210
+ if (error instanceof DiffTooLarge) throw new RemoteError("forgePanel/diff-too-large", error.message, {}, { cause: error });
3211
+ if (error instanceof NoForkPoint) throw new RemoteError("forgePanel/no-fork-point", error.message, {}, { cause: error });
3212
+ throw error;
3213
+ }
3214
+ }
3215
+ /**
3216
+ * Report a failed change request action, separating a head that moved since the user confirmed it.
3217
+ * @param failure - the provider's failure.
3218
+ * @returns the Remote error to throw.
3219
+ */
3220
+ function actionError(failure) {
3221
+ return new RemoteError(failure.code === "stale" ? "forgePanel/stale-head" : "forgePanel/action-failed", failure.message, {});
3222
+ }
3223
+ /**
3224
+ * Whether `prefix` followed by hexadecimal digits is a branch name that Git
3225
+ * accepts: no leading `-` or `/`, no empty component, no component that
3226
+ * starts with `.` or ends with `.lock`, and none of `..`, `@{`, whitespace,
3227
+ * control characters, `~`, `^`, `:`, `?`, `*`, `[`, or `\`.
3228
+ * @param prefix - configured worktree branch prefix.
3229
+ * @returns whether every generated name passes `git check-ref-format --branch`.
3230
+ */
3231
+ function branchPrefix(prefix) {
3232
+ const name = `${prefix}0`;
3233
+ if (name.startsWith("-") || name.includes("..") || name.includes("@{")) return false;
3234
+ for (const char of name) if (char <= " " || char === "" || "~^:?*[\\".includes(char)) return false;
3235
+ return name.split("/").every((part) => part.length > 0 && !part.startsWith(".") && !part.endsWith(".lock"));
3236
+ }
3237
+ //#endregion
3238
+ export { ForgePanelController, ForgePanelController as default };