dsh-worktree-switch 0.2.1 → 0.3.1

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 CHANGED
@@ -1,6 +1,108 @@
1
1
  import Schema from "@deepseek-ai/schemastery";
2
+ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
2
3
  import { lstat, realpath, stat } from "node:fs/promises";
3
- import { basename, isAbsolute, relative, resolve } from "node:path";
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
+ }
4
106
  /** Per-stream capture cap; worktree listings and status lines are small. */
5
107
  const MAX_OUTPUT_BYTES = 1048576;
6
108
  /**
@@ -275,7 +377,7 @@ function isWorktreeName(value) {
275
377
  }
276
378
  // src/host/handlers.ts
277
379
  /**
278
- * The plugin's host Remote face: the four verbs the browser half can call,
380
+ * The plugin's host Remote face: the five verbs the browser half can call,
279
381
  * expressed against a live session and the checkouts on disk.
280
382
  *
281
383
  * This module is the seam the host tests drive directly. Everything below it —
@@ -339,14 +441,68 @@ async function worktreeAt(current, repositoryRoot) {
339
441
  };
340
442
  }
341
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
+ /**
342
491
  * Build the host face.
343
492
  * @param ctx - Host context with `agents`, `worktrees`, `workingDirectory`, and `subprocess`.
344
493
  * @param options - resolved row config.
345
- * @returns the four verbs.
494
+ * @returns the five verbs.
346
495
  */
347
496
  function createWorktreeToggle(ctx, options) {
348
497
  /** One in-flight preparation per session, so a double mount cannot create twice. */
349
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();
350
506
  const sessionOf = async (sessionId, signal) => {
351
507
  signal?.throwIfAborted();
352
508
  if (sessionId === "") throw new WorktreeToggleError("bad-request", "a session id is required");
@@ -360,21 +516,12 @@ function createWorktreeToggle(ctx, options) {
360
516
  if (agent === void 0) throw new WorktreeToggleError("session-not-found", `no live agent for session ${sessionId}`, { sessionId });
361
517
  return agent;
362
518
  };
363
- /**
364
- * Resolve the canonical root of the repository that contains a directory.
365
- * Git reports the spelling of the path it was given; the worktree service
366
- * publishes canonical paths, so both halves must agree on one spelling.
367
- * @param directory - absolute directory inside the project.
368
- * @param signal - caller cancellation.
369
- * @returns the canonical repository root.
370
- */
371
- const repositoryOf = async (directory, signal) => canonicalPath(await repositoryRoot(ctx, directory, signal));
372
519
  const status = async (request, signal) => {
373
520
  const agent = await sessionOf(request.sessionId, signal);
374
521
  const startDirectory = agent.session.header.cwd ?? ctx.workingDirectory.get(agent.session);
375
522
  let root;
376
523
  try {
377
- root = await repositoryOf(startDirectory, signal);
524
+ root = await repositoryOf(ctx, startDirectory, signal);
378
525
  } catch (error) {
379
526
  if (error instanceof GitFailure) return {
380
527
  supported: false,
@@ -386,10 +533,15 @@ function createWorktreeToggle(ctx, options) {
386
533
  const current = ctx.workingDirectory.get(agent.session);
387
534
  signal?.throwIfAborted();
388
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
+ });
389
541
  return {
390
542
  supported: true,
391
543
  repositoryRoot: root,
392
- sessionWorktree: await worktreeAt(current, root),
544
+ sessionWorktree: copy,
393
545
  sourceDirty,
394
546
  defaultEnabled: options.defaultEnabled
395
547
  };
@@ -398,14 +550,19 @@ function createWorktreeToggle(ctx, options) {
398
550
  const agent = await sessionOf(request.sessionId, signal);
399
551
  const current = await status(request, signal);
400
552
  if (!current.supported) throw new WorktreeToggleError("not-a-git-repository", "the session project is not inside a Git working tree");
401
- if (current.sessionWorktree !== null) return {
402
- state: "existing",
403
- worktree: current.sessionWorktree,
404
- repositoryRoot: current.repositoryRoot,
405
- sourceDirty: current.sourceDirty
406
- };
407
- const working = ctx.workingDirectory.get(agent.session);
408
- const workingProject = await repositoryOf(working, signal);
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);
409
566
  if (workingProject !== current.repositoryRoot) throw new WorktreeToggleError("session-moved", `this session now works in ${workingProject}, not in its project ${current.repositoryRoot}`, {
410
567
  repositoryRoot: current.repositoryRoot,
411
568
  workingProject
@@ -419,13 +576,19 @@ function createWorktreeToggle(ctx, options) {
419
576
  name,
420
577
  from: "HEAD"
421
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
+ });
422
589
  return {
423
590
  state: "created",
424
- worktree: {
425
- name: created.branch,
426
- path: created.path,
427
- branch: created.branch
428
- },
591
+ worktree,
429
592
  repositoryRoot: created.repositoryRoot,
430
593
  sourceDirty: current.sourceDirty
431
594
  };
@@ -452,7 +615,7 @@ function createWorktreeToggle(ctx, options) {
452
615
  const directory = absolutePath(request.path);
453
616
  let root;
454
617
  try {
455
- root = await repositoryOf(directory, signal);
618
+ root = await repositoryOf(ctx, directory, signal);
456
619
  } catch (error) {
457
620
  if (error instanceof GitFailure) return {
458
621
  supported: false,
@@ -493,15 +656,20 @@ function createWorktreeToggle(ctx, options) {
493
656
  items
494
657
  };
495
658
  };
496
- const discard = async (request, signal) => {
497
- const directory = absolutePath(request.path);
498
- if (!isWorktreeName(request.name)) throw new WorktreeToggleError("worktree-name-invalid", `not a worktree name: ${request.name}`);
499
- const root = await repositoryOf(directory, signal);
500
- const listed = await list({ path: directory }, signal);
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);
501
669
  if (!listed.supported) throw new WorktreeToggleError("not-a-git-repository", "the project is not inside a Git working tree");
502
- const item = listed.items.find((entry) => entry.name === request.name);
503
- if (item === void 0) throw new WorktreeToggleError("worktree-not-found", `no worktree named ${request.name} in ${root}`, {
504
- name: request.name,
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,
505
673
  repositoryRoot: root
506
674
  });
507
675
  if (item.changedFiles > 0) throw new WorktreeToggleError("worktree-dirty", `${item.name} has ${item.changedFiles} uncommitted change(s); commit or discard them first`, {
@@ -545,6 +713,7 @@ function createWorktreeToggle(ctx, options) {
545
713
  name: item.name,
546
714
  branch: item.branch
547
715
  });
716
+ for (const [sessionId, copy] of createdBySession) if (copy.path === item.path) createdBySession.delete(sessionId);
548
717
  return {
549
718
  removed: true,
550
719
  name: item.name,
@@ -552,9 +721,90 @@ function createWorktreeToggle(ctx, options) {
552
721
  path: item.path
553
722
  };
554
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
+ };
555
804
  return {
556
805
  status,
557
806
  prepare,
807
+ release,
558
808
  list,
559
809
  discard
560
810
  };
@@ -568,6 +818,7 @@ const PLUGIN_ID = "dsh-worktree-switch";
568
818
  const ENDPOINTS = [
569
819
  "status",
570
820
  "prepare",
821
+ "release",
571
822
  "list",
572
823
  "discard"
573
824
  ];
@@ -634,6 +885,11 @@ async function dispatch(toggle, endpoint, payload, signal) {
634
885
  if ("failure" in sessionId) throw sessionId.failure;
635
886
  return ok(await toggle.prepare({ sessionId: sessionId.value }, signal));
636
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
+ }
637
893
  if (endpoint === "list") {
638
894
  const path = requiredString(payload, "path");
639
895
  if ("failure" in path) throw path.failure;
@@ -686,6 +942,108 @@ function registerWorktreeRoutes(ctx, toggle) {
686
942
  }
687
943
  }), `${PLUGIN_ID} ${endpoint} route`);
688
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
+ }
689
1047
  // src/index.ts
690
1048
  /** Plugin id: package name, bundle row id, browser module id. */
691
1049
  const name = "dsh-worktree-switch";
@@ -703,14 +1061,30 @@ const inject = [
703
1061
  "workingDirectory"
704
1062
  ];
705
1063
  /** Schemastery schema for {@link Config}. */
706
- const Config = Schema.object({ defaultEnabled: Schema.boolean().default(true).description("Start a new session in a worktree copy until the user turns the switch off for that project.") });
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
+ });
707
1068
  /**
708
1069
  * Mount the host half.
709
1070
  * @param ctx - Host context.
710
1071
  * @param config - validated row config.
711
1072
  */
712
1073
  function apply(ctx, config) {
713
- registerWorktreeRoutes(ctx, createWorktreeToggle(ctx, { defaultEnabled: config.defaultEnabled }));
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);
714
1088
  }
715
1089
  export { Config, apply, inject, name };
716
1090