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/LICENSE +21 -0
- package/README.md +42 -15
- package/cordis.patch.yml +8 -0
- package/lib/client.js +87 -18
- package/lib/index.d.ts +2 -0
- package/lib/index.js +412 -38
- package/lib/index.js.map +1 -1
- package/package.json +56 -55
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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)
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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 ===
|
|
503
|
-
if (item === void 0) throw new WorktreeToggleError("worktree-not-found", `no worktree named ${
|
|
504
|
-
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({
|
|
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
|
-
|
|
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
|
|