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

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