dsh-worktree-switch 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1091 @@
1
+ import Schema from "@deepseek-ai/schemastery";
2
+ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
+ import { lstat, realpath, stat } from "node:fs/promises";
4
+ // src/host/paths.ts
5
+ /**
6
+ * Path predicates the host half shares between the verbs, the scope hint, and
7
+ * the working-directory watcher.
8
+ * @module dsh-worktree-switch/host/paths
9
+ */
10
+ /**
11
+ * Whether one absolute directory is the path itself or an ancestor of it.
12
+ * @param directory - candidate ancestor.
13
+ * @param path - absolute path to test.
14
+ * @returns true when `path` is `directory` or lives below it.
15
+ */
16
+ function contains(directory, path) {
17
+ const inside = relative(directory, path);
18
+ return inside === "" || !inside.startsWith("..") && !isAbsolute(inside);
19
+ }
20
+ // src/host/guard.ts
21
+ /**
22
+ * The source-checkout guard: while a session runs in a worktree copy, shell
23
+ * commands that would run Git against the project checkout are refused before
24
+ * dispatch.
25
+ *
26
+ * The prompt context (`worktree-switch:scope`) explains where the workspace
27
+ * is, but it cannot stop a model that still reaches for the checkout it can
28
+ * see — and the sandbox deliberately grants writes there. This guard is the
29
+ * enforcement half that needs no harness change: `tools/pre-execute` can deny
30
+ * one call, and the model reads the reason as that call's error result.
31
+ *
32
+ * The pattern is deliberately narrow. Only a shell call that mentions `git`
33
+ * is considered, and only when it names the project checkout either as an
34
+ * explicit `workdir` or as a literal absolute path in the command text.
35
+ * `cd ..`-style relative escapes and shell variables are not resolved; the
36
+ * guard is a net, not the sandbox boundary.
37
+ * @module dsh-worktree-switch/host/guard
38
+ */
39
+ /** Tool names whose `command` argument is executed by a shell. */
40
+ const SHELL_TOOLS = /* @__PURE__ */ new Set(["bash"]);
41
+ /**
42
+ * Read a shell call's command and explicit directory, if this is one.
43
+ * @param exec - the pending tool call.
44
+ * @returns the fields the guard needs, or null for every other tool.
45
+ */
46
+ function shellArguments(exec) {
47
+ if (!SHELL_TOOLS.has(exec.name)) return null;
48
+ if (exec.arguments === null || typeof exec.arguments !== "object") return null;
49
+ const args = exec.arguments;
50
+ const command = args["command"];
51
+ if (typeof command !== "string" || command.trim() === "") return null;
52
+ const workdir = args["workdir"];
53
+ const explicit = typeof workdir === "string" && workdir.trim() !== "" ? workdir : void 0;
54
+ return explicit === void 0 ? { command } : {
55
+ command,
56
+ workdir: explicit
57
+ };
58
+ }
59
+ /**
60
+ * The model-facing denial for one shell command.
61
+ * @param command - shell command text as the model wrote it.
62
+ * @param workdir - explicit working directory, when the call carried one.
63
+ * @param cwd - the session's committed working directory.
64
+ * @param scope - the copy the session runs in.
65
+ * @returns the denial reason, or null when the call may run.
66
+ */
67
+ function sourceCheckoutDenial(command, workdir, cwd, scope) {
68
+ if (!/\bgit\b/.test(command)) return null;
69
+ const checkout = scope.worktree.path;
70
+ const project = scope.repositoryRoot;
71
+ if (workdir !== void 0) {
72
+ const target = isAbsolute(workdir) ? workdir : resolve(cwd, workdir);
73
+ if (contains(project, target) && !contains(checkout, target)) return denial(scope);
74
+ }
75
+ if (command.split(checkout).join("").includes(project)) return denial(scope);
76
+ return null;
77
+ }
78
+ /** The one explanation every denial carries, including the way to get main. */
79
+ function denial(scope) {
80
+ return `Denied: this session runs in the worktree copy ${JSON.stringify(scope.worktree.path)}, so Git commands targeting the project checkout ${JSON.stringify(scope.repositoryRoot)} are blocked. Run Git in the current working directory instead; to bring main's latest into this copy, use \`git fetch origin main\` and then \`git merge origin/main\` (or \`git rebase origin/main\`).`;
81
+ }
82
+ /**
83
+ * Refuse shell calls that run Git against the project checkout while the
84
+ * session is inside a copy.
85
+ *
86
+ * The registry is the same one the prompt context reads, so the guard is
87
+ * active exactly while the model has been told it is in a copy.
88
+ * @param ctx - host context carrying `workingDirectory` and the tool runtime.
89
+ * @param registry - sessions' confirmed copies.
90
+ */
91
+ function installSourceCheckoutGuard(ctx, registry) {
92
+ ctx.on("tools/pre-execute", async (exec, next) => {
93
+ const agent = exec.agent;
94
+ if (agent === void 0) return next();
95
+ const scope = registry.get(String(agent.session.id));
96
+ if (scope === null) return next();
97
+ const args = shellArguments(exec);
98
+ if (args === null) return next();
99
+ const denial = sourceCheckoutDenial(args.command, args.workdir, ctx.workingDirectory.get(agent.session), scope);
100
+ return denial === null ? next() : {
101
+ kind: "deny",
102
+ reason: denial
103
+ };
104
+ });
105
+ }
106
+ /** Per-stream capture cap; worktree listings and status lines are small. */
107
+ const MAX_OUTPUT_BYTES = 1048576;
108
+ /**
109
+ * Clear inherited repository selectors and config so the command reads the
110
+ * checkout named by `cwd`, never one inherited from the Host's environment.
111
+ * One entry per key: `undefined` removes the variable for the child.
112
+ */
113
+ const GIT_ENV = Object.fromEntries([
114
+ "GIT_DIR",
115
+ "GIT_WORK_TREE",
116
+ "GIT_COMMON_DIR",
117
+ "GIT_INDEX_FILE",
118
+ "GIT_OBJECT_DIRECTORY",
119
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
120
+ "GIT_CONFIG",
121
+ "GIT_CONFIG_PARAMETERS",
122
+ "GIT_CONFIG_COUNT",
123
+ "GIT_CEILING_DIRECTORIES",
124
+ "GIT_PREFIX",
125
+ "GIT_SHALLOW_FILE",
126
+ "GIT_GRAFT_FILE",
127
+ "GIT_REPLACE_REF_BASE",
128
+ "GIT_ATTR_SOURCE"
129
+ ].map((key) => [key, void 0]));
130
+ /** A Git invocation that exited with a status the caller did not allow. */
131
+ var GitFailure = class extends Error {
132
+ args;
133
+ result;
134
+ /**
135
+ * @param args - arguments after the executable, for diagnostics.
136
+ * @param result - the captured outcome.
137
+ */
138
+ constructor(args, result) {
139
+ const detail = result.stderr.trim() === "" ? result.stdout.trim() : result.stderr.trim();
140
+ super(`git ${args.join(" ")} failed (exit ${result.exitCode})${detail === "" ? "" : `: ${detail}`}`);
141
+ this.args = args;
142
+ this.result = result;
143
+ this.name = "GitFailure";
144
+ }
145
+ };
146
+ /**
147
+ * Run one Git command without a shell and await its termination.
148
+ * @param ctx - Host context carrying the subprocess provider.
149
+ * @param cwd - absolute directory the command runs in.
150
+ * @param args - arguments after the executable.
151
+ * @param options - cancellation, accepted exit statuses, and deadline.
152
+ * @returns captured stdout, stderr, and exit status.
153
+ * @throws {GitFailure} when the exit status is not allowed or the command was killed.
154
+ */
155
+ async function runGit(ctx, cwd, args, options = {}) {
156
+ const deadline = AbortSignal.timeout(options.timeoutMs ?? 2e4);
157
+ const signal = options.signal === void 0 ? deadline : AbortSignal.any([options.signal, deadline]);
158
+ const allowed = options.allowedExitCodes ?? [0];
159
+ const argv = [
160
+ await ctx.subprocess.resolveExecutable("git", void 0, signal),
161
+ "-c",
162
+ "core.hooksPath=/dev/null",
163
+ "-c",
164
+ "core.fsmonitor=false",
165
+ ...args
166
+ ];
167
+ const child = ctx.subprocess.spawn({
168
+ argv,
169
+ cwd,
170
+ env: {
171
+ ...GIT_ENV,
172
+ GIT_TERMINAL_PROMPT: "0",
173
+ GIT_LFS_SKIP_SMUDGE: "1",
174
+ GIT_NO_LAZY_FETCH: "1"
175
+ },
176
+ signal,
177
+ graceMs: 1500,
178
+ stdio: {
179
+ stdin: "ignore",
180
+ stdout: { maxBytes: MAX_OUTPUT_BYTES },
181
+ stderr: { maxBytes: MAX_OUTPUT_BYTES }
182
+ }
183
+ });
184
+ try {
185
+ const outcome = await child.done;
186
+ const result = {
187
+ stdout: child.collected.stdout?.readFrom(0).text ?? "",
188
+ stderr: child.collected.stderr?.readFrom(0).text ?? "",
189
+ exitCode: outcome.exitCode ?? -1
190
+ };
191
+ if (outcome.exitCode === null || !allowed.includes(outcome.exitCode)) throw new GitFailure(args, result);
192
+ return result;
193
+ } finally {
194
+ child.terminate();
195
+ await child.waitForExit();
196
+ }
197
+ }
198
+ /**
199
+ * Resolve the repository root that contains `directory`.
200
+ * @param ctx - Host context carrying the subprocess provider.
201
+ * @param directory - absolute directory inside (or at) the checkout.
202
+ * @param signal - caller cancellation.
203
+ * @returns the canonical top-level directory Git reports.
204
+ * @throws {GitFailure} when the directory is not inside a Git working tree.
205
+ */
206
+ async function repositoryRoot(ctx, directory, signal) {
207
+ const result = await runGit(ctx, directory, ["rev-parse", "--show-toplevel"], {
208
+ signal,
209
+ allowedExitCodes: [0, 128]
210
+ });
211
+ if (result.exitCode !== 0) throw new GitFailure(["rev-parse", "--show-toplevel"], result);
212
+ return result.stdout.replace(/\r?\n$/, "");
213
+ }
214
+ /**
215
+ * Resolve symlinks in a path that exists, falling back to the path as given.
216
+ * @param path - absolute path.
217
+ * @returns the canonical spelling when the path exists.
218
+ */
219
+ async function canonicalPath(path) {
220
+ try {
221
+ return await realpath(path);
222
+ } catch {
223
+ return path;
224
+ }
225
+ }
226
+ /**
227
+ * Parse `git worktree list --porcelain` output into its records.
228
+ * @param stdout - the command's stdout.
229
+ * @returns one record per reported checkout, in Git's order.
230
+ */
231
+ function parseWorktreeList(stdout) {
232
+ const records = [];
233
+ let path;
234
+ let branch;
235
+ const flush = () => {
236
+ if (path === void 0) return;
237
+ records.push(branch === void 0 ? { path } : {
238
+ path,
239
+ branch
240
+ });
241
+ path = void 0;
242
+ branch = void 0;
243
+ };
244
+ for (const line of stdout.split("\n")) {
245
+ if (line === "") {
246
+ flush();
247
+ continue;
248
+ }
249
+ if (line.startsWith("worktree ")) path = line.slice(9);
250
+ else if (line.startsWith("branch refs/heads/")) branch = line.slice(18);
251
+ }
252
+ flush();
253
+ return records;
254
+ }
255
+ /**
256
+ * List the worktree checkouts of one repository.
257
+ * @param ctx - Host context carrying the subprocess provider.
258
+ * @param root - repository root the command runs in.
259
+ * @param signal - caller cancellation.
260
+ * @returns every checkout Git reports for the repository.
261
+ */
262
+ async function listWorktreeRecords(ctx, root, signal) {
263
+ return parseWorktreeList((await runGit(ctx, root, [
264
+ "worktree",
265
+ "list",
266
+ "--porcelain"
267
+ ], { signal })).stdout);
268
+ }
269
+ /**
270
+ * Count the changed and untracked files of one checkout.
271
+ * @param ctx - Host context carrying the subprocess provider.
272
+ * @param directory - checkout directory to inspect.
273
+ * @param signal - caller cancellation.
274
+ * @returns the number of `git status --porcelain` entries.
275
+ */
276
+ async function changedFileCount(ctx, directory, signal) {
277
+ return (await runGit(ctx, directory, [
278
+ "status",
279
+ "--porcelain",
280
+ "--untracked-files=normal"
281
+ ], { signal })).stdout.split("\n").filter((line) => line.trim() !== "").length;
282
+ }
283
+ // src/host/naming.ts
284
+ /**
285
+ * Names for the copy a session gets: lowercase words and a short suffix, so a
286
+ * branch, a directory, a `git branch` row and this plugin's UI all read the
287
+ * same. No prefix: the pool path already says where it lives.
288
+ * @module dsh-worktree-switch/host/naming
289
+ */
290
+ /** First word of a generated name. */
291
+ const ADJECTIVES = [
292
+ "amber",
293
+ "brisk",
294
+ "calm",
295
+ "clever",
296
+ "crisp",
297
+ "eager",
298
+ "faint",
299
+ "fresh",
300
+ "gentle",
301
+ "happy",
302
+ "hollow",
303
+ "kind",
304
+ "lively",
305
+ "mellow",
306
+ "nimble",
307
+ "polite",
308
+ "quiet",
309
+ "rapid",
310
+ "ready",
311
+ "silent",
312
+ "soft",
313
+ "solid",
314
+ "steady",
315
+ "sunny",
316
+ "swift",
317
+ "tidy",
318
+ "vivid",
319
+ "warm",
320
+ "wise",
321
+ "young"
322
+ ];
323
+ /** Second word of a generated name. */
324
+ const NOUNS = [
325
+ "anchor",
326
+ "atlas",
327
+ "badger",
328
+ "birch",
329
+ "brook",
330
+ "cedar",
331
+ "comet",
332
+ "coral",
333
+ "delta",
334
+ "ember",
335
+ "fern",
336
+ "field",
337
+ "finch",
338
+ "glade",
339
+ "harbor",
340
+ "heron",
341
+ "island",
342
+ "lark",
343
+ "ledge",
344
+ "maple",
345
+ "meadow",
346
+ "otter",
347
+ "pebble",
348
+ "pine",
349
+ "quartz",
350
+ "river",
351
+ "shore",
352
+ "stone",
353
+ "trail",
354
+ "willow"
355
+ ];
356
+ /**
357
+ * Generate one candidate worktree name.
358
+ * @param random - uniform source in `[0, 1)`; injectable for deterministic tests.
359
+ * @returns a lowercase, hyphen-joined branch-safe name such as `quiet-harbor-3fa9`.
360
+ */
361
+ function worktreeName(random = Math.random) {
362
+ const pick = (values) => {
363
+ return values[Math.min(values.length - 1, Math.floor(random() * values.length))] ?? values[0] ?? "copy";
364
+ };
365
+ const suffix = Math.min(65535, Math.floor(random() * 65536)).toString(16).padStart(4, "0");
366
+ return `${pick(ADJECTIVES)}-${pick(NOUNS)}-${suffix}`;
367
+ }
368
+ /** A pool entry name is exactly one path segment, as `git worktree` names it. */
369
+ const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
370
+ /**
371
+ * Validate a worktree name received from the browser before it reaches Git.
372
+ * @param value - candidate name.
373
+ * @returns true for one plain path segment that Git also accepts as a branch.
374
+ */
375
+ function isWorktreeName(value) {
376
+ return NAME_PATTERN.test(value) && !value.endsWith(".lock");
377
+ }
378
+ // src/host/handlers.ts
379
+ /**
380
+ * The plugin's host Remote face: the five verbs the browser half can call,
381
+ * expressed against a live session and the checkouts on disk.
382
+ *
383
+ * This module is the seam the host tests drive directly. Everything below it —
384
+ * Git, the worktree service, the sandbox policy it applies, and session
385
+ * working-directory publication — belongs to the harness and is exercised by
386
+ * its own tests.
387
+ * @module dsh-worktree-switch/host/handlers
388
+ */
389
+ /** Attempts at a fresh name before a creation failure becomes an error. */
390
+ const CREATE_ATTEMPTS = 3;
391
+ /** A failed verb, carrying the stable code the browser half switches on. */
392
+ var WorktreeToggleError = class extends Error {
393
+ code;
394
+ details;
395
+ /**
396
+ * @param code - stable failure code.
397
+ * @param message - human-readable explanation.
398
+ * @param details - extra machine-readable facts.
399
+ */
400
+ constructor(code, message, details = {}) {
401
+ super(message);
402
+ this.code = code;
403
+ this.details = details;
404
+ this.name = "WorktreeToggleError";
405
+ }
406
+ };
407
+ /** Normalize a browser-supplied path before any filesystem work. */
408
+ function absolutePath(value) {
409
+ const trimmed = value.trim();
410
+ if (trimmed === "") throw new WorktreeToggleError("bad-request", "a project path is required");
411
+ return resolve(trimmed);
412
+ }
413
+ /**
414
+ * The copy a session already runs in.
415
+ *
416
+ * A linked worktree is recognised from the checkout itself — its `.git` is a
417
+ * file pointing back at the source repository, not a directory — rather than
418
+ * from any configured pool path. The pool belongs to the harness worktree
419
+ * service, so this plugin never needs a second copy of that setting: moving the
420
+ * pool in the service's row config keeps every verb working.
421
+ * @param current - the session's committed working directory.
422
+ * @param repositoryRoot - canonical root of the project's repository.
423
+ * @returns the copy's identity, or null when the directory is not one.
424
+ */
425
+ async function worktreeAt(current, repositoryRoot) {
426
+ const path = await canonicalPath(current);
427
+ if (path === repositoryRoot) return null;
428
+ const inside = relative(repositoryRoot, path);
429
+ if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) return null;
430
+ const name = basename(path);
431
+ if (!isWorktreeName(name)) return null;
432
+ try {
433
+ if (!(await lstat(`${path}/.git`)).isFile()) return null;
434
+ } catch {
435
+ return null;
436
+ }
437
+ return {
438
+ name,
439
+ path,
440
+ branch: name
441
+ };
442
+ }
443
+ /**
444
+ * Resolve the canonical root of the repository that contains a directory.
445
+ * Git reports the spelling of the path it was given; the worktree service
446
+ * publishes canonical paths, so both halves must agree on one spelling.
447
+ * @param ctx - host context carrying `subprocess`.
448
+ * @param directory - absolute directory inside the project.
449
+ * @param signal - caller cancellation.
450
+ * @returns the canonical repository root.
451
+ */
452
+ async function repositoryOf(ctx, directory, signal) {
453
+ return canonicalPath(await repositoryRoot(ctx, directory, signal));
454
+ }
455
+ /**
456
+ * Re-derive the copy one session runs in from its committed directory.
457
+ *
458
+ * A copy's root is the only linked checkout whose name matches the pool
459
+ * convention, but a session may have changed into a directory *inside* the copy,
460
+ * so ancestors are probed from the committed directory up to the project
461
+ * anchor.
462
+ * @param ctx - host context carrying `workingDirectory` and `subprocess`.
463
+ * @param agent - live session owner.
464
+ * @param signal - caller cancellation.
465
+ * @returns the copy and project root, or null when the session is not in one.
466
+ */
467
+ async function detectSessionScope(ctx, agent, signal) {
468
+ const current = ctx.workingDirectory.get(agent.session);
469
+ const anchor = agent.session.header.cwd ?? current;
470
+ if (current === anchor || !contains(anchor, current)) return null;
471
+ let root;
472
+ try {
473
+ root = await repositoryOf(ctx, anchor, signal);
474
+ } catch {
475
+ return null;
476
+ }
477
+ signal?.throwIfAborted();
478
+ let probe = current;
479
+ for (;;) {
480
+ const copy = await worktreeAt(probe, root);
481
+ if (copy !== null) return {
482
+ worktree: copy,
483
+ repositoryRoot: root
484
+ };
485
+ const parent = dirname(probe);
486
+ if (probe === root || probe === anchor || parent === probe) return null;
487
+ probe = parent;
488
+ }
489
+ }
490
+ /**
491
+ * Build the host face.
492
+ * @param ctx - Host context with `agents`, `worktrees`, `workingDirectory`, and `subprocess`.
493
+ * @param options - resolved row config.
494
+ * @returns the five verbs.
495
+ */
496
+ function createWorktreeToggle(ctx, options) {
497
+ /** One in-flight preparation per session, so a double mount cannot create twice. */
498
+ const pending = /* @__PURE__ */ new Map();
499
+ /**
500
+ * Copies this host instance created for a session, by session id. Turning the
501
+ * switch off may only delete what the switch itself just made: a copy that
502
+ * existed before this instance started is left for the panel's explicit
503
+ * discard, because it may carry work the switch never saw.
504
+ */
505
+ const createdBySession = /* @__PURE__ */ new Map();
506
+ const sessionOf = async (sessionId, signal) => {
507
+ signal?.throwIfAborted();
508
+ if (sessionId === "") throw new WorktreeToggleError("bad-request", "a session id is required");
509
+ const controller = ctx.get("sessionController");
510
+ if (controller !== void 0) {
511
+ const resolved = await controller.resolveAgent(sessionId);
512
+ if ("error" in resolved) throw new WorktreeToggleError(resolved.error.code, resolved.error.message, { sessionId });
513
+ return resolved.agent;
514
+ }
515
+ const agent = ctx.agents.get(sessionId);
516
+ if (agent === void 0) throw new WorktreeToggleError("session-not-found", `no live agent for session ${sessionId}`, { sessionId });
517
+ return agent;
518
+ };
519
+ const status = async (request, signal) => {
520
+ const agent = await sessionOf(request.sessionId, signal);
521
+ const startDirectory = agent.session.header.cwd ?? ctx.workingDirectory.get(agent.session);
522
+ let root;
523
+ try {
524
+ root = await repositoryOf(ctx, startDirectory, signal);
525
+ } catch (error) {
526
+ if (error instanceof GitFailure) return {
527
+ supported: false,
528
+ reason: "not-a-git-repository",
529
+ defaultEnabled: options.defaultEnabled
530
+ };
531
+ throw error;
532
+ }
533
+ const current = ctx.workingDirectory.get(agent.session);
534
+ signal?.throwIfAborted();
535
+ const sourceDirty = await changedFileCount(ctx, root, signal);
536
+ const copy = await worktreeAt(current, root);
537
+ options.onScope?.(request.sessionId, copy === null ? null : {
538
+ worktree: copy,
539
+ repositoryRoot: root
540
+ });
541
+ return {
542
+ supported: true,
543
+ repositoryRoot: root,
544
+ sessionWorktree: copy,
545
+ sourceDirty,
546
+ defaultEnabled: options.defaultEnabled
547
+ };
548
+ };
549
+ const prepareOnce = async (request, signal) => {
550
+ const agent = await sessionOf(request.sessionId, signal);
551
+ const current = await status(request, signal);
552
+ if (!current.supported) throw new WorktreeToggleError("not-a-git-repository", "the session project is not inside a Git working tree");
553
+ if (current.sessionWorktree !== null) {
554
+ options.onScope?.(request.sessionId, {
555
+ worktree: current.sessionWorktree,
556
+ repositoryRoot: current.repositoryRoot
557
+ });
558
+ return {
559
+ state: "existing",
560
+ worktree: current.sessionWorktree,
561
+ repositoryRoot: current.repositoryRoot,
562
+ sourceDirty: current.sourceDirty
563
+ };
564
+ }
565
+ const workingProject = await repositoryOf(ctx, ctx.workingDirectory.get(agent.session), signal);
566
+ if (workingProject !== current.repositoryRoot) throw new WorktreeToggleError("session-moved", `this session now works in ${workingProject}, not in its project ${current.repositoryRoot}`, {
567
+ repositoryRoot: current.repositoryRoot,
568
+ workingProject
569
+ });
570
+ let lastFailure;
571
+ for (let attempt = 0; attempt < CREATE_ATTEMPTS; attempt += 1) {
572
+ signal?.throwIfAborted();
573
+ const name = worktreeName();
574
+ try {
575
+ const created = await ctx.worktrees.create(agent, {
576
+ name,
577
+ from: "HEAD"
578
+ }, signal);
579
+ const worktree = {
580
+ name: created.branch,
581
+ path: created.path,
582
+ branch: created.branch
583
+ };
584
+ createdBySession.set(request.sessionId, worktree);
585
+ options.onScope?.(request.sessionId, {
586
+ worktree,
587
+ repositoryRoot: created.repositoryRoot
588
+ });
589
+ return {
590
+ state: "created",
591
+ worktree,
592
+ repositoryRoot: created.repositoryRoot,
593
+ sourceDirty: current.sourceDirty
594
+ };
595
+ } catch (error) {
596
+ lastFailure = error;
597
+ const message = error instanceof Error ? error.message : String(error);
598
+ if (!/already exists|worktree branch already exists|worktree path already exists/i.test(message)) throw new WorktreeToggleError("worktree-create-failed", message, { name });
599
+ }
600
+ }
601
+ throw new WorktreeToggleError("worktree-create-failed", `could not allocate a free worktree name after ${CREATE_ATTEMPTS} attempts`, { cause: lastFailure instanceof Error ? lastFailure.message : String(lastFailure) });
602
+ };
603
+ const prepare = async (request, signal) => {
604
+ const running = pending.get(request.sessionId);
605
+ if (running !== void 0) return running;
606
+ const operation = prepareOnce(request, signal);
607
+ pending.set(request.sessionId, operation);
608
+ try {
609
+ return await operation;
610
+ } finally {
611
+ if (pending.get(request.sessionId) === operation) pending.delete(request.sessionId);
612
+ }
613
+ };
614
+ const list = async (request, signal) => {
615
+ const directory = absolutePath(request.path);
616
+ let root;
617
+ try {
618
+ root = await repositoryOf(ctx, directory, signal);
619
+ } catch (error) {
620
+ if (error instanceof GitFailure) return {
621
+ supported: false,
622
+ reason: "not-a-git-repository"
623
+ };
624
+ throw error;
625
+ }
626
+ const records = await listWorktreeRecords(ctx, root, signal);
627
+ const items = [];
628
+ for (const record of records) {
629
+ signal?.throwIfAborted();
630
+ const path = await canonicalPath(record.path);
631
+ if (path === root) continue;
632
+ const name = basename(path);
633
+ if (!isWorktreeName(name)) continue;
634
+ const branch = record.branch ?? name;
635
+ let createdAt = 0;
636
+ let prunable = true;
637
+ try {
638
+ const info = await stat(path);
639
+ createdAt = Math.trunc(info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs);
640
+ prunable = false;
641
+ } catch {}
642
+ const changedFiles = prunable ? 0 : await changedFileCount(ctx, path, signal);
643
+ items.push({
644
+ name,
645
+ path,
646
+ branch,
647
+ createdAt,
648
+ changedFiles,
649
+ prunable
650
+ });
651
+ }
652
+ items.sort((left, right) => right.createdAt - left.createdAt || left.name.localeCompare(right.name));
653
+ return {
654
+ supported: true,
655
+ repositoryRoot: root,
656
+ items
657
+ };
658
+ };
659
+ /**
660
+ * Delete one clean copy and its branch. Both the panel's discard and the
661
+ * switch's own undo go through here, so the dirty guard has one home.
662
+ * @param root - canonical repository root.
663
+ * @param name - copy name; already validated by the caller.
664
+ * @param signal - caller cancellation.
665
+ * @returns the removed copy's identity.
666
+ */
667
+ const removeCopy = async (root, name, signal) => {
668
+ const listed = await list({ path: root }, signal);
669
+ if (!listed.supported) throw new WorktreeToggleError("not-a-git-repository", "the project is not inside a Git working tree");
670
+ const item = listed.items.find((entry) => entry.name === name);
671
+ if (item === void 0) throw new WorktreeToggleError("worktree-not-found", `no worktree named ${name} in ${root}`, {
672
+ name,
673
+ repositoryRoot: root
674
+ });
675
+ if (item.changedFiles > 0) throw new WorktreeToggleError("worktree-dirty", `${item.name} has ${item.changedFiles} uncommitted change(s); commit or discard them first`, {
676
+ name: item.name,
677
+ changedFiles: item.changedFiles
678
+ });
679
+ if (item.prunable) await runGit(ctx, root, ["worktree", "prune"], { signal });
680
+ else {
681
+ const removed = await runGit(ctx, root, [
682
+ "worktree",
683
+ "remove",
684
+ item.path
685
+ ], {
686
+ signal,
687
+ allowedExitCodes: [0, 128]
688
+ });
689
+ if (removed.exitCode !== 0) throw new WorktreeToggleError("worktree-remove-failed", new GitFailure([
690
+ "worktree",
691
+ "remove",
692
+ item.path
693
+ ], removed).message, {
694
+ name: item.name,
695
+ path: item.path
696
+ });
697
+ }
698
+ const deleted = await runGit(ctx, root, [
699
+ "branch",
700
+ "-D",
701
+ "--",
702
+ item.branch
703
+ ], {
704
+ signal,
705
+ allowedExitCodes: [0, 1]
706
+ });
707
+ const branchGone = deleted.exitCode === 1 && /not found/i.test(deleted.stderr);
708
+ if (deleted.exitCode !== 0 && !branchGone) throw new WorktreeToggleError("worktree-remove-failed", new GitFailure([
709
+ "branch",
710
+ "-D",
711
+ item.branch
712
+ ], deleted).message, {
713
+ name: item.name,
714
+ branch: item.branch
715
+ });
716
+ for (const [sessionId, copy] of createdBySession) if (copy.path === item.path) createdBySession.delete(sessionId);
717
+ return {
718
+ removed: true,
719
+ name: item.name,
720
+ branch: item.branch,
721
+ path: item.path
722
+ };
723
+ };
724
+ const discard = async (request, signal) => {
725
+ const directory = absolutePath(request.path);
726
+ if (!isWorktreeName(request.name)) throw new WorktreeToggleError("worktree-name-invalid", `not a worktree name: ${request.name}`);
727
+ const root = await repositoryOf(ctx, directory, signal);
728
+ return await removeCopy(root, request.name, signal);
729
+ };
730
+ const release = async (request, signal) => {
731
+ const running = pending.get(request.sessionId);
732
+ if (running !== void 0) try {
733
+ await running;
734
+ } catch {}
735
+ signal?.throwIfAborted();
736
+ const agent = await sessionOf(request.sessionId, signal);
737
+ const current = ctx.workingDirectory.get(agent.session);
738
+ const anchor = agent.session.header.cwd ?? current;
739
+ let root;
740
+ try {
741
+ root = await repositoryOf(ctx, anchor, signal);
742
+ } catch (error) {
743
+ if (error instanceof GitFailure) throw new WorktreeToggleError("not-a-git-repository", "the session project is not inside a Git working tree");
744
+ throw error;
745
+ }
746
+ const listed = await list({ path: anchor }, signal);
747
+ if (!listed.supported) return {
748
+ directory: current,
749
+ removed: null,
750
+ kept: null
751
+ };
752
+ const path = await canonicalPath(current);
753
+ const item = listed.items.find((entry) => contains(entry.path, path));
754
+ if (item === void 0) {
755
+ options.onScope?.(request.sessionId, null);
756
+ return {
757
+ directory: current,
758
+ removed: null,
759
+ kept: null
760
+ };
761
+ }
762
+ const copy = {
763
+ name: item.name,
764
+ path: item.path,
765
+ branch: item.branch
766
+ };
767
+ signal?.throwIfAborted();
768
+ let directory;
769
+ try {
770
+ directory = await ctx.workingDirectory.set(agent, anchor, signal);
771
+ } catch (error) {
772
+ throw new WorktreeToggleError("release-failed", `could not move the session back to ${anchor}: ${error instanceof Error ? error.message : String(error)}`, {
773
+ name: copy.name,
774
+ path: copy.path
775
+ });
776
+ }
777
+ options.onScope?.(request.sessionId, null);
778
+ const mine = createdBySession.get(request.sessionId);
779
+ if (mine === void 0 || mine.path !== copy.path) return {
780
+ directory,
781
+ removed: null,
782
+ kept: copy
783
+ };
784
+ try {
785
+ const removed = await removeCopy(root, copy.name, signal);
786
+ return {
787
+ directory,
788
+ removed: {
789
+ name: removed.name,
790
+ path: removed.path,
791
+ branch: removed.branch
792
+ },
793
+ kept: null
794
+ };
795
+ } catch (error) {
796
+ if (signal?.aborted === true) throw error;
797
+ return {
798
+ directory,
799
+ removed: null,
800
+ kept: copy
801
+ };
802
+ }
803
+ };
804
+ return {
805
+ status,
806
+ prepare,
807
+ release,
808
+ list,
809
+ discard
810
+ };
811
+ }
812
+ // src/host/routes.ts
813
+ /** Absolute prefix every route below lives under. */
814
+ const API_PREFIX = "/api/dsh-worktree-switch";
815
+ /** Plugin id; the wire method prefix and the package name are the same string. */
816
+ const PLUGIN_ID = "dsh-worktree-switch";
817
+ /** One route per verb. */
818
+ const ENDPOINTS = [
819
+ "status",
820
+ "prepare",
821
+ "release",
822
+ "list",
823
+ "discard"
824
+ ];
825
+ /** A payload object with the named string field, or the failure to send back. */
826
+ function requiredString(payload, field) {
827
+ const value = payload[field];
828
+ if (typeof value !== "string" || value.trim() === "") return { failure: new WorktreeToggleError("bad-request", `payload.${field} must be a non-empty string`) };
829
+ return { value };
830
+ }
831
+ /**
832
+ * Decode the browser's `client-request` envelope.
833
+ * @param body - parsed JSON body.
834
+ * @returns the request, or null when the envelope is malformed.
835
+ */
836
+ function readClientRequest(body) {
837
+ if (body === null || typeof body !== "object" || Array.isArray(body)) return null;
838
+ const record = body;
839
+ if (record.type !== "client-request") return null;
840
+ const { rpcId, method } = record;
841
+ if (typeof rpcId !== "string" || rpcId === "") return null;
842
+ if (typeof method !== "string" || method === "") return null;
843
+ const payload = record.payload;
844
+ return {
845
+ rpcId,
846
+ method,
847
+ payload: payload !== null && typeof payload === "object" && !Array.isArray(payload) ? payload : {}
848
+ };
849
+ }
850
+ /** A successful result. */
851
+ function ok(value) {
852
+ return {
853
+ ok: true,
854
+ value
855
+ };
856
+ }
857
+ /** A failed result carrying the stable code. */
858
+ function fail(code, message, details = {}) {
859
+ return {
860
+ ok: false,
861
+ error: {
862
+ code,
863
+ message,
864
+ details
865
+ }
866
+ };
867
+ }
868
+ /**
869
+ * Dispatch one decoded verb.
870
+ * @param toggle - the host face.
871
+ * @param endpoint - the verb.
872
+ * @param payload - its decoded payload.
873
+ * @param signal - request cancellation.
874
+ * @returns the wire result.
875
+ */
876
+ async function dispatch(toggle, endpoint, payload, signal) {
877
+ try {
878
+ if (endpoint === "status") {
879
+ const sessionId = requiredString(payload, "sessionId");
880
+ if ("failure" in sessionId) throw sessionId.failure;
881
+ return ok(await toggle.status({ sessionId: sessionId.value }, signal));
882
+ }
883
+ if (endpoint === "prepare") {
884
+ const sessionId = requiredString(payload, "sessionId");
885
+ if ("failure" in sessionId) throw sessionId.failure;
886
+ return ok(await toggle.prepare({ sessionId: sessionId.value }, signal));
887
+ }
888
+ if (endpoint === "release") {
889
+ const sessionId = requiredString(payload, "sessionId");
890
+ if ("failure" in sessionId) throw sessionId.failure;
891
+ return ok(await toggle.release({ sessionId: sessionId.value }, signal));
892
+ }
893
+ if (endpoint === "list") {
894
+ const path = requiredString(payload, "path");
895
+ if ("failure" in path) throw path.failure;
896
+ return ok(await toggle.list({ path: path.value }, signal));
897
+ }
898
+ const path = requiredString(payload, "path");
899
+ if ("failure" in path) throw path.failure;
900
+ const name = requiredString(payload, "name");
901
+ if ("failure" in name) throw name.failure;
902
+ return ok(await toggle.discard({
903
+ path: path.value,
904
+ name: name.value
905
+ }, signal));
906
+ } catch (error) {
907
+ if (error instanceof WorktreeToggleError) return fail(error.code, error.message, { ...error.details });
908
+ return fail("internal", error instanceof Error ? error.message : String(error));
909
+ }
910
+ }
911
+ /**
912
+ * Register one exact route per verb on the shared `/api` channel.
913
+ * @param ctx - Host context carrying `connection`.
914
+ * @param toggle - the host face the routes call.
915
+ */
916
+ function registerWorktreeRoutes(ctx, toggle) {
917
+ for (const endpoint of ENDPOINTS) ctx.effect(() => ctx.connection.fetch.register({
918
+ path: `${API_PREFIX}/${endpoint}`,
919
+ methods: ["POST"],
920
+ requestBody: "buffered",
921
+ async fetch(request) {
922
+ if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return new Response("content type must be application/json", { status: 415 });
923
+ let body;
924
+ try {
925
+ body = await request.json();
926
+ } catch {
927
+ return new Response("body is not JSON", { status: 400 });
928
+ }
929
+ const message = readClientRequest(body);
930
+ if (message === null) return new Response("invalid client-request message", { status: 400 });
931
+ if (message.method !== `dsh-worktree-switch/${endpoint}`) return Response.json({
932
+ type: "server-response",
933
+ rpcId: message.rpcId,
934
+ result: fail("bad-request", "RPC method does not match endpoint")
935
+ });
936
+ const result = await dispatch(toggle, endpoint, message.payload, request.signal);
937
+ return Response.json({
938
+ type: "server-response",
939
+ rpcId: message.rpcId,
940
+ result
941
+ });
942
+ }
943
+ }), `${PLUGIN_ID} ${endpoint} route`);
944
+ }
945
+ // src/host/scope.ts
946
+ /** Sessions whose confirmed copy the prompt context reads. */
947
+ var SessionScopeRegistry = class {
948
+ scopes = /* @__PURE__ */ new Map();
949
+ /**
950
+ * Read one session's confirmed scope, without any filesystem work.
951
+ * @param sessionId - wire session identity.
952
+ * @returns the known copy, or null.
953
+ */
954
+ get(sessionId) {
955
+ return this.scopes.get(sessionId) ?? null;
956
+ }
957
+ /**
958
+ * Record that a session now runs in a copy.
959
+ * @param sessionId - wire session identity.
960
+ * @param scope - the confirmed copy and its project root.
961
+ */
962
+ remember(sessionId, scope) {
963
+ this.scopes.set(sessionId, scope);
964
+ }
965
+ /**
966
+ * Drop a session's scope; a session with no entry gets no hint.
967
+ * @param sessionId - wire session identity.
968
+ */
969
+ forget(sessionId) {
970
+ this.scopes.delete(sessionId);
971
+ }
972
+ /** Drop every entry; the plugin dropping its context drops its hints with it. */
973
+ clear() {
974
+ this.scopes.clear();
975
+ }
976
+ };
977
+ /**
978
+ * The model-facing line describing a worktree session's workspace.
979
+ * @param scope - the session's confirmed copy.
980
+ * @returns one sentence pair, JSON-quoted so paths survive the prompt.
981
+ */
982
+ function scopeHint(scope) {
983
+ return `This session is a linked Git worktree checkout at ${JSON.stringify(scope.worktree.path)} of the project ${JSON.stringify(scope.repositoryRoot)}. Run Git commands and file edits from the current working directory; the project checkout is the source, not this session's workspace.`;
984
+ }
985
+ /**
986
+ * Register the worktree scope as dynamic prompt context and keep it fresh.
987
+ *
988
+ * The context is `required`, so it survives compositions that suppress
989
+ * optional runtime context, and it renders from the registry synchronously —
990
+ * the expensive Git detection happens on session creation and directory
991
+ * changes, never at request time.
992
+ * @param ctx - host context carrying `systemPrompt`, `agents`, and `workingDirectory`.
993
+ * @param wiring - registry plus the Git-backed detector.
994
+ */
995
+ function installWorktreeScope(ctx, wiring) {
996
+ const { registry, detect } = wiring;
997
+ ctx.inject(["systemPrompt"], (scoped) => {
998
+ scoped.systemPrompt.context({
999
+ name: "worktree-switch:scope",
1000
+ order: scoped.systemPrompt.getContextOrder("WORKING_DIRECTORY") + 5,
1001
+ interpolate: false,
1002
+ required: true,
1003
+ text: (assembly) => {
1004
+ const agent = assembly.agent;
1005
+ if (agent === void 0) return "";
1006
+ const sessionId = String(agent.session.id);
1007
+ const current = ctx.workingDirectory.get(agent.session);
1008
+ const scope = registry.get(sessionId);
1009
+ if (scope !== null && contains(scope.worktree.path, current)) return scopeHint(scope);
1010
+ if (scope !== null) registry.forget(sessionId);
1011
+ const anchor = agent.session.header.cwd ?? current;
1012
+ if (current === anchor || !contains(anchor, current)) return "";
1013
+ if (lastChecked.get(sessionId) === current) return "";
1014
+ refresh(agent, current);
1015
+ return "";
1016
+ }
1017
+ });
1018
+ });
1019
+ const inFlight = /* @__PURE__ */ new Set();
1020
+ /** The directory value each session was last inspected at, hit or miss. */
1021
+ const lastChecked = /* @__PURE__ */ new Map();
1022
+ /** Re-derive one live agent's scope, remembering or forgetting it. */
1023
+ const refresh = async (agent, current = ctx.workingDirectory.get(agent.session)) => {
1024
+ const sessionId = String(agent.session.id);
1025
+ const anchor = agent.session.header.cwd ?? current;
1026
+ if (current === anchor || !contains(anchor, current)) {
1027
+ registry.forget(sessionId);
1028
+ return;
1029
+ }
1030
+ if (inFlight.has(sessionId)) return;
1031
+ inFlight.add(sessionId);
1032
+ lastChecked.set(sessionId, current);
1033
+ try {
1034
+ const found = await detect(agent);
1035
+ if (found === null) registry.forget(sessionId);
1036
+ else registry.remember(sessionId, found);
1037
+ } catch {
1038
+ registry.forget(sessionId);
1039
+ } finally {
1040
+ inFlight.delete(sessionId);
1041
+ }
1042
+ };
1043
+ ctx.on("agent/created", async ({ agent }) => {
1044
+ await refresh(agent);
1045
+ });
1046
+ }
1047
+ // src/index.ts
1048
+ /** Plugin id: package name, bundle row id, browser module id. */
1049
+ const name = "dsh-worktree-switch";
1050
+ /**
1051
+ * Services this plugin cannot work without.
1052
+ *
1053
+ * `worktrees` is provided by the row this bundle's patch inserts next to its
1054
+ * own, so Cordis holds this plugin until that row has mounted.
1055
+ */
1056
+ const inject = [
1057
+ "agents",
1058
+ "connection",
1059
+ "subprocess",
1060
+ "worktrees",
1061
+ "workingDirectory"
1062
+ ];
1063
+ /** Schemastery schema for {@link Config}. */
1064
+ const Config = Schema.object({
1065
+ defaultEnabled: Schema.boolean().default(true).description("Start a new session in a worktree copy until the user turns the switch off for that project."),
1066
+ guardSourceCheckout: Schema.boolean().default(true).description("While a session is in a copy, deny shell commands that run Git against the project checkout.")
1067
+ });
1068
+ /**
1069
+ * Mount the host half.
1070
+ * @param ctx - Host context.
1071
+ * @param config - validated row config.
1072
+ */
1073
+ function apply(ctx, config) {
1074
+ const scopes = new SessionScopeRegistry();
1075
+ const toggle = createWorktreeToggle(ctx, {
1076
+ defaultEnabled: config.defaultEnabled,
1077
+ onScope: (sessionId, scope) => {
1078
+ if (scope === null) scopes.forget(sessionId);
1079
+ else scopes.remember(sessionId, scope);
1080
+ }
1081
+ });
1082
+ installWorktreeScope(ctx, {
1083
+ registry: scopes,
1084
+ detect: (agent, signal) => detectSessionScope(ctx, agent, signal)
1085
+ });
1086
+ if (config.guardSourceCheckout) installSourceCheckoutGuard(ctx, scopes);
1087
+ registerWorktreeRoutes(ctx, toggle);
1088
+ }
1089
+ export { Config, apply, inject, name };
1090
+
1091
+ //# sourceMappingURL=index.js.map