dsh-worktree-switch 0.2.1 → 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/README.md +34 -15
- 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 +3 -1
package/README.md
CHANGED
|
@@ -2,20 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
在新会话屏幕的输入框工具行里放一个 **Worktree 开关**。默认打开(按项目记忆):
|
|
4
4
|
打开时立刻在 `<项目根>/.dsh/worktrees/<名字>` 建一份仓库副本,并把这条会话的工作
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
目录切进去;在发出第一条消息之前再关掉,会话会被切回项目目录,开关自己刚建的干净
|
|
6
|
+
副本也一并删掉——关掉就是撤销这次隔离。副本不会自动消失、也不会自动合并,用户可以
|
|
7
|
+
在 **Worktrees 面板**里把它丢弃,或者自己把分支合回源仓库。会话在副本里时,宿主半还会
|
|
8
|
+
给模型一条工作区说明,并拒绝把 git 指向主检出的 shell 调用(可关)。
|
|
7
9
|
|
|
8
10
|
## 它做什么
|
|
9
11
|
|
|
10
12
|
- **开关**(`conversation.input.left` 槽,只在空白新会话的 hero 上出现):显示本会话
|
|
11
|
-
当前的副本状态——检查中 / 关闭 / 创建中 / 就绪(显示名字)/ 失败(显示原因 +
|
|
12
|
-
|
|
13
|
-
-
|
|
13
|
+
当前的副本状态——检查中 / 关闭 / 创建中 / 离开中 / 就绪(显示名字)/ 失败(显示原因 +
|
|
14
|
+
重试)/ 不可用(项目不是 Git 仓库)。开关位置本身记的是"这个项目的新会话要不要隔离"。
|
|
15
|
+
- **创建**发生在开关打开的那一刻,早于第一条消息;**关闭**发生在第一条消息之前时,
|
|
16
|
+
会话被切回项目目录,开关在这次打开中建的那份干净副本被删除(脏副本或开关之前就存在
|
|
17
|
+
的副本留在池里,交给面板处理)。副本名 = 分支名 = 池内目录名,形如
|
|
14
18
|
`quiet-harbor-3fa9`,由插件生成,短且唯一。
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
- **创建失败不回落**:提示词不发送,错误就地显示。从检查开始、到副本就绪之前、离开副本
|
|
20
|
+
的过程中(或失败之后),开关通过 `ctx.conversation.blocks` 把该会话的输入框置为"暂停
|
|
21
|
+
发送";重试成功、关掉开关(确认不要隔离)或副本就绪时才解除。关掉开关是明确的退出
|
|
22
|
+
方式——它表示"这条会话就在原目录里跑",而不是假装隔离成功。
|
|
23
|
+
- **会话范围说明**:会话在副本里时,宿主半跟着每轮请求给模型补一条上下文(紧跟工作目录那
|
|
24
|
+
行):当前目录是 `<项目>` 的 linked worktree 副本,git 与文件操作在副本里做,主检出是源。
|
|
25
|
+
它会随恢复会话、每次进/出副本重新推导,回到项目目录后自动消失。这是**提示**,不是权限
|
|
26
|
+
边界——真正的写隔离仍取决于沙箱根(见「已知边界」)。
|
|
27
|
+
- **主检出守卫**:会话在副本里时,`tools/pre-execute` 会拦下“把 git 指向主检出”的 bash
|
|
28
|
+
调用(命令里出现主检出的绝对路径,或 `workdir` 落在主检出里),拒绝原因直接告诉模型
|
|
29
|
+
在副本里该怎么做(`git fetch origin main` + `merge`/`rebase`)。这是启发式拦截,不是
|
|
30
|
+
沙箱边界:`cd ..` 之类的相对逃逸、shell 变量展开不在它的覆盖范围内;配置
|
|
31
|
+
`guardSourceCheckout: false` 可关掉。拦下的是 git,主检出里的普通写入仍然由沙箱放行。
|
|
19
32
|
- **面板**(侧边栏条目 + 自己的页面):按项目列出该仓库的所有 linked worktree(包括
|
|
20
33
|
手工 `git worktree add` 出来的),含名字、分支、路径、创建时间和未提交改动数;丢弃
|
|
21
34
|
需要第二次确认,且**有未提交改动时拒绝丢弃**并说明原因。丢弃会删掉检出目录和分支。
|
|
@@ -47,8 +60,9 @@ dsh --profile web --dump-config # 应能看到 "# == dsh-worktree-switch" 和
|
|
|
47
60
|
未提交内容留在原地。
|
|
48
61
|
4. 侧边栏出现 **Worktree** 入口:打开后按项目列出磁盘上的副本,含创建时间与未提交改动。
|
|
49
62
|
5. 想丢弃一份副本:点两次 Discard(第二次是确认);有未提交改动时按钮禁用并说明原因。
|
|
50
|
-
6.
|
|
51
|
-
|
|
63
|
+
6. 开关只在空白会话上出现:发第一条消息之前关掉,会把会话切回项目目录并删掉刚建的空
|
|
64
|
+
副本;发过消息之后开关消失,副本留在池里,由面板丢弃。拨开关同时记住这个项目下次
|
|
65
|
+
的默认位置。
|
|
52
66
|
|
|
53
67
|
## 配置
|
|
54
68
|
|
|
@@ -85,6 +99,7 @@ worktree,所以新旧目录的副本都会出现,可以照常丢弃。
|
|
|
85
99
|
| 字段 | 默认 | 含义 |
|
|
86
100
|
|---|---|---|
|
|
87
101
|
| `defaultEnabled` | `true` | 用户从未在这个项目上拨过开关时,新会话是否默认隔离。用户拨过一次后,按项目记在浏览器 localStorage 里,优于此项。 |
|
|
102
|
+
| `guardSourceCheckout` | `true` | 会话在副本里时,用 `tools/pre-execute` 拒绝把 git 指向主检出的 bash 调用。关掉就只剩提示。 |
|
|
88
103
|
|
|
89
104
|
## 目录与命名
|
|
90
105
|
|
|
@@ -105,18 +120,22 @@ worktree,所以新旧目录的副本都会出现,可以照常丢弃。
|
|
|
105
120
|
定位文件)显示的是源项目根,而不是副本。这是刻意的:沙箱边界与侧边栏归属都依赖它。
|
|
106
121
|
- 沙箱边界仍是源项目根,因此副本不是"写隔离",它隔离的是**提交与未提交改动**:
|
|
107
122
|
助手在副本里改的文件不会落到源检出的工作树里,但同一会话仍可写源项目根下的文件。
|
|
123
|
+
主检出守卫只覆盖 bash 里明显指向主检出的 git 调用,是提示之外的兜底,不是权限。
|
|
108
124
|
- 不自动合并、不自动回收、不支持多仓库、不识别旧插件的 `.worktree-space/` 目录。
|
|
109
125
|
|
|
110
126
|
## 实现
|
|
111
127
|
|
|
112
|
-
宿主半(`src/index.ts
|
|
113
|
-
`ctx.sessionController
|
|
114
|
-
Fetch 路由(`src/host/routes.ts`),说 `client-request` / `server-response`
|
|
128
|
+
宿主半(`src/index.ts`)做三件事:拿 `cordis.patch.yml` 挂上的 `ctx.worktrees` 和
|
|
129
|
+
`ctx.sessionController`,把五个动词注册成 `/api/dsh-worktree-switch/<verb>` 上的精确
|
|
130
|
+
Fetch 路由(`src/host/routes.ts`),说 `client-request` / `server-response` 信封;另注册一条
|
|
131
|
+
`systemPrompt.context`(`worktree-switch:scope`),内容来自 `SessionScopeRegistry`,由五个
|
|
132
|
+
动词、`agent/created` 和每次组装时的目录校验共同维护。
|
|
115
133
|
|
|
116
134
|
| 动词 | 入参 | 行为 |
|
|
117
135
|
|---|---|---|
|
|
118
136
|
| `status` | `{ sessionId }` | 解析仓库根、本会话是否已在某个 linked worktree 里、源仓库未提交改动数 |
|
|
119
137
|
| `prepare` | `{ sessionId }` | 幂等;必要时经 `ctx.worktrees.create` 建副本并切目录,同名冲突自动换名重试 |
|
|
138
|
+
| `release` | `{ sessionId }` | 把会话切回项目目录;删掉开关自己建的那份干净副本,脏副本与非开关建的副本留在池里 |
|
|
120
139
|
| `list` | `{ path }` | 列出该仓库除主检出外的所有 linked worktree,返回名字/分支/路径/创建时间/改动数 |
|
|
121
140
|
| `discard` | `{ path, name }` | 干净才删:`git worktree remove` + `git branch -D`;改动非空则拒绝 |
|
|
122
141
|
|
|
@@ -133,7 +152,7 @@ pnpm --filter dsh-worktree-switch test
|
|
|
133
152
|
pnpm build # 构建全部包并校验 exports / bundle patch 文件存在
|
|
134
153
|
```
|
|
135
154
|
|
|
136
|
-
|
|
155
|
+
测试只有两条接缝:宿主半的五个动词(真实临时 Git 仓库 + 真实服务,`tests/host.spec.ts`)
|
|
137
156
|
和浏览器半的槽注册与渲染(真实 `SlotRegistry` + 假 `connection`/`conversation`,
|
|
138
157
|
`tests/*.client.spec.tsx`)。不断言内部 Git 调用次数,也不测上游 worktree 服务自己的
|
|
139
158
|
Git 细节。
|
package/lib/client.js
CHANGED
|
@@ -30,6 +30,7 @@ function chipLabel(state, t) {
|
|
|
30
30
|
case "checking": return t("chip.checking");
|
|
31
31
|
case "off": return t("chip.off");
|
|
32
32
|
case "creating": return t("chip.creating");
|
|
33
|
+
case "reverting": return t("chip.reverting");
|
|
33
34
|
case "ready": return t("chip.ready", { name: state.worktree?.name ?? "" });
|
|
34
35
|
case "failed": return t("chip.failed");
|
|
35
36
|
case "disabled": return t("chip.disabled");
|
|
@@ -46,6 +47,7 @@ function chipHint(state, t) {
|
|
|
46
47
|
case "checking": return t("chip.hint.checking");
|
|
47
48
|
case "off": return t("chip.hint.off");
|
|
48
49
|
case "creating": return t("chip.hint.creating");
|
|
50
|
+
case "reverting": return t("chip.hint.reverting");
|
|
49
51
|
case "ready": {
|
|
50
52
|
const parts = [t("chip.hint.ready", {
|
|
51
53
|
branch: state.worktree?.branch ?? "",
|
|
@@ -53,6 +55,7 @@ function chipHint(state, t) {
|
|
|
53
55
|
})];
|
|
54
56
|
if (!state.intent) parts.push(t("chip.hint.intentOff", { name: state.worktree?.name ?? "" }));
|
|
55
57
|
if (state.sourceDirty > 0) parts.push(t("chip.hint.dirty", { count: state.sourceDirty }));
|
|
58
|
+
if (state.error !== null) parts.push(state.error);
|
|
56
59
|
return parts.join(" ");
|
|
57
60
|
}
|
|
58
61
|
case "failed": {
|
|
@@ -79,7 +82,7 @@ function WorktreeChip(props) {
|
|
|
79
82
|
block(null);
|
|
80
83
|
return;
|
|
81
84
|
}
|
|
82
|
-
const reason = state.phase === "checking" ? t("chip.blocked.checking") :
|
|
85
|
+
const reason = state.phase === "checking" ? t("chip.blocked.checking") : state.phase === "creating" ? t("chip.blocked.creating") : state.phase === "reverting" ? t("chip.blocked.reverting") : state.phase === "failed" && state.intent ? t("chip.blocked.failed") : null;
|
|
83
86
|
block(reason);
|
|
84
87
|
}, [
|
|
85
88
|
block,
|
|
@@ -93,7 +96,7 @@ function WorktreeChip(props) {
|
|
|
93
96
|
}, [block]);
|
|
94
97
|
if (!hero) return null;
|
|
95
98
|
const hint = chipHint(state, t);
|
|
96
|
-
const locked = state.phase === "checking" || state.phase === "disabled";
|
|
99
|
+
const locked = state.phase === "checking" || state.phase === "disabled" || state.phase === "reverting";
|
|
97
100
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
98
101
|
className: "dsh-wt-chip",
|
|
99
102
|
"data-phase": state.phase,
|
|
@@ -411,6 +414,7 @@ const en = {
|
|
|
411
414
|
"chip.checking": "Worktree…",
|
|
412
415
|
"chip.off": "Worktree off",
|
|
413
416
|
"chip.creating": "Creating worktree…",
|
|
417
|
+
"chip.reverting": "Leaving worktree…",
|
|
414
418
|
"chip.ready": "Worktree: {name}",
|
|
415
419
|
"chip.failed": "Worktree failed",
|
|
416
420
|
"chip.retry": "Retry",
|
|
@@ -419,13 +423,15 @@ const en = {
|
|
|
419
423
|
"chip.hint.off": "New sessions in this project start in the project directory.",
|
|
420
424
|
"chip.hint.on": "This session runs in a copy of the project; the source checkout is untouched.",
|
|
421
425
|
"chip.hint.creating": "Creating a checkout inside the project…",
|
|
426
|
+
"chip.hint.reverting": "Moving this session back to the project directory…",
|
|
422
427
|
"chip.hint.ready": "Branch {branch} at {path}",
|
|
423
428
|
"chip.hint.failed": "The copy was not created, so this session is still in the project directory.",
|
|
424
429
|
"chip.hint.disabled": "This project is not a Git repository, so there is nothing to copy.",
|
|
425
430
|
"chip.hint.dirty": "The source checkout has {count} uncommitted change(s); the copy starts from the last commit.",
|
|
426
|
-
"chip.hint.intentOff": "
|
|
431
|
+
"chip.hint.intentOff": "Turning off could not leave {name}; this session still runs in it.",
|
|
427
432
|
"chip.blocked.checking": "Checking whether this session gets a worktree…",
|
|
428
433
|
"chip.blocked.creating": "Creating the worktree before the first prompt…",
|
|
434
|
+
"chip.blocked.reverting": "Leaving the worktree before the first prompt…",
|
|
429
435
|
"chip.blocked.failed": "The worktree could not be created. Retry, turn the switch off, or discard the failed copy first.",
|
|
430
436
|
"panel.title": "Worktrees",
|
|
431
437
|
"panel.subtitle": "Copies created by the Worktree switch: one checkout and one branch each. Discard removes both.",
|
|
@@ -455,6 +461,7 @@ const zh = {
|
|
|
455
461
|
"chip.checking": "Worktree…",
|
|
456
462
|
"chip.off": "Worktree 已关",
|
|
457
463
|
"chip.creating": "正在创建 Worktree…",
|
|
464
|
+
"chip.reverting": "正在离开 Worktree…",
|
|
458
465
|
"chip.ready": "Worktree:{name}",
|
|
459
466
|
"chip.failed": "Worktree 创建失败",
|
|
460
467
|
"chip.retry": "重试",
|
|
@@ -463,13 +470,15 @@ const zh = {
|
|
|
463
470
|
"chip.hint.off": "这个项目的新会话直接在项目目录里开始。",
|
|
464
471
|
"chip.hint.on": "本会话在项目的一份副本里运行,源仓库不受影响。",
|
|
465
472
|
"chip.hint.creating": "正在项目里创建检出…",
|
|
473
|
+
"chip.hint.reverting": "正在把本会话切回项目目录…",
|
|
466
474
|
"chip.hint.ready": "分支 {branch},位于 {path}",
|
|
467
475
|
"chip.hint.failed": "副本没有创建成功,本会话仍然在项目目录里。",
|
|
468
476
|
"chip.hint.disabled": "这个项目不是 Git 仓库,没有可复制的内容。",
|
|
469
477
|
"chip.hint.dirty": "源仓库有 {count} 处未提交改动;副本会从最后一次提交开始。",
|
|
470
|
-
"chip.hint.intentOff": "
|
|
478
|
+
"chip.hint.intentOff": "关闭时没能离开 {name},本会话仍在副本里运行。",
|
|
471
479
|
"chip.blocked.checking": "正在检查本会话是否需要 Worktree…",
|
|
472
480
|
"chip.blocked.creating": "正在创建 Worktree,副本就绪前暂停发送…",
|
|
481
|
+
"chip.blocked.reverting": "正在离开 Worktree,切回项目目录前暂停发送…",
|
|
473
482
|
"chip.blocked.failed": "Worktree 创建失败。请重试、关闭开关,或先丢弃失败的副本。",
|
|
474
483
|
"panel.title": "Worktree",
|
|
475
484
|
"panel.subtitle": "Worktree 开关创建的副本:每份副本是一个检出加一个分支,丢弃会同时删掉两者。",
|
|
@@ -623,6 +632,10 @@ var InternalBinding = class {
|
|
|
623
632
|
started = false;
|
|
624
633
|
/** The project whose remembered choice is in play; null until status answers. */
|
|
625
634
|
project = null;
|
|
635
|
+
/** A create is in flight; a second one would race the same copy. */
|
|
636
|
+
creating = false;
|
|
637
|
+
/** The release in flight, shared by callers that ask to leave at once. */
|
|
638
|
+
releasing = null;
|
|
626
639
|
face;
|
|
627
640
|
/**
|
|
628
641
|
* @param ctx - browser Cordis context.
|
|
@@ -683,6 +696,7 @@ var InternalBinding = class {
|
|
|
683
696
|
...base,
|
|
684
697
|
phase: "ready"
|
|
685
698
|
});
|
|
699
|
+
if (!intent) this.release();
|
|
686
700
|
return;
|
|
687
701
|
}
|
|
688
702
|
if (!intent) {
|
|
@@ -702,8 +716,10 @@ var InternalBinding = class {
|
|
|
702
716
|
}
|
|
703
717
|
/** Create the copy for a session the switch says should have one. */
|
|
704
718
|
async create() {
|
|
719
|
+
if (this.creating) return;
|
|
705
720
|
const before = this.store.getSnapshot();
|
|
706
|
-
if (before.repositoryRoot === null) return;
|
|
721
|
+
if (before.repositoryRoot === null || !before.intent) return;
|
|
722
|
+
this.creating = true;
|
|
707
723
|
this.store.set({
|
|
708
724
|
...before,
|
|
709
725
|
phase: "creating",
|
|
@@ -712,6 +728,10 @@ var InternalBinding = class {
|
|
|
712
728
|
try {
|
|
713
729
|
const prepared = await callHost(this.ctx, "prepare", { sessionId: this.sessionId }, this.signal);
|
|
714
730
|
const now = this.store.getSnapshot();
|
|
731
|
+
if (!now.intent) {
|
|
732
|
+
if (now.phase === "reverting") this.release();
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
715
735
|
this.store.set({
|
|
716
736
|
...now,
|
|
717
737
|
phase: "ready",
|
|
@@ -724,12 +744,7 @@ var InternalBinding = class {
|
|
|
724
744
|
if (this.signal.aborted) return;
|
|
725
745
|
const now = this.store.getSnapshot();
|
|
726
746
|
if (!now.intent) {
|
|
727
|
-
this.
|
|
728
|
-
...now,
|
|
729
|
-
phase: "off",
|
|
730
|
-
worktree: null,
|
|
731
|
-
error: null
|
|
732
|
-
});
|
|
747
|
+
if (now.phase === "reverting") this.release();
|
|
733
748
|
return;
|
|
734
749
|
}
|
|
735
750
|
this.store.set({
|
|
@@ -737,22 +752,76 @@ var InternalBinding = class {
|
|
|
737
752
|
phase: "failed",
|
|
738
753
|
error: messageOf(error)
|
|
739
754
|
});
|
|
755
|
+
} finally {
|
|
756
|
+
this.creating = false;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Leave the copy this session runs in, so "off" means the project directory.
|
|
761
|
+
* Concurrent callers share one round trip.
|
|
762
|
+
* @returns completion of the leave.
|
|
763
|
+
*/
|
|
764
|
+
release() {
|
|
765
|
+
const running = this.releasing;
|
|
766
|
+
if (running !== null) return running;
|
|
767
|
+
const operation = this.leave().finally(() => {
|
|
768
|
+
if (this.releasing === operation) this.releasing = null;
|
|
769
|
+
});
|
|
770
|
+
this.releasing = operation;
|
|
771
|
+
return operation;
|
|
772
|
+
}
|
|
773
|
+
/** Ask the host to undo the copy and settle the switch on what it reported. */
|
|
774
|
+
async leave() {
|
|
775
|
+
const before = this.store.getSnapshot();
|
|
776
|
+
this.store.set({
|
|
777
|
+
...before,
|
|
778
|
+
phase: "reverting",
|
|
779
|
+
error: null
|
|
780
|
+
});
|
|
781
|
+
try {
|
|
782
|
+
await callHost(this.ctx, "release", { sessionId: this.sessionId }, this.signal);
|
|
783
|
+
if (this.signal.aborted) return;
|
|
784
|
+
const now = this.store.getSnapshot();
|
|
785
|
+
this.store.set({
|
|
786
|
+
...now,
|
|
787
|
+
phase: "off",
|
|
788
|
+
intent: false,
|
|
789
|
+
worktree: null,
|
|
790
|
+
error: null
|
|
791
|
+
});
|
|
792
|
+
} catch (error) {
|
|
793
|
+
if (this.signal.aborted) return;
|
|
794
|
+
const now = this.store.getSnapshot();
|
|
795
|
+
this.store.set({
|
|
796
|
+
...now,
|
|
797
|
+
phase: now.worktree === null ? "off" : "ready",
|
|
798
|
+
intent: false,
|
|
799
|
+
error: messageOf(error)
|
|
800
|
+
});
|
|
740
801
|
}
|
|
741
802
|
}
|
|
742
803
|
/** Persist and apply one switch move. */
|
|
743
804
|
setIntent(next) {
|
|
744
805
|
const state = this.store.getSnapshot();
|
|
745
|
-
if (state.phase === "checking" || state.phase === "disabled") return;
|
|
806
|
+
if (state.phase === "checking" || state.phase === "disabled" || state.phase === "reverting") return;
|
|
746
807
|
if (this.project !== null) writeIntent(this.project, next);
|
|
747
|
-
|
|
808
|
+
if (!next) {
|
|
809
|
+
if (state.intent === next) return;
|
|
810
|
+
this.store.set({
|
|
811
|
+
...state,
|
|
812
|
+
intent: false,
|
|
813
|
+
error: null
|
|
814
|
+
});
|
|
815
|
+
this.release();
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (state.intent === next) return;
|
|
748
819
|
this.store.set({
|
|
749
820
|
...state,
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
error: next ? state.error : null
|
|
821
|
+
intent: true,
|
|
822
|
+
error: null
|
|
753
823
|
});
|
|
754
|
-
if (
|
|
755
|
-
if (phase === "off") this.create();
|
|
824
|
+
if (state.phase === "off" || state.phase === "failed") this.create();
|
|
756
825
|
}
|
|
757
826
|
/** Re-run the host round trip with the switch on. */
|
|
758
827
|
retry() {
|
package/lib/index.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export declare const inject: string[];
|
|
|
13
13
|
export interface Config {
|
|
14
14
|
/** Default for a project the user has never switched either way. */
|
|
15
15
|
defaultEnabled: boolean;
|
|
16
|
+
/** Refuse shell commands that run Git against the project checkout while the session is in a copy. */
|
|
17
|
+
guardSourceCheckout: boolean;
|
|
16
18
|
}
|
|
17
19
|
/** Schemastery schema for {@link Config}. */
|
|
18
20
|
export declare const Config: Schema<Config>;
|
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
|
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/host/git.ts","../src/host/naming.ts","../src/host/handlers.ts","../src/host/routes.ts","../src/index.ts"],"sourcesContent":["/**\n * Unconfined Git calls behind the plugin's user-initiated verbs.\n *\n * Creation goes through `ctx.worktrees` (the harness service), which runs its\n * own Git under the calling session's sandbox policy. The list, status, and\n * discard verbs here belong to a person clicking in the UI rather than to a\n * model turn: they inspect and delete the copies the user can see, from a path\n * the same UI just listed. They therefore spawn Git directly, the same way the\n * installed `dsh-worktree-space` plugin does, instead of borrowing a session's\n * policy for an operation no session asked for.\n * @module dsh-worktree-switch/host/git\n */\n\nimport { realpath } from 'node:fs/promises'\nimport type { Context } from '@deepseek-ai/cordis'\nimport type {} from '@deepseek-ai/dsh-subprocess'\n\n/** Whole-command deadline for one Git call. */\nexport const GIT_TIMEOUT_MS = 20_000\n\n/** Per-stream capture cap; worktree listings and status lines are small. */\nconst MAX_OUTPUT_BYTES = 1_048_576\n\n/**\n * Clear inherited repository selectors and config so the command reads the\n * checkout named by `cwd`, never one inherited from the Host's environment.\n * One entry per key: `undefined` removes the variable for the child.\n */\nconst GIT_ENV: NodeJS.ProcessEnv = Object.fromEntries([\n 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY',\n 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_CONFIG', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT',\n 'GIT_CEILING_DIRECTORIES', 'GIT_PREFIX', 'GIT_SHALLOW_FILE', 'GIT_GRAFT_FILE',\n 'GIT_REPLACE_REF_BASE', 'GIT_ATTR_SOURCE',\n].map(key => [key, undefined]))\n\n/** One bounded Git invocation's captured result. */\nexport interface GitResult {\n /** Captured stdout. */\n readonly stdout: string\n /** Captured stderr. */\n readonly stderr: string\n /** Process exit status. */\n readonly exitCode: number\n}\n\n/** A Git invocation that exited with a status the caller did not allow. */\nexport class GitFailure extends Error {\n /**\n * @param args - arguments after the executable, for diagnostics.\n * @param result - the captured outcome.\n */\n constructor(readonly args: readonly string[], readonly result: GitResult) {\n const detail = result.stderr.trim() === '' ? result.stdout.trim() : result.stderr.trim()\n super(`git ${args.join(' ')} failed (exit ${result.exitCode})${detail === '' ? '' : `: ${detail}`}`)\n this.name = 'GitFailure'\n }\n}\n\n/** Options for {@link runGit}. */\nexport interface GitOptions {\n /** Caller cancellation, combined with the command deadline. */\n readonly signal?: AbortSignal | undefined\n /** Exit statuses that are not failures; defaults to `[0]`. */\n readonly allowedExitCodes?: readonly number[] | undefined\n /** Override for {@link GIT_TIMEOUT_MS}. */\n readonly timeoutMs?: number | undefined\n}\n\n/**\n * Run one Git command without a shell and await its termination.\n * @param ctx - Host context carrying the subprocess provider.\n * @param cwd - absolute directory the command runs in.\n * @param args - arguments after the executable.\n * @param options - cancellation, accepted exit statuses, and deadline.\n * @returns captured stdout, stderr, and exit status.\n * @throws {GitFailure} when the exit status is not allowed or the command was killed.\n */\nexport async function runGit(\n ctx: Context,\n cwd: string,\n args: readonly string[],\n options: GitOptions = {},\n): Promise<GitResult> {\n const deadline = AbortSignal.timeout(options.timeoutMs ?? GIT_TIMEOUT_MS)\n const signal = options.signal === undefined ? deadline : AbortSignal.any([options.signal, deadline])\n const allowed = options.allowedExitCodes ?? [0]\n const program = await ctx.subprocess.resolveExecutable('git', undefined, signal)\n const argv = [program, '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', ...args]\n const child = ctx.subprocess.spawn({\n argv,\n cwd,\n env: {\n ...GIT_ENV,\n GIT_TERMINAL_PROMPT: '0',\n GIT_LFS_SKIP_SMUDGE: '1',\n GIT_NO_LAZY_FETCH: '1',\n },\n signal,\n graceMs: 1_500,\n stdio: {\n stdin: 'ignore',\n stdout: { maxBytes: MAX_OUTPUT_BYTES },\n stderr: { maxBytes: MAX_OUTPUT_BYTES },\n },\n })\n try {\n const outcome = await child.done\n const stdout = child.collected.stdout?.readFrom(0).text ?? ''\n const stderr = child.collected.stderr?.readFrom(0).text ?? ''\n const result: GitResult = { stdout, stderr, exitCode: outcome.exitCode ?? -1 }\n if (outcome.exitCode === null || !allowed.includes(outcome.exitCode)) throw new GitFailure(args, result)\n return result\n } finally {\n child.terminate()\n await child.waitForExit()\n }\n}\n\n/**\n * Resolve the repository root that contains `directory`.\n * @param ctx - Host context carrying the subprocess provider.\n * @param directory - absolute directory inside (or at) the checkout.\n * @param signal - caller cancellation.\n * @returns the canonical top-level directory Git reports.\n * @throws {GitFailure} when the directory is not inside a Git working tree.\n */\nexport async function repositoryRoot(ctx: Context, directory: string, signal?: AbortSignal): Promise<string> {\n const result = await runGit(ctx, directory, ['rev-parse', '--show-toplevel'], {\n signal,\n allowedExitCodes: [0, 128],\n })\n if (result.exitCode !== 0) throw new GitFailure(['rev-parse', '--show-toplevel'], result)\n return result.stdout.replace(/\\r?\\n$/, '')\n}\n\n/**\n * Resolve symlinks in a path that exists, falling back to the path as given.\n * @param path - absolute path.\n * @returns the canonical spelling when the path exists.\n */\nexport async function canonicalPath(path: string): Promise<string> {\n try {\n return await realpath(path)\n } catch {\n return path\n }\n}\n\n/** One checkout reported by `git worktree list --porcelain`. */\nexport interface WorktreeRecord {\n /** Absolute checkout directory. */\n readonly path: string\n /** Branch the checkout has checked out, when it has one. */\n readonly branch?: string | undefined\n}\n\n/**\n * Parse `git worktree list --porcelain` output into its records.\n * @param stdout - the command's stdout.\n * @returns one record per reported checkout, in Git's order.\n */\nexport function parseWorktreeList(stdout: string): WorktreeRecord[] {\n const records: WorktreeRecord[] = []\n let path: string | undefined\n let branch: string | undefined\n const flush = (): void => {\n if (path === undefined) return\n records.push(branch === undefined ? { path } : { path, branch })\n path = undefined\n branch = undefined\n }\n for (const line of stdout.split('\\n')) {\n if (line === '') {\n flush()\n continue\n }\n if (line.startsWith('worktree ')) path = line.slice('worktree '.length)\n else if (line.startsWith('branch refs/heads/')) branch = line.slice('branch refs/heads/'.length)\n }\n flush()\n return records\n}\n\n/**\n * List the worktree checkouts of one repository.\n * @param ctx - Host context carrying the subprocess provider.\n * @param root - repository root the command runs in.\n * @param signal - caller cancellation.\n * @returns every checkout Git reports for the repository.\n */\nexport async function listWorktreeRecords(\n ctx: Context,\n root: string,\n signal?: AbortSignal,\n): Promise<WorktreeRecord[]> {\n const result = await runGit(ctx, root, ['worktree', 'list', '--porcelain'], { signal })\n return parseWorktreeList(result.stdout)\n}\n\n/**\n * Count the changed and untracked files of one checkout.\n * @param ctx - Host context carrying the subprocess provider.\n * @param directory - checkout directory to inspect.\n * @param signal - caller cancellation.\n * @returns the number of `git status --porcelain` entries.\n */\nexport async function changedFileCount(\n ctx: Context,\n directory: string,\n signal?: AbortSignal,\n): Promise<number> {\n const result = await runGit(ctx, directory, ['status', '--porcelain', '--untracked-files=normal'], {\n signal,\n })\n return result.stdout.split('\\n').filter(line => line.trim() !== '').length\n}\n","/**\n * Names for the copy a session gets: lowercase words and a short suffix, so a\n * branch, a directory, a `git branch` row and this plugin's UI all read the\n * same. No prefix: the pool path already says where it lives.\n * @module dsh-worktree-switch/host/naming\n */\n\n/** First word of a generated name. */\nconst ADJECTIVES = [\n 'amber', 'brisk', 'calm', 'clever', 'crisp', 'eager', 'faint', 'fresh',\n 'gentle', 'happy', 'hollow', 'kind', 'lively', 'mellow', 'nimble', 'polite',\n 'quiet', 'rapid', 'ready', 'silent', 'soft', 'solid', 'steady', 'sunny',\n 'swift', 'tidy', 'vivid', 'warm', 'wise', 'young',\n] as const\n\n/** Second word of a generated name. */\nconst NOUNS = [\n 'anchor', 'atlas', 'badger', 'birch', 'brook', 'cedar', 'comet', 'coral',\n 'delta', 'ember', 'fern', 'field', 'finch', 'glade', 'harbor', 'heron',\n 'island', 'lark', 'ledge', 'maple', 'meadow', 'otter', 'pebble', 'pine',\n 'quartz', 'river', 'shore', 'stone', 'trail', 'willow',\n] as const\n\n/**\n * Generate one candidate worktree name.\n * @param random - uniform source in `[0, 1)`; injectable for deterministic tests.\n * @returns a lowercase, hyphen-joined branch-safe name such as `quiet-harbor-3fa9`.\n */\nexport function worktreeName(random: () => number = Math.random): string {\n const pick = (values: readonly string[]): string => {\n const index = Math.min(values.length - 1, Math.floor(random() * values.length))\n return values[index] ?? values[0] ?? 'copy'\n }\n const suffix = Math.min(0xffff, Math.floor(random() * 0x10000)).toString(16).padStart(4, '0')\n return `${pick(ADJECTIVES)}-${pick(NOUNS)}-${suffix}`\n}\n\n/** A pool entry name is exactly one path segment, as `git worktree` names it. */\nconst NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/\n\n/**\n * Validate a worktree name received from the browser before it reaches Git.\n * @param value - candidate name.\n * @returns true for one plain path segment that Git also accepts as a branch.\n */\nexport function isWorktreeName(value: string): boolean {\n return NAME_PATTERN.test(value) && !value.endsWith('.lock')\n}\n","/**\n * The plugin's host Remote face: the four verbs the browser half can call,\n * expressed against a live session and the checkouts on disk.\n *\n * This module is the seam the host tests drive directly. Everything below it —\n * Git, the worktree service, the sandbox policy it applies, and session\n * working-directory publication — belongs to the harness and is exercised by\n * its own tests.\n * @module dsh-worktree-switch/host/handlers\n */\n\nimport { lstat, stat } from 'node:fs/promises'\nimport { basename, isAbsolute, relative, resolve } from 'node:path'\nimport type { Context } from '@deepseek-ai/cordis'\nimport type { Agent } from '@deepseek-ai/dsh-agent'\nimport type { SessionId } from '@deepseek-ai/dsh-session'\nimport type {} from '@deepseek-ai/dsh-api-session-controller'\nimport type {} from '@deepseek-ai/dsh-experimental-worktree'\nimport type {} from '@deepseek-ai/dsh-working-directory'\nimport type {\n DiscardValue,\n ListValue,\n PrepareValue,\n StatusValue,\n WorktreeItem,\n WorktreeRef,\n} from '../types.ts'\nimport {\n canonicalPath,\n changedFileCount,\n GitFailure,\n listWorktreeRecords,\n repositoryRoot,\n runGit,\n} from './git.ts'\nimport { isWorktreeName, worktreeName } from './naming.ts'\n\n/** Attempts at a fresh name before a creation failure becomes an error. */\nconst CREATE_ATTEMPTS = 3\n\nexport type {\n DiscardValue,\n ListValue,\n PrepareValue,\n StatusValue,\n WorktreeItem,\n WorktreeRef,\n} from '../types.ts'\n\n/** A failed verb, carrying the stable code the browser half switches on. */\nexport class WorktreeToggleError extends Error {\n /**\n * @param code - stable failure code.\n * @param message - human-readable explanation.\n * @param details - extra machine-readable facts.\n */\n constructor(\n readonly code: string,\n message: string,\n readonly details: Readonly<Record<string, unknown>> = {},\n ) {\n super(message)\n this.name = 'WorktreeToggleError'\n }\n}\n\n/** Row config the verbs read. */\nexport interface WorktreeToggleOptions {\n /** Default for a project nothing has remembered yet. */\n readonly defaultEnabled: boolean\n}\n\n/** The host face the route layer exposes over the Connection fetch bridge. */\nexport interface WorktreeToggle {\n /**\n * Report whether the session's project can be isolated, and in what.\n * @param request - the calling session.\n * @param signal - caller cancellation.\n * @returns the status payload; `supported: false` is a normal answer.\n */\n status(request: { readonly sessionId: string }, signal?: AbortSignal): Promise<StatusValue>\n /**\n * Put the session into a fresh copy, creating it when necessary.\n * @param request - the calling session.\n * @param signal - caller cancellation.\n * @returns the created or already-present copy.\n * @throws {WorktreeToggleError} when the project cannot host a copy or Git refused.\n */\n prepare(request: { readonly sessionId: string }, signal?: AbortSignal): Promise<PrepareValue>\n /**\n * List the copies under one project's pool.\n * @param request - any directory inside the project.\n * @param signal - caller cancellation.\n * @returns the pool contents, or `supported: false` for a non-repository path.\n */\n list(request: { readonly path: string }, signal?: AbortSignal): Promise<ListValue>\n /**\n * Delete one copy and its branch.\n * @param request - a directory inside the project, and the copy's name.\n * @param signal - caller cancellation.\n * @returns the removed copy's identity.\n * @throws {WorktreeToggleError} when the copy is missing, dirty, or Git refused.\n */\n discard(\n request: { readonly path: string; readonly name: string },\n signal?: AbortSignal,\n ): Promise<DiscardValue>\n}\n\n/** Normalize a browser-supplied path before any filesystem work. */\nfunction absolutePath(value: string): string {\n const trimmed = value.trim()\n if (trimmed === '') throw new WorktreeToggleError('bad-request', 'a project path is required')\n return resolve(trimmed)\n}\n\n/**\n * The copy a session already runs in.\n *\n * A linked worktree is recognised from the checkout itself — its `.git` is a\n * file pointing back at the source repository, not a directory — rather than\n * from any configured pool path. The pool belongs to the harness worktree\n * service, so this plugin never needs a second copy of that setting: moving the\n * pool in the service's row config keeps every verb working.\n * @param current - the session's committed working directory.\n * @param repositoryRoot - canonical root of the project's repository.\n * @returns the copy's identity, or null when the directory is not one.\n */\nasync function worktreeAt(current: string, repositoryRoot: string): Promise<WorktreeRef | null> {\n const path = await canonicalPath(current)\n if (path === repositoryRoot) return null\n const inside = relative(repositoryRoot, path)\n if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) return null\n const name = basename(path)\n if (!isWorktreeName(name)) return null\n try {\n if (!(await lstat(`${path}/.git`)).isFile()) return null\n } catch {\n return null\n }\n return { name, path, branch: name }\n}\n\n/**\n * Build the host face.\n * @param ctx - Host context with `agents`, `worktrees`, `workingDirectory`, and `subprocess`.\n * @param options - resolved row config.\n * @returns the four verbs.\n */\nexport function createWorktreeToggle(ctx: Context, options: WorktreeToggleOptions): WorktreeToggle {\n /** One in-flight preparation per session, so a double mount cannot create twice. */\n const pending = new Map<string, Promise<PrepareValue>>()\n\n const sessionOf = async (sessionId: string, signal?: AbortSignal): Promise<Agent> => {\n signal?.throwIfAborted()\n if (sessionId === '') throw new WorktreeToggleError('bad-request', 'a session id is required')\n const controller = ctx.get('sessionController')\n if (controller !== undefined) {\n const resolved = await controller.resolveAgent(sessionId as SessionId)\n if ('error' in resolved) {\n throw new WorktreeToggleError(resolved.error.code, resolved.error.message, {\n sessionId,\n })\n }\n return resolved.agent\n }\n const agent = ctx.agents.get(sessionId as SessionId)\n if (agent === undefined) {\n throw new WorktreeToggleError('session-not-found', `no live agent for session ${sessionId}`, {\n sessionId,\n })\n }\n return agent\n }\n\n /**\n * Resolve the canonical root of the repository that contains a directory.\n * Git reports the spelling of the path it was given; the worktree service\n * publishes canonical paths, so both halves must agree on one spelling.\n * @param directory - absolute directory inside the project.\n * @param signal - caller cancellation.\n * @returns the canonical repository root.\n */\n const repositoryOf = async (directory: string, signal?: AbortSignal): Promise<string> =>\n canonicalPath(await repositoryRoot(ctx, directory, signal))\n\n const status = async (\n request: { readonly sessionId: string },\n signal?: AbortSignal,\n ): Promise<StatusValue> => {\n const agent = await sessionOf(request.sessionId, signal)\n // The immutable creation directory is the project anchor; the committed\n // working directory may already be a copy this session entered.\n const startDirectory = agent.session.header.cwd ?? ctx.workingDirectory.get(agent.session)\n let root: string\n try {\n root = await repositoryOf(startDirectory, signal)\n } catch (error) {\n if (error instanceof GitFailure) {\n return { supported: false, reason: 'not-a-git-repository', defaultEnabled: options.defaultEnabled }\n }\n throw error\n }\n const current = ctx.workingDirectory.get(agent.session)\n signal?.throwIfAborted()\n const sourceDirty = await changedFileCount(ctx, root, signal)\n return {\n supported: true,\n repositoryRoot: root,\n sessionWorktree: await worktreeAt(current, root),\n sourceDirty,\n defaultEnabled: options.defaultEnabled,\n }\n }\n\n const prepareOnce = async (\n request: { readonly sessionId: string },\n signal?: AbortSignal,\n ): Promise<PrepareValue> => {\n const agent = await sessionOf(request.sessionId, signal)\n const current = await status(request, signal)\n if (!current.supported) {\n throw new WorktreeToggleError(\n 'not-a-git-repository',\n 'the session project is not inside a Git working tree',\n )\n }\n if (current.sessionWorktree !== null) {\n return {\n state: 'existing',\n worktree: current.sessionWorktree,\n repositoryRoot: current.repositoryRoot,\n sourceDirty: current.sourceDirty,\n }\n }\n // Status anchors on the immutable creation directory, while the worktree\n // service creates relative to the session's CURRENT directory. Refuse to\n // create when the two name different repositories: a copy in the wrong\n // project would look like success and be worse than no copy.\n const working = ctx.workingDirectory.get(agent.session)\n const workingProject = await repositoryOf(working, signal)\n if (workingProject !== current.repositoryRoot) {\n throw new WorktreeToggleError(\n 'session-moved',\n `this session now works in ${workingProject}, not in its project ${current.repositoryRoot}`,\n { repositoryRoot: current.repositoryRoot, workingProject },\n )\n }\n let lastFailure: unknown\n for (let attempt = 0; attempt < CREATE_ATTEMPTS; attempt += 1) {\n signal?.throwIfAborted()\n const name = worktreeName()\n try {\n const created = await ctx.worktrees.create(agent, { name, from: 'HEAD' }, signal)\n return {\n state: 'created',\n worktree: { name: created.branch, path: created.path, branch: created.branch },\n repositoryRoot: created.repositoryRoot,\n sourceDirty: current.sourceDirty,\n }\n } catch (error) {\n lastFailure = error\n const message = error instanceof Error ? error.message : String(error)\n // A name collision is the one failure a fresh name can fix. Everything\n // else (no commits yet, branch or path exists, Git missing) is reported\n // as-is: the caller must not be told \"created\" after a silent retry.\n if (!/already exists|worktree branch already exists|worktree path already exists/i.test(message)) {\n throw new WorktreeToggleError('worktree-create-failed', message, { name })\n }\n }\n }\n throw new WorktreeToggleError(\n 'worktree-create-failed',\n `could not allocate a free worktree name after ${CREATE_ATTEMPTS} attempts`,\n { cause: lastFailure instanceof Error ? lastFailure.message : String(lastFailure) },\n )\n }\n\n const prepare = async (\n request: { readonly sessionId: string },\n signal?: AbortSignal,\n ): Promise<PrepareValue> => {\n const running = pending.get(request.sessionId)\n if (running !== undefined) return running\n const operation = prepareOnce(request, signal)\n pending.set(request.sessionId, operation)\n try {\n return await operation\n } finally {\n if (pending.get(request.sessionId) === operation) pending.delete(request.sessionId)\n }\n }\n\n const list = async (\n request: { readonly path: string },\n signal?: AbortSignal,\n ): Promise<ListValue> => {\n const directory = absolutePath(request.path)\n let root: string\n try {\n root = await repositoryOf(directory, signal)\n } catch (error) {\n if (error instanceof GitFailure) return { supported: false, reason: 'not-a-git-repository' }\n throw error\n }\n const records = await listWorktreeRecords(ctx, root, signal)\n const items: WorktreeItem[] = []\n for (const record of records) {\n signal?.throwIfAborted()\n const path = await canonicalPath(record.path)\n // The repository's own checkout is the source, never a copy.\n if (path === root) continue\n const name = basename(path)\n if (!isWorktreeName(name)) continue\n const branch = record.branch ?? name\n let createdAt = 0\n let prunable = true\n try {\n const info = await stat(path)\n createdAt = Math.trunc(info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs)\n prunable = false\n } catch {\n // The checkout directory is gone; Git still lists it until pruned.\n }\n const changedFiles = prunable ? 0 : await changedFileCount(ctx, path, signal)\n items.push({ name, path, branch, createdAt, changedFiles, prunable })\n }\n items.sort((left, right) => right.createdAt - left.createdAt || left.name.localeCompare(right.name))\n return { supported: true, repositoryRoot: root, items }\n }\n\n const discard = async (\n request: { readonly path: string; readonly name: string },\n signal?: AbortSignal,\n ): Promise<DiscardValue> => {\n const directory = absolutePath(request.path)\n if (!isWorktreeName(request.name)) {\n throw new WorktreeToggleError('worktree-name-invalid', `not a worktree name: ${request.name}`)\n }\n const root = await repositoryOf(directory, signal)\n const listed = await list({ path: directory }, signal)\n if (!listed.supported) {\n throw new WorktreeToggleError('not-a-git-repository', 'the project is not inside a Git working tree')\n }\n const item = listed.items.find(entry => entry.name === request.name)\n if (item === undefined) {\n throw new WorktreeToggleError(\n 'worktree-not-found',\n `no worktree named ${request.name} in ${root}`,\n { name: request.name, repositoryRoot: root },\n )\n }\n if (item.changedFiles > 0) {\n throw new WorktreeToggleError(\n 'worktree-dirty',\n `${item.name} has ${item.changedFiles} uncommitted change(s); commit or discard them first`,\n { name: item.name, changedFiles: item.changedFiles },\n )\n }\n if (item.prunable) {\n await runGit(ctx, root, ['worktree', 'prune'], { signal })\n } else {\n const removed = await runGit(ctx, root, ['worktree', 'remove', item.path], {\n signal,\n allowedExitCodes: [0, 128],\n })\n if (removed.exitCode !== 0) {\n throw new WorktreeToggleError(\n 'worktree-remove-failed',\n new GitFailure(['worktree', 'remove', item.path], removed).message,\n { name: item.name, path: item.path },\n )\n }\n }\n const deleted = await runGit(ctx, root, ['branch', '-D', '--', item.branch], {\n signal,\n allowedExitCodes: [0, 1],\n })\n // Status 1 with \"not found\" is the branch already being gone (removed by\n // hand between the read and the click); that is not a failed discard.\n const branchGone = deleted.exitCode === 1 && /not found/i.test(deleted.stderr)\n if (deleted.exitCode !== 0 && !branchGone) {\n throw new WorktreeToggleError(\n 'worktree-remove-failed',\n new GitFailure(['branch', '-D', item.branch], deleted).message,\n { name: item.name, branch: item.branch },\n )\n }\n return { removed: true, name: item.name, branch: item.branch, path: item.path }\n }\n\n return { status, prepare, list, discard }\n}\n","/**\n * The Connection fetch bridge: four exact POST routes on the shared `/api`\n * channel, one per verb, speaking the `client-request` / `server-response`\n * envelope the browser's `connection.rpc.call` already implements.\n *\n * Exact routes are the only shape an out-of-tree plugin can claim: `/api` is\n * the one logical channel the API Gateway owns, so `rpc.handle` refuses it and\n * a second `rpc.intercept` throws. Every route below answers owner peers only,\n * which is what the local Web GUI is.\n * @module dsh-worktree-switch/host/routes\n */\n\nimport type { Context } from '@deepseek-ai/cordis'\nimport type { ConnectionRpcResult } from '@deepseek-ai/dsh-client-connection'\nimport type {} from '@deepseek-ai/dsh-client-connection'\nimport type { DiscardValue, ListValue, PrepareValue, StatusValue, WorktreeToggle } from './handlers.ts'\nimport { WorktreeToggleError } from './handlers.ts'\n\n/** Absolute prefix every route below lives under. */\nexport const API_PREFIX = '/api/dsh-worktree-switch'\n\n/** Plugin id; the wire method prefix and the package name are the same string. */\nexport const PLUGIN_ID = 'dsh-worktree-switch'\n\n/** One route per verb. */\nexport const ENDPOINTS = ['status', 'prepare', 'list', 'discard'] as const\n\n/** A verb this route table serves. */\nexport type Endpoint = (typeof ENDPOINTS)[number]\n\n/** A decoded client request. */\ninterface ClientRequest {\n readonly rpcId: string\n readonly method: string\n readonly payload: Record<string, unknown>\n}\n\n/** A payload object with the named string field, or the failure to send back. */\nfunction requiredString(\n payload: Record<string, unknown>,\n field: string,\n): { readonly value: string } | { readonly failure: WorktreeToggleError } {\n const value = payload[field]\n if (typeof value !== 'string' || value.trim() === '') {\n return {\n failure: new WorktreeToggleError('bad-request', `payload.${field} must be a non-empty string`),\n }\n }\n return { value }\n}\n\n/**\n * Decode the browser's `client-request` envelope.\n * @param body - parsed JSON body.\n * @returns the request, or null when the envelope is malformed.\n */\nexport function readClientRequest(body: unknown): ClientRequest | null {\n if (body === null || typeof body !== 'object' || Array.isArray(body)) return null\n const record = body as Record<string, unknown>\n if (record.type !== 'client-request') return null\n const { rpcId, method } = record\n if (typeof rpcId !== 'string' || rpcId === '') return null\n if (typeof method !== 'string' || method === '') return null\n const payload = record.payload\n return {\n rpcId,\n method,\n payload: payload !== null && typeof payload === 'object' && !Array.isArray(payload)\n ? payload as Record<string, unknown>\n : {},\n }\n}\n\n/** A successful result. */\nfunction ok<T>(value: T): ConnectionRpcResult<T> {\n return { ok: true, value }\n}\n\n/** A failed result carrying the stable code. */\nfunction fail(code: string, message: string, details: Record<string, unknown> = {}): ConnectionRpcResult<never> {\n return { ok: false, error: { code, message, details } }\n}\n\n/**\n * Dispatch one decoded verb.\n * @param toggle - the host face.\n * @param endpoint - the verb.\n * @param payload - its decoded payload.\n * @param signal - request cancellation.\n * @returns the wire result.\n */\nexport async function dispatch(\n toggle: WorktreeToggle,\n endpoint: Endpoint,\n payload: Record<string, unknown>,\n signal: AbortSignal,\n): Promise<ConnectionRpcResult<StatusValue | PrepareValue | ListValue | DiscardValue>> {\n try {\n if (endpoint === 'status') {\n const sessionId = requiredString(payload, 'sessionId')\n if ('failure' in sessionId) throw sessionId.failure\n return ok(await toggle.status({ sessionId: sessionId.value }, signal))\n }\n if (endpoint === 'prepare') {\n const sessionId = requiredString(payload, 'sessionId')\n if ('failure' in sessionId) throw sessionId.failure\n return ok(await toggle.prepare({ sessionId: sessionId.value }, signal))\n }\n if (endpoint === 'list') {\n const path = requiredString(payload, 'path')\n if ('failure' in path) throw path.failure\n return ok(await toggle.list({ path: path.value }, signal))\n }\n const path = requiredString(payload, 'path')\n if ('failure' in path) throw path.failure\n const name = requiredString(payload, 'name')\n if ('failure' in name) throw name.failure\n return ok(await toggle.discard({ path: path.value, name: name.value }, signal))\n } catch (error) {\n if (error instanceof WorktreeToggleError) return fail(error.code, error.message, { ...error.details })\n return fail('internal', error instanceof Error ? error.message : String(error))\n }\n}\n\n/**\n * Register one exact route per verb on the shared `/api` channel.\n * @param ctx - Host context carrying `connection`.\n * @param toggle - the host face the routes call.\n */\nexport function registerWorktreeRoutes(ctx: Context, toggle: WorktreeToggle): void {\n for (const endpoint of ENDPOINTS) {\n ctx.effect(() => ctx.connection.fetch.register({\n path: `${API_PREFIX}/${endpoint}`,\n methods: ['POST'],\n requestBody: 'buffered',\n async fetch(request) {\n const contentType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()\n if (contentType !== 'application/json') {\n return new Response('content type must be application/json', { status: 415 })\n }\n let body: unknown\n try {\n body = await request.json()\n } catch {\n return new Response('body is not JSON', { status: 400 })\n }\n const message = readClientRequest(body)\n if (message === null) return new Response('invalid client-request message', { status: 400 })\n if (message.method !== `${PLUGIN_ID}/${endpoint}`) {\n return Response.json({\n type: 'server-response',\n rpcId: message.rpcId,\n result: fail('bad-request', 'RPC method does not match endpoint'),\n })\n }\n const result = await dispatch(toggle, endpoint, message.payload, request.signal)\n return Response.json({ type: 'server-response', rpcId: message.rpcId, result })\n },\n }), `${PLUGIN_ID} ${endpoint} route`)\n }\n}\n","/**\n * Host half of the Worktree switch.\n *\n * It mounts no service itself: the bundle patch inserts the harness worktree\n * service (`@deepseek-ai/dsh-experimental-worktree`, which provides\n * `ctx.worktrees`) and this plugin's own row. This plugin exposes four verbs\n * over the Connection fetch bridge and drives that service for creation; the\n * browser half renders the switch and the panel.\n * @module dsh-worktree-switch\n */\n\nimport type { Context } from '@deepseek-ai/cordis'\nimport Schema from '@deepseek-ai/schemastery'\nimport type {} from '@deepseek-ai/dsh-agent'\nimport type {} from '@deepseek-ai/dsh-experimental-worktree'\nimport type {} from '@deepseek-ai/dsh-subprocess'\nimport type {} from '@deepseek-ai/dsh-working-directory'\nimport { createWorktreeToggle } from './host/handlers.ts'\nimport { registerWorktreeRoutes } from './host/routes.ts'\n\n/** Plugin id: package name, bundle row id, browser module id. */\nexport const name = 'dsh-worktree-switch'\n\n/**\n * Services this plugin cannot work without.\n *\n * `worktrees` is provided by the row this bundle's patch inserts next to its\n * own, so Cordis holds this plugin until that row has mounted.\n */\nexport const inject = ['agents', 'connection', 'subprocess', 'worktrees', 'workingDirectory']\n\n/** Row config accepted from `cordis.patch.yml`. */\nexport interface Config {\n /** Default for a project the user has never switched either way. */\n defaultEnabled: boolean\n}\n\n/** Schemastery schema for {@link Config}. */\nexport const Config: Schema<Config> = Schema.object({\n defaultEnabled: Schema.boolean()\n .default(true)\n .description('Start a new session in a worktree copy until the user turns the switch off for that project.'),\n})\n\n/**\n * Mount the host half.\n * @param ctx - Host context.\n * @param config - validated row config.\n */\nexport function apply(ctx: Context, config: Config): void {\n const toggle = createWorktreeToggle(ctx, { defaultEnabled: config.defaultEnabled })\n registerWorktreeRoutes(ctx, toggle)\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/host/paths.ts","../src/host/guard.ts","../src/host/git.ts","../src/host/naming.ts","../src/host/handlers.ts","../src/host/routes.ts","../src/host/scope.ts","../src/index.ts"],"sourcesContent":["/**\n * Path predicates the host half shares between the verbs, the scope hint, and\n * the working-directory watcher.\n * @module dsh-worktree-switch/host/paths\n */\n\nimport { isAbsolute, relative } from 'node:path'\n\n/**\n * Whether one absolute directory is the path itself or an ancestor of it.\n * @param directory - candidate ancestor.\n * @param path - absolute path to test.\n * @returns true when `path` is `directory` or lives below it.\n */\nexport function contains(directory: string, path: string): boolean {\n const inside = relative(directory, path)\n return inside === '' || (!inside.startsWith('..') && !isAbsolute(inside))\n}\n","/**\n * The source-checkout guard: while a session runs in a worktree copy, shell\n * commands that would run Git against the project checkout are refused before\n * dispatch.\n *\n * The prompt context (`worktree-switch:scope`) explains where the workspace\n * is, but it cannot stop a model that still reaches for the checkout it can\n * see — and the sandbox deliberately grants writes there. This guard is the\n * enforcement half that needs no harness change: `tools/pre-execute` can deny\n * one call, and the model reads the reason as that call's error result.\n *\n * The pattern is deliberately narrow. Only a shell call that mentions `git`\n * is considered, and only when it names the project checkout either as an\n * explicit `workdir` or as a literal absolute path in the command text.\n * `cd ..`-style relative escapes and shell variables are not resolved; the\n * guard is a net, not the sandbox boundary.\n * @module dsh-worktree-switch/host/guard\n */\n\nimport { isAbsolute, resolve } from 'node:path'\nimport type { Context } from '@deepseek-ai/cordis'\nimport type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'\nimport type { SessionScope } from '../types.ts'\nimport { contains } from './paths.ts'\nimport type { SessionScopeRegistry } from './scope.ts'\n\n/** Tool names whose `command` argument is executed by a shell. */\nconst SHELL_TOOLS = new Set(['bash'])\n\n/** The arguments the guard reads from one shell call. */\ninterface ShellArguments {\n /** Command text as the model wrote it. */\n readonly command: string\n /** Explicit working directory, when the call carried one. */\n readonly workdir?: string\n}\n\n/**\n * Read a shell call's command and explicit directory, if this is one.\n * @param exec - the pending tool call.\n * @returns the fields the guard needs, or null for every other tool.\n */\nfunction shellArguments(exec: ToolExecution): ShellArguments | null {\n if (!SHELL_TOOLS.has(exec.name)) return null\n if (exec.arguments === null || typeof exec.arguments !== 'object') return null\n const args = exec.arguments as Record<string, unknown>\n const command = args['command']\n if (typeof command !== 'string' || command.trim() === '') return null\n const workdir = args['workdir']\n const explicit = typeof workdir === 'string' && workdir.trim() !== '' ? workdir : undefined\n return explicit === undefined ? { command } : { command, workdir: explicit }\n}\n\n/**\n * The model-facing denial for one shell command.\n * @param command - shell command text as the model wrote it.\n * @param workdir - explicit working directory, when the call carried one.\n * @param cwd - the session's committed working directory.\n * @param scope - the copy the session runs in.\n * @returns the denial reason, or null when the call may run.\n */\nexport function sourceCheckoutDenial(\n command: string,\n workdir: string | undefined,\n cwd: string,\n scope: SessionScope,\n): string | null {\n // Non-Git commands are out of scope: this guard is about the checkout's Git\n // state, not about every write the sandbox already allows.\n if (!/\\bgit\\b/.test(command)) return null\n const checkout = scope.worktree.path\n const project = scope.repositoryRoot\n if (workdir !== undefined) {\n const target = isAbsolute(workdir) ? workdir : resolve(cwd, workdir)\n if (contains(project, target) && !contains(checkout, target)) return denial(scope)\n }\n // The copy lives inside the project, so mention of the copy path must not\n // count as naming the checkout; strip it before looking for the project.\n if (command.split(checkout).join('').includes(project)) return denial(scope)\n return null\n}\n\n/** The one explanation every denial carries, including the way to get main. */\nfunction denial(scope: SessionScope): string {\n 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\\`).`\n}\n\n/**\n * Refuse shell calls that run Git against the project checkout while the\n * session is inside a copy.\n *\n * The registry is the same one the prompt context reads, so the guard is\n * active exactly while the model has been told it is in a copy.\n * @param ctx - host context carrying `workingDirectory` and the tool runtime.\n * @param registry - sessions' confirmed copies.\n */\nexport function installSourceCheckoutGuard(ctx: Context, registry: SessionScopeRegistry): void {\n ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {\n const agent = exec.agent\n if (agent === undefined) return next()\n const scope = registry.get(String(agent.session.id))\n if (scope === null) return next()\n const args = shellArguments(exec)\n if (args === null) return next()\n const denial = sourceCheckoutDenial(args.command, args.workdir, ctx.workingDirectory.get(agent.session), scope)\n return denial === null ? next() : { kind: 'deny', reason: denial }\n })\n}\n","/**\n * Unconfined Git calls behind the plugin's user-initiated verbs.\n *\n * Creation goes through `ctx.worktrees` (the harness service), which runs its\n * own Git under the calling session's sandbox policy. The list, status, and\n * discard verbs here belong to a person clicking in the UI rather than to a\n * model turn: they inspect and delete the copies the user can see, from a path\n * the same UI just listed. They therefore spawn Git directly, the same way the\n * installed `dsh-worktree-space` plugin does, instead of borrowing a session's\n * policy for an operation no session asked for.\n * @module dsh-worktree-switch/host/git\n */\n\nimport { realpath } from 'node:fs/promises'\nimport type { Context } from '@deepseek-ai/cordis'\nimport type {} from '@deepseek-ai/dsh-subprocess'\n\n/** Whole-command deadline for one Git call. */\nexport const GIT_TIMEOUT_MS = 20_000\n\n/** Per-stream capture cap; worktree listings and status lines are small. */\nconst MAX_OUTPUT_BYTES = 1_048_576\n\n/**\n * Clear inherited repository selectors and config so the command reads the\n * checkout named by `cwd`, never one inherited from the Host's environment.\n * One entry per key: `undefined` removes the variable for the child.\n */\nconst GIT_ENV: NodeJS.ProcessEnv = Object.fromEntries([\n 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY',\n 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_CONFIG', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT',\n 'GIT_CEILING_DIRECTORIES', 'GIT_PREFIX', 'GIT_SHALLOW_FILE', 'GIT_GRAFT_FILE',\n 'GIT_REPLACE_REF_BASE', 'GIT_ATTR_SOURCE',\n].map(key => [key, undefined]))\n\n/** One bounded Git invocation's captured result. */\nexport interface GitResult {\n /** Captured stdout. */\n readonly stdout: string\n /** Captured stderr. */\n readonly stderr: string\n /** Process exit status. */\n readonly exitCode: number\n}\n\n/** A Git invocation that exited with a status the caller did not allow. */\nexport class GitFailure extends Error {\n /**\n * @param args - arguments after the executable, for diagnostics.\n * @param result - the captured outcome.\n */\n constructor(readonly args: readonly string[], readonly result: GitResult) {\n const detail = result.stderr.trim() === '' ? result.stdout.trim() : result.stderr.trim()\n super(`git ${args.join(' ')} failed (exit ${result.exitCode})${detail === '' ? '' : `: ${detail}`}`)\n this.name = 'GitFailure'\n }\n}\n\n/** Options for {@link runGit}. */\nexport interface GitOptions {\n /** Caller cancellation, combined with the command deadline. */\n readonly signal?: AbortSignal | undefined\n /** Exit statuses that are not failures; defaults to `[0]`. */\n readonly allowedExitCodes?: readonly number[] | undefined\n /** Override for {@link GIT_TIMEOUT_MS}. */\n readonly timeoutMs?: number | undefined\n}\n\n/**\n * Run one Git command without a shell and await its termination.\n * @param ctx - Host context carrying the subprocess provider.\n * @param cwd - absolute directory the command runs in.\n * @param args - arguments after the executable.\n * @param options - cancellation, accepted exit statuses, and deadline.\n * @returns captured stdout, stderr, and exit status.\n * @throws {GitFailure} when the exit status is not allowed or the command was killed.\n */\nexport async function runGit(\n ctx: Context,\n cwd: string,\n args: readonly string[],\n options: GitOptions = {},\n): Promise<GitResult> {\n const deadline = AbortSignal.timeout(options.timeoutMs ?? GIT_TIMEOUT_MS)\n const signal = options.signal === undefined ? deadline : AbortSignal.any([options.signal, deadline])\n const allowed = options.allowedExitCodes ?? [0]\n const program = await ctx.subprocess.resolveExecutable('git', undefined, signal)\n const argv = [program, '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', ...args]\n const child = ctx.subprocess.spawn({\n argv,\n cwd,\n env: {\n ...GIT_ENV,\n GIT_TERMINAL_PROMPT: '0',\n GIT_LFS_SKIP_SMUDGE: '1',\n GIT_NO_LAZY_FETCH: '1',\n },\n signal,\n graceMs: 1_500,\n stdio: {\n stdin: 'ignore',\n stdout: { maxBytes: MAX_OUTPUT_BYTES },\n stderr: { maxBytes: MAX_OUTPUT_BYTES },\n },\n })\n try {\n const outcome = await child.done\n const stdout = child.collected.stdout?.readFrom(0).text ?? ''\n const stderr = child.collected.stderr?.readFrom(0).text ?? ''\n const result: GitResult = { stdout, stderr, exitCode: outcome.exitCode ?? -1 }\n if (outcome.exitCode === null || !allowed.includes(outcome.exitCode)) throw new GitFailure(args, result)\n return result\n } finally {\n child.terminate()\n await child.waitForExit()\n }\n}\n\n/**\n * Resolve the repository root that contains `directory`.\n * @param ctx - Host context carrying the subprocess provider.\n * @param directory - absolute directory inside (or at) the checkout.\n * @param signal - caller cancellation.\n * @returns the canonical top-level directory Git reports.\n * @throws {GitFailure} when the directory is not inside a Git working tree.\n */\nexport async function repositoryRoot(ctx: Context, directory: string, signal?: AbortSignal): Promise<string> {\n const result = await runGit(ctx, directory, ['rev-parse', '--show-toplevel'], {\n signal,\n allowedExitCodes: [0, 128],\n })\n if (result.exitCode !== 0) throw new GitFailure(['rev-parse', '--show-toplevel'], result)\n return result.stdout.replace(/\\r?\\n$/, '')\n}\n\n/**\n * Resolve symlinks in a path that exists, falling back to the path as given.\n * @param path - absolute path.\n * @returns the canonical spelling when the path exists.\n */\nexport async function canonicalPath(path: string): Promise<string> {\n try {\n return await realpath(path)\n } catch {\n return path\n }\n}\n\n/** One checkout reported by `git worktree list --porcelain`. */\nexport interface WorktreeRecord {\n /** Absolute checkout directory. */\n readonly path: string\n /** Branch the checkout has checked out, when it has one. */\n readonly branch?: string | undefined\n}\n\n/**\n * Parse `git worktree list --porcelain` output into its records.\n * @param stdout - the command's stdout.\n * @returns one record per reported checkout, in Git's order.\n */\nexport function parseWorktreeList(stdout: string): WorktreeRecord[] {\n const records: WorktreeRecord[] = []\n let path: string | undefined\n let branch: string | undefined\n const flush = (): void => {\n if (path === undefined) return\n records.push(branch === undefined ? { path } : { path, branch })\n path = undefined\n branch = undefined\n }\n for (const line of stdout.split('\\n')) {\n if (line === '') {\n flush()\n continue\n }\n if (line.startsWith('worktree ')) path = line.slice('worktree '.length)\n else if (line.startsWith('branch refs/heads/')) branch = line.slice('branch refs/heads/'.length)\n }\n flush()\n return records\n}\n\n/**\n * List the worktree checkouts of one repository.\n * @param ctx - Host context carrying the subprocess provider.\n * @param root - repository root the command runs in.\n * @param signal - caller cancellation.\n * @returns every checkout Git reports for the repository.\n */\nexport async function listWorktreeRecords(\n ctx: Context,\n root: string,\n signal?: AbortSignal,\n): Promise<WorktreeRecord[]> {\n const result = await runGit(ctx, root, ['worktree', 'list', '--porcelain'], { signal })\n return parseWorktreeList(result.stdout)\n}\n\n/**\n * Count the changed and untracked files of one checkout.\n * @param ctx - Host context carrying the subprocess provider.\n * @param directory - checkout directory to inspect.\n * @param signal - caller cancellation.\n * @returns the number of `git status --porcelain` entries.\n */\nexport async function changedFileCount(\n ctx: Context,\n directory: string,\n signal?: AbortSignal,\n): Promise<number> {\n const result = await runGit(ctx, directory, ['status', '--porcelain', '--untracked-files=normal'], {\n signal,\n })\n return result.stdout.split('\\n').filter(line => line.trim() !== '').length\n}\n","/**\n * Names for the copy a session gets: lowercase words and a short suffix, so a\n * branch, a directory, a `git branch` row and this plugin's UI all read the\n * same. No prefix: the pool path already says where it lives.\n * @module dsh-worktree-switch/host/naming\n */\n\n/** First word of a generated name. */\nconst ADJECTIVES = [\n 'amber', 'brisk', 'calm', 'clever', 'crisp', 'eager', 'faint', 'fresh',\n 'gentle', 'happy', 'hollow', 'kind', 'lively', 'mellow', 'nimble', 'polite',\n 'quiet', 'rapid', 'ready', 'silent', 'soft', 'solid', 'steady', 'sunny',\n 'swift', 'tidy', 'vivid', 'warm', 'wise', 'young',\n] as const\n\n/** Second word of a generated name. */\nconst NOUNS = [\n 'anchor', 'atlas', 'badger', 'birch', 'brook', 'cedar', 'comet', 'coral',\n 'delta', 'ember', 'fern', 'field', 'finch', 'glade', 'harbor', 'heron',\n 'island', 'lark', 'ledge', 'maple', 'meadow', 'otter', 'pebble', 'pine',\n 'quartz', 'river', 'shore', 'stone', 'trail', 'willow',\n] as const\n\n/**\n * Generate one candidate worktree name.\n * @param random - uniform source in `[0, 1)`; injectable for deterministic tests.\n * @returns a lowercase, hyphen-joined branch-safe name such as `quiet-harbor-3fa9`.\n */\nexport function worktreeName(random: () => number = Math.random): string {\n const pick = (values: readonly string[]): string => {\n const index = Math.min(values.length - 1, Math.floor(random() * values.length))\n return values[index] ?? values[0] ?? 'copy'\n }\n const suffix = Math.min(0xffff, Math.floor(random() * 0x10000)).toString(16).padStart(4, '0')\n return `${pick(ADJECTIVES)}-${pick(NOUNS)}-${suffix}`\n}\n\n/** A pool entry name is exactly one path segment, as `git worktree` names it. */\nconst NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/\n\n/**\n * Validate a worktree name received from the browser before it reaches Git.\n * @param value - candidate name.\n * @returns true for one plain path segment that Git also accepts as a branch.\n */\nexport function isWorktreeName(value: string): boolean {\n return NAME_PATTERN.test(value) && !value.endsWith('.lock')\n}\n","/**\n * The plugin's host Remote face: the five verbs the browser half can call,\n * expressed against a live session and the checkouts on disk.\n *\n * This module is the seam the host tests drive directly. Everything below it —\n * Git, the worktree service, the sandbox policy it applies, and session\n * working-directory publication — belongs to the harness and is exercised by\n * its own tests.\n * @module dsh-worktree-switch/host/handlers\n */\n\nimport { lstat, stat } from 'node:fs/promises'\nimport { basename, dirname, isAbsolute, relative, resolve } from 'node:path'\nimport type { Context } from '@deepseek-ai/cordis'\nimport type { Agent } from '@deepseek-ai/dsh-agent'\nimport type { SessionId } from '@deepseek-ai/dsh-session'\nimport type {} from '@deepseek-ai/dsh-api-session-controller'\nimport type {} from '@deepseek-ai/dsh-experimental-worktree'\nimport type {} from '@deepseek-ai/dsh-working-directory'\nimport type {\n DiscardValue,\n ListValue,\n PrepareValue,\n ReleaseValue,\n SessionScope,\n StatusValue,\n WorktreeItem,\n WorktreeRef,\n} from '../types.ts'\nimport {\n canonicalPath,\n changedFileCount,\n GitFailure,\n listWorktreeRecords,\n repositoryRoot,\n runGit,\n} from './git.ts'\nimport { isWorktreeName, worktreeName } from './naming.ts'\nimport { contains } from './paths.ts'\n\n/** Attempts at a fresh name before a creation failure becomes an error. */\nconst CREATE_ATTEMPTS = 3\n\nexport type {\n DiscardValue,\n ListValue,\n PrepareValue,\n ReleaseValue,\n SessionScope,\n StatusValue,\n WorktreeItem,\n WorktreeRef,\n} from '../types.ts'\n\n/** A failed verb, carrying the stable code the browser half switches on. */\nexport class WorktreeToggleError extends Error {\n /**\n * @param code - stable failure code.\n * @param message - human-readable explanation.\n * @param details - extra machine-readable facts.\n */\n constructor(\n readonly code: string,\n message: string,\n readonly details: Readonly<Record<string, unknown>> = {},\n ) {\n super(message)\n this.name = 'WorktreeToggleError'\n }\n}\n\n/** Row config the verbs read. */\nexport interface WorktreeToggleOptions {\n /** Default for a project nothing has remembered yet. */\n readonly defaultEnabled: boolean\n /**\n * Report what each verb learned about the session's copy, so the model-facing\n * scope hint can follow it without a second Git walk.\n * @param sessionId - wire session identity.\n * @param scope - the confirmed copy, or null when the session is not in one.\n */\n readonly onScope?: (sessionId: string, scope: SessionScope | null) => void\n}\n\n/** The host face the route layer exposes over the Connection fetch bridge. */\nexport interface WorktreeToggle {\n /**\n * Report whether the session's project can be isolated, and in what.\n * @param request - the calling session.\n * @param signal - caller cancellation.\n * @returns the status payload; `supported: false` is a normal answer.\n */\n status(request: { readonly sessionId: string }, signal?: AbortSignal): Promise<StatusValue>\n /**\n * Put the session into a fresh copy, creating it when necessary.\n * @param request - the calling session.\n * @param signal - caller cancellation.\n * @returns the created or already-present copy.\n * @throws {WorktreeToggleError} when the project cannot host a copy or Git refused.\n */\n prepare(request: { readonly sessionId: string }, signal?: AbortSignal): Promise<PrepareValue>\n /**\n * Put the session back in its project directory, undoing a copy the switch\n * created for it when it never became the conversation's workspace.\n * @param request - the calling session.\n * @param signal - caller cancellation.\n * @returns the directory the session now runs in, the copy removed, and the\n * copy left on disk when deleting it was not safe.\n * @throws {WorktreeToggleError} when the project cannot be resolved or the\n * session could not be moved back.\n */\n release(request: { readonly sessionId: string }, signal?: AbortSignal): Promise<ReleaseValue>\n /**\n * List the copies under one project's pool.\n * @param request - any directory inside the project.\n * @param signal - caller cancellation.\n * @returns the pool contents, or `supported: false` for a non-repository path.\n */\n list(request: { readonly path: string }, signal?: AbortSignal): Promise<ListValue>\n /**\n * Delete one copy and its branch.\n * @param request - a directory inside the project, and the copy's name.\n * @param signal - caller cancellation.\n * @returns the removed copy's identity.\n * @throws {WorktreeToggleError} when the copy is missing, dirty, or Git refused.\n */\n discard(\n request: { readonly path: string; readonly name: string },\n signal?: AbortSignal,\n ): Promise<DiscardValue>\n}\n\n/** Normalize a browser-supplied path before any filesystem work. */\nfunction absolutePath(value: string): string {\n const trimmed = value.trim()\n if (trimmed === '') throw new WorktreeToggleError('bad-request', 'a project path is required')\n return resolve(trimmed)\n}\n\n/**\n * The copy a session already runs in.\n *\n * A linked worktree is recognised from the checkout itself — its `.git` is a\n * file pointing back at the source repository, not a directory — rather than\n * from any configured pool path. The pool belongs to the harness worktree\n * service, so this plugin never needs a second copy of that setting: moving the\n * pool in the service's row config keeps every verb working.\n * @param current - the session's committed working directory.\n * @param repositoryRoot - canonical root of the project's repository.\n * @returns the copy's identity, or null when the directory is not one.\n */\nasync function worktreeAt(current: string, repositoryRoot: string): Promise<WorktreeRef | null> {\n const path = await canonicalPath(current)\n if (path === repositoryRoot) return null\n const inside = relative(repositoryRoot, path)\n if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) return null\n const name = basename(path)\n if (!isWorktreeName(name)) return null\n try {\n if (!(await lstat(`${path}/.git`)).isFile()) return null\n } catch {\n return null\n }\n return { name, path, branch: name }\n}\n\n/**\n * Resolve the canonical root of the repository that contains a directory.\n * Git reports the spelling of the path it was given; the worktree service\n * publishes canonical paths, so both halves must agree on one spelling.\n * @param ctx - host context carrying `subprocess`.\n * @param directory - absolute directory inside the project.\n * @param signal - caller cancellation.\n * @returns the canonical repository root.\n */\nasync function repositoryOf(ctx: Context, directory: string, signal?: AbortSignal): Promise<string> {\n return canonicalPath(await repositoryRoot(ctx, directory, signal))\n}\n\n/**\n * Re-derive the copy one session runs in from its committed directory.\n *\n * A copy's root is the only linked checkout whose name matches the pool\n * convention, but a session may have changed into a directory *inside* the copy,\n * so ancestors are probed from the committed directory up to the project\n * anchor.\n * @param ctx - host context carrying `workingDirectory` and `subprocess`.\n * @param agent - live session owner.\n * @param signal - caller cancellation.\n * @returns the copy and project root, or null when the session is not in one.\n */\nexport async function detectSessionScope(\n ctx: Context,\n agent: Agent,\n signal?: AbortSignal,\n): Promise<SessionScope | null> {\n const current = ctx.workingDirectory.get(agent.session)\n const anchor = agent.session.header.cwd ?? current\n if (current === anchor || !contains(anchor, current)) return null\n let root: string\n try {\n root = await repositoryOf(ctx, anchor, signal)\n } catch {\n return null\n }\n signal?.throwIfAborted()\n let probe = current\n for (;;) {\n const copy = await worktreeAt(probe, root)\n if (copy !== null) return { worktree: copy, repositoryRoot: root }\n const parent = dirname(probe)\n if (probe === root || probe === anchor || parent === probe) return null\n probe = parent\n }\n}\n\n/**\n * Build the host face.\n * @param ctx - Host context with `agents`, `worktrees`, `workingDirectory`, and `subprocess`.\n * @param options - resolved row config.\n * @returns the five verbs.\n */\nexport function createWorktreeToggle(ctx: Context, options: WorktreeToggleOptions): WorktreeToggle {\n /** One in-flight preparation per session, so a double mount cannot create twice. */\n const pending = new Map<string, Promise<PrepareValue>>()\n /**\n * Copies this host instance created for a session, by session id. Turning the\n * switch off may only delete what the switch itself just made: a copy that\n * existed before this instance started is left for the panel's explicit\n * discard, because it may carry work the switch never saw.\n */\n const createdBySession = new Map<string, WorktreeRef>()\n\n const sessionOf = async (sessionId: string, signal?: AbortSignal): Promise<Agent> => {\n signal?.throwIfAborted()\n if (sessionId === '') throw new WorktreeToggleError('bad-request', 'a session id is required')\n const controller = ctx.get('sessionController')\n if (controller !== undefined) {\n const resolved = await controller.resolveAgent(sessionId as SessionId)\n if ('error' in resolved) {\n throw new WorktreeToggleError(resolved.error.code, resolved.error.message, {\n sessionId,\n })\n }\n return resolved.agent\n }\n const agent = ctx.agents.get(sessionId as SessionId)\n if (agent === undefined) {\n throw new WorktreeToggleError('session-not-found', `no live agent for session ${sessionId}`, {\n sessionId,\n })\n }\n return agent\n }\n\n const status = async (\n request: { readonly sessionId: string },\n signal?: AbortSignal,\n ): Promise<StatusValue> => {\n const agent = await sessionOf(request.sessionId, signal)\n // The immutable creation directory is the project anchor; the committed\n // working directory may already be a copy this session entered.\n const startDirectory = agent.session.header.cwd ?? ctx.workingDirectory.get(agent.session)\n let root: string\n try {\n root = await repositoryOf(ctx, startDirectory, signal)\n } catch (error) {\n if (error instanceof GitFailure) {\n return { supported: false, reason: 'not-a-git-repository', defaultEnabled: options.defaultEnabled }\n }\n throw error\n }\n const current = ctx.workingDirectory.get(agent.session)\n signal?.throwIfAborted()\n const sourceDirty = await changedFileCount(ctx, root, signal)\n const copy = await worktreeAt(current, root)\n options.onScope?.(request.sessionId, copy === null ? null : { worktree: copy, repositoryRoot: root })\n return {\n supported: true,\n repositoryRoot: root,\n sessionWorktree: copy,\n sourceDirty,\n defaultEnabled: options.defaultEnabled,\n }\n }\n\n const prepareOnce = async (\n request: { readonly sessionId: string },\n signal?: AbortSignal,\n ): Promise<PrepareValue> => {\n const agent = await sessionOf(request.sessionId, signal)\n const current = await status(request, signal)\n if (!current.supported) {\n throw new WorktreeToggleError(\n 'not-a-git-repository',\n 'the session project is not inside a Git working tree',\n )\n }\n if (current.sessionWorktree !== null) {\n options.onScope?.(request.sessionId, {\n worktree: current.sessionWorktree,\n repositoryRoot: current.repositoryRoot,\n })\n return {\n state: 'existing',\n worktree: current.sessionWorktree,\n repositoryRoot: current.repositoryRoot,\n sourceDirty: current.sourceDirty,\n }\n }\n // Status anchors on the immutable creation directory, while the worktree\n // service creates relative to the session's CURRENT directory. Refuse to\n // create when the two name different repositories: a copy in the wrong\n // project would look like success and be worse than no copy.\n const working = ctx.workingDirectory.get(agent.session)\n const workingProject = await repositoryOf(ctx, working, signal)\n if (workingProject !== current.repositoryRoot) {\n throw new WorktreeToggleError(\n 'session-moved',\n `this session now works in ${workingProject}, not in its project ${current.repositoryRoot}`,\n { repositoryRoot: current.repositoryRoot, workingProject },\n )\n }\n let lastFailure: unknown\n for (let attempt = 0; attempt < CREATE_ATTEMPTS; attempt += 1) {\n signal?.throwIfAborted()\n const name = worktreeName()\n try {\n const created = await ctx.worktrees.create(agent, { name, from: 'HEAD' }, signal)\n const worktree: WorktreeRef = { name: created.branch, path: created.path, branch: created.branch }\n createdBySession.set(request.sessionId, worktree)\n options.onScope?.(request.sessionId, { worktree, repositoryRoot: created.repositoryRoot })\n return {\n state: 'created',\n worktree,\n repositoryRoot: created.repositoryRoot,\n sourceDirty: current.sourceDirty,\n }\n } catch (error) {\n lastFailure = error\n const message = error instanceof Error ? error.message : String(error)\n // A name collision is the one failure a fresh name can fix. Everything\n // else (no commits yet, branch or path exists, Git missing) is reported\n // as-is: the caller must not be told \"created\" after a silent retry.\n if (!/already exists|worktree branch already exists|worktree path already exists/i.test(message)) {\n throw new WorktreeToggleError('worktree-create-failed', message, { name })\n }\n }\n }\n throw new WorktreeToggleError(\n 'worktree-create-failed',\n `could not allocate a free worktree name after ${CREATE_ATTEMPTS} attempts`,\n { cause: lastFailure instanceof Error ? lastFailure.message : String(lastFailure) },\n )\n }\n\n const prepare = async (\n request: { readonly sessionId: string },\n signal?: AbortSignal,\n ): Promise<PrepareValue> => {\n const running = pending.get(request.sessionId)\n if (running !== undefined) return running\n const operation = prepareOnce(request, signal)\n pending.set(request.sessionId, operation)\n try {\n return await operation\n } finally {\n if (pending.get(request.sessionId) === operation) pending.delete(request.sessionId)\n }\n }\n\n const list = async (\n request: { readonly path: string },\n signal?: AbortSignal,\n ): Promise<ListValue> => {\n const directory = absolutePath(request.path)\n let root: string\n try {\n root = await repositoryOf(ctx, directory, signal)\n } catch (error) {\n if (error instanceof GitFailure) return { supported: false, reason: 'not-a-git-repository' }\n throw error\n }\n const records = await listWorktreeRecords(ctx, root, signal)\n const items: WorktreeItem[] = []\n for (const record of records) {\n signal?.throwIfAborted()\n const path = await canonicalPath(record.path)\n // The repository's own checkout is the source, never a copy.\n if (path === root) continue\n const name = basename(path)\n if (!isWorktreeName(name)) continue\n const branch = record.branch ?? name\n let createdAt = 0\n let prunable = true\n try {\n const info = await stat(path)\n createdAt = Math.trunc(info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs)\n prunable = false\n } catch {\n // The checkout directory is gone; Git still lists it until pruned.\n }\n const changedFiles = prunable ? 0 : await changedFileCount(ctx, path, signal)\n items.push({ name, path, branch, createdAt, changedFiles, prunable })\n }\n items.sort((left, right) => right.createdAt - left.createdAt || left.name.localeCompare(right.name))\n return { supported: true, repositoryRoot: root, items }\n }\n\n /**\n * Delete one clean copy and its branch. Both the panel's discard and the\n * switch's own undo go through here, so the dirty guard has one home.\n * @param root - canonical repository root.\n * @param name - copy name; already validated by the caller.\n * @param signal - caller cancellation.\n * @returns the removed copy's identity.\n */\n const removeCopy = async (root: string, name: string, signal?: AbortSignal): Promise<DiscardValue> => {\n const listed = await list({ path: root }, signal)\n if (!listed.supported) {\n throw new WorktreeToggleError('not-a-git-repository', 'the project is not inside a Git working tree')\n }\n const item = listed.items.find(entry => entry.name === name)\n if (item === undefined) {\n throw new WorktreeToggleError(\n 'worktree-not-found',\n `no worktree named ${name} in ${root}`,\n { name, repositoryRoot: root },\n )\n }\n if (item.changedFiles > 0) {\n throw new WorktreeToggleError(\n 'worktree-dirty',\n `${item.name} has ${item.changedFiles} uncommitted change(s); commit or discard them first`,\n { name: item.name, changedFiles: item.changedFiles },\n )\n }\n if (item.prunable) {\n await runGit(ctx, root, ['worktree', 'prune'], { signal })\n } else {\n const removed = await runGit(ctx, root, ['worktree', 'remove', item.path], {\n signal,\n allowedExitCodes: [0, 128],\n })\n if (removed.exitCode !== 0) {\n throw new WorktreeToggleError(\n 'worktree-remove-failed',\n new GitFailure(['worktree', 'remove', item.path], removed).message,\n { name: item.name, path: item.path },\n )\n }\n }\n const deleted = await runGit(ctx, root, ['branch', '-D', '--', item.branch], {\n signal,\n allowedExitCodes: [0, 1],\n })\n // Status 1 with \"not found\" is the branch already being gone (removed by\n // hand between the read and the click); that is not a failed discard.\n const branchGone = deleted.exitCode === 1 && /not found/i.test(deleted.stderr)\n if (deleted.exitCode !== 0 && !branchGone) {\n throw new WorktreeToggleError(\n 'worktree-remove-failed',\n new GitFailure(['branch', '-D', item.branch], deleted).message,\n { name: item.name, branch: item.branch },\n )\n }\n for (const [sessionId, copy] of createdBySession) {\n if (copy.path === item.path) createdBySession.delete(sessionId)\n }\n return { removed: true, name: item.name, branch: item.branch, path: item.path }\n }\n\n const discard = async (\n request: { readonly path: string; readonly name: string },\n signal?: AbortSignal,\n ): Promise<DiscardValue> => {\n const directory = absolutePath(request.path)\n if (!isWorktreeName(request.name)) {\n throw new WorktreeToggleError('worktree-name-invalid', `not a worktree name: ${request.name}`)\n }\n const root = await repositoryOf(ctx, directory, signal)\n return await removeCopy(root, request.name, signal)\n }\n\n const release = async (\n request: { readonly sessionId: string },\n signal?: AbortSignal,\n ): Promise<ReleaseValue> => {\n // A create may still be in flight for this session: undo after it settles,\n // so a switch turned off mid-creation cannot race the copy into existence.\n const running = pending.get(request.sessionId)\n if (running !== undefined) {\n try {\n await running\n } catch {\n // Creation failed and already moved nothing; there is nothing to undo.\n }\n }\n signal?.throwIfAborted()\n const agent = await sessionOf(request.sessionId, signal)\n const current = ctx.workingDirectory.get(agent.session)\n // The immutable creation directory anchors the project, exactly as status\n // does: the committed directory may already be a copy this session entered.\n const anchor = agent.session.header.cwd ?? current\n let root: string\n try {\n root = await repositoryOf(ctx, anchor, signal)\n } catch (error) {\n if (error instanceof GitFailure) {\n throw new WorktreeToggleError(\n 'not-a-git-repository',\n 'the session project is not inside a Git working tree',\n )\n }\n throw error\n }\n const listed = await list({ path: anchor }, signal)\n if (!listed.supported) return { directory: current, removed: null, kept: null }\n const path = await canonicalPath(current)\n const item = listed.items.find(entry => contains(entry.path, path))\n if (item === undefined) {\n options.onScope?.(request.sessionId, null)\n return { directory: current, removed: null, kept: null }\n }\n const copy: WorktreeRef = { name: item.name, path: item.path, branch: item.branch }\n signal?.throwIfAborted()\n let directory: string\n try {\n directory = await ctx.workingDirectory.set(agent, anchor, signal)\n } catch (error) {\n throw new WorktreeToggleError(\n 'release-failed',\n `could not move the session back to ${anchor}: ${error instanceof Error ? error.message : String(error)}`,\n { name: copy.name, path: copy.path },\n )\n }\n options.onScope?.(request.sessionId, null)\n const mine = createdBySession.get(request.sessionId)\n if (mine === undefined || mine.path !== copy.path) {\n // Not the switch's own copy: leave it, the panel can discard it by hand.\n return { directory, removed: null, kept: copy }\n }\n try {\n const removed = await removeCopy(root, copy.name, signal)\n return {\n directory,\n removed: { name: removed.name, path: removed.path, branch: removed.branch },\n kept: null,\n }\n } catch (error) {\n if (signal?.aborted === true) throw error\n // Dirty or refused by Git: the session is still back in the project, so\n // the switch keeps its promise; the copy stays for the panel to handle.\n return { directory, removed: null, kept: copy }\n }\n }\n\n return { status, prepare, release, list, discard }\n}\n","/**\n * The Connection fetch bridge: five exact POST routes on the shared `/api`\n * channel, one per verb, speaking the `client-request` / `server-response`\n * envelope the browser's `connection.rpc.call` already implements.\n *\n * Exact routes are the only shape an out-of-tree plugin can claim: `/api` is\n * the one logical channel the API Gateway owns, so `rpc.handle` refuses it and\n * a second `rpc.intercept` throws. Every route below answers owner peers only,\n * which is what the local Web GUI is.\n * @module dsh-worktree-switch/host/routes\n */\n\nimport type { Context } from '@deepseek-ai/cordis'\nimport type { ConnectionRpcResult } from '@deepseek-ai/dsh-client-connection'\nimport type {} from '@deepseek-ai/dsh-client-connection'\nimport type { DiscardValue, ListValue, PrepareValue, ReleaseValue, StatusValue, WorktreeToggle } from './handlers.ts'\nimport { WorktreeToggleError } from './handlers.ts'\n\n/** Absolute prefix every route below lives under. */\nexport const API_PREFIX = '/api/dsh-worktree-switch'\n\n/** Plugin id; the wire method prefix and the package name are the same string. */\nexport const PLUGIN_ID = 'dsh-worktree-switch'\n\n/** One route per verb. */\nexport const ENDPOINTS = ['status', 'prepare', 'release', 'list', 'discard'] as const\n\n/** A verb this route table serves. */\nexport type Endpoint = (typeof ENDPOINTS)[number]\n\n/** A decoded client request. */\ninterface ClientRequest {\n readonly rpcId: string\n readonly method: string\n readonly payload: Record<string, unknown>\n}\n\n/** A payload object with the named string field, or the failure to send back. */\nfunction requiredString(\n payload: Record<string, unknown>,\n field: string,\n): { readonly value: string } | { readonly failure: WorktreeToggleError } {\n const value = payload[field]\n if (typeof value !== 'string' || value.trim() === '') {\n return {\n failure: new WorktreeToggleError('bad-request', `payload.${field} must be a non-empty string`),\n }\n }\n return { value }\n}\n\n/**\n * Decode the browser's `client-request` envelope.\n * @param body - parsed JSON body.\n * @returns the request, or null when the envelope is malformed.\n */\nexport function readClientRequest(body: unknown): ClientRequest | null {\n if (body === null || typeof body !== 'object' || Array.isArray(body)) return null\n const record = body as Record<string, unknown>\n if (record.type !== 'client-request') return null\n const { rpcId, method } = record\n if (typeof rpcId !== 'string' || rpcId === '') return null\n if (typeof method !== 'string' || method === '') return null\n const payload = record.payload\n return {\n rpcId,\n method,\n payload: payload !== null && typeof payload === 'object' && !Array.isArray(payload)\n ? payload as Record<string, unknown>\n : {},\n }\n}\n\n/** A successful result. */\nfunction ok<T>(value: T): ConnectionRpcResult<T> {\n return { ok: true, value }\n}\n\n/** A failed result carrying the stable code. */\nfunction fail(code: string, message: string, details: Record<string, unknown> = {}): ConnectionRpcResult<never> {\n return { ok: false, error: { code, message, details } }\n}\n\n/**\n * Dispatch one decoded verb.\n * @param toggle - the host face.\n * @param endpoint - the verb.\n * @param payload - its decoded payload.\n * @param signal - request cancellation.\n * @returns the wire result.\n */\nexport async function dispatch(\n toggle: WorktreeToggle,\n endpoint: Endpoint,\n payload: Record<string, unknown>,\n signal: AbortSignal,\n): Promise<ConnectionRpcResult<StatusValue | PrepareValue | ReleaseValue | ListValue | DiscardValue>> {\n try {\n if (endpoint === 'status') {\n const sessionId = requiredString(payload, 'sessionId')\n if ('failure' in sessionId) throw sessionId.failure\n return ok(await toggle.status({ sessionId: sessionId.value }, signal))\n }\n if (endpoint === 'prepare') {\n const sessionId = requiredString(payload, 'sessionId')\n if ('failure' in sessionId) throw sessionId.failure\n return ok(await toggle.prepare({ sessionId: sessionId.value }, signal))\n }\n if (endpoint === 'release') {\n const sessionId = requiredString(payload, 'sessionId')\n if ('failure' in sessionId) throw sessionId.failure\n return ok(await toggle.release({ sessionId: sessionId.value }, signal))\n }\n if (endpoint === 'list') {\n const path = requiredString(payload, 'path')\n if ('failure' in path) throw path.failure\n return ok(await toggle.list({ path: path.value }, signal))\n }\n const path = requiredString(payload, 'path')\n if ('failure' in path) throw path.failure\n const name = requiredString(payload, 'name')\n if ('failure' in name) throw name.failure\n return ok(await toggle.discard({ path: path.value, name: name.value }, signal))\n } catch (error) {\n if (error instanceof WorktreeToggleError) return fail(error.code, error.message, { ...error.details })\n return fail('internal', error instanceof Error ? error.message : String(error))\n }\n}\n\n/**\n * Register one exact route per verb on the shared `/api` channel.\n * @param ctx - Host context carrying `connection`.\n * @param toggle - the host face the routes call.\n */\nexport function registerWorktreeRoutes(ctx: Context, toggle: WorktreeToggle): void {\n for (const endpoint of ENDPOINTS) {\n ctx.effect(() => ctx.connection.fetch.register({\n path: `${API_PREFIX}/${endpoint}`,\n methods: ['POST'],\n requestBody: 'buffered',\n async fetch(request) {\n const contentType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()\n if (contentType !== 'application/json') {\n return new Response('content type must be application/json', { status: 415 })\n }\n let body: unknown\n try {\n body = await request.json()\n } catch {\n return new Response('body is not JSON', { status: 400 })\n }\n const message = readClientRequest(body)\n if (message === null) return new Response('invalid client-request message', { status: 400 })\n if (message.method !== `${PLUGIN_ID}/${endpoint}`) {\n return Response.json({\n type: 'server-response',\n rpcId: message.rpcId,\n result: fail('bad-request', 'RPC method does not match endpoint'),\n })\n }\n const result = await dispatch(toggle, endpoint, message.payload, request.signal)\n return Response.json({ type: 'server-response', rpcId: message.rpcId, result })\n },\n }), `${PLUGIN_ID} ${endpoint} route`)\n }\n}\n","/**\n * The copy a session is confirmed to run in, plus the one line the model reads\n * about it.\n *\n * The working-directory service already tells the model *where* it is. This\n * module answers the question that line does not: which checkout is the\n * session's workspace and which one is merely the project it was copied from.\n * The registry is fed by three sources — the switch verbs, `agent/created`\n * (resume), and `working-directory/change` (any `cd`) — so the line is never\n * stale, and it drops out entirely once the session leaves the copy.\n * @module dsh-worktree-switch/host/scope\n */\n\nimport type { Context } from '@deepseek-ai/cordis'\nimport type { Agent } from '@deepseek-ai/dsh-agent'\nimport type {} from '@deepseek-ai/dsh-system-prompt'\nimport type { SessionScope } from '../types.ts'\nimport { contains } from './paths.ts'\n\n/** Sessions whose confirmed copy the prompt context reads. */\nexport class SessionScopeRegistry {\n private readonly scopes = new Map<string, SessionScope>()\n\n /**\n * Read one session's confirmed scope, without any filesystem work.\n * @param sessionId - wire session identity.\n * @returns the known copy, or null.\n */\n get(sessionId: string): SessionScope | null {\n return this.scopes.get(sessionId) ?? null\n }\n\n /**\n * Record that a session now runs in a copy.\n * @param sessionId - wire session identity.\n * @param scope - the confirmed copy and its project root.\n */\n remember(sessionId: string, scope: SessionScope): void {\n this.scopes.set(sessionId, scope)\n }\n\n /**\n * Drop a session's scope; a session with no entry gets no hint.\n * @param sessionId - wire session identity.\n */\n forget(sessionId: string): void {\n this.scopes.delete(sessionId)\n }\n\n /** Drop every entry; the plugin dropping its context drops its hints with it. */\n clear(): void {\n this.scopes.clear()\n }\n}\n\n/**\n * The model-facing line describing a worktree session's workspace.\n * @param scope - the session's confirmed copy.\n * @returns one sentence pair, JSON-quoted so paths survive the prompt.\n */\nexport function scopeHint(scope: SessionScope): string {\n 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.`\n}\n\n/** What the installer needs from the host half that owns Git. */\nexport interface WorktreeScopeWiring {\n /** Shared registry the prompt context reads. */\n readonly registry: SessionScopeRegistry\n /**\n * Re-derive one session's copy from its committed directory.\n * @param agent - live session owner.\n * @param signal - cancellation.\n * @returns the confirmed copy, or null when the session is not in one.\n */\n detect(agent: Agent, signal?: AbortSignal): Promise<SessionScope | null>\n}\n\n/**\n * Register the worktree scope as dynamic prompt context and keep it fresh.\n *\n * The context is `required`, so it survives compositions that suppress\n * optional runtime context, and it renders from the registry synchronously —\n * the expensive Git detection happens on session creation and directory\n * changes, never at request time.\n * @param ctx - host context carrying `systemPrompt`, `agents`, and `workingDirectory`.\n * @param wiring - registry plus the Git-backed detector.\n */\nexport function installWorktreeScope(ctx: Context, wiring: WorktreeScopeWiring): void {\n const { registry, detect } = wiring\n\n ctx.inject(['systemPrompt'], (scoped: Context) => {\n scoped.systemPrompt.context({\n name: 'worktree-switch:scope',\n order: scoped.systemPrompt.getContextOrder('WORKING_DIRECTORY') + 5,\n interpolate: false,\n required: true,\n text: (assembly) => {\n const agent = assembly.agent\n if (agent === undefined) return ''\n const sessionId = String(agent.session.id)\n const current = ctx.workingDirectory.get(agent.session)\n const scope = registry.get(sessionId)\n if (scope !== null && contains(scope.worktree.path, current)) return scopeHint(scope)\n // The committed directory is the authority: a session that left the\n // copy must not keep the line.\n if (scope !== null) registry.forget(sessionId)\n const anchor = agent.session.header.cwd ?? current\n // A session still at its creation directory is in no copy; only a\n // directory change inside the project is worth a Git look, and only\n // once per distinct directory value.\n if (current === anchor || !contains(anchor, current)) return ''\n if (lastChecked.get(sessionId) === current) return ''\n void refresh(agent, current)\n return ''\n },\n })\n })\n\n const inFlight = new Set<string>()\n /** The directory value each session was last inspected at, hit or miss. */\n const lastChecked = new Map<string, string>()\n\n /** Re-derive one live agent's scope, remembering or forgetting it. */\n const refresh = async (agent: Agent, current: string = ctx.workingDirectory.get(agent.session)): Promise<void> => {\n const sessionId = String(agent.session.id)\n const anchor = agent.session.header.cwd ?? current\n // Cheap reject before any Git work: a copy is always inside the project,\n // and a session still at its creation directory is in no copy.\n if (current === anchor || !contains(anchor, current)) {\n registry.forget(sessionId)\n return\n }\n if (inFlight.has(sessionId)) return\n inFlight.add(sessionId)\n lastChecked.set(sessionId, current)\n try {\n const found = await detect(agent)\n if (found === null) registry.forget(sessionId)\n else registry.remember(sessionId, found)\n } catch {\n registry.forget(sessionId)\n } finally {\n inFlight.delete(sessionId)\n }\n }\n\n // A resumed session enters no verb, so creation is where it is recognised; an\n // ordinary new session sits at its creation directory and stops at the cheap\n // check. The hook is serial, so the first request already sees the line.\n ctx.on('agent/created', async ({ agent }) => {\n await refresh(agent)\n return undefined\n })\n}\n\n","/**\n * Host half of the Worktree switch.\n *\n * It mounts no service itself: the bundle patch inserts the harness worktree\n * service (`@deepseek-ai/dsh-experimental-worktree`, which provides\n * `ctx.worktrees`) and this plugin's own row. This plugin exposes five verbs\n * over the Connection fetch bridge and drives that service for creation; it\n * also contributes one model-facing context line while a session runs in a\n * copy, and the browser half renders the switch and the panel.\n * @module dsh-worktree-switch\n */\n\nimport type { Context } from '@deepseek-ai/cordis'\nimport Schema from '@deepseek-ai/schemastery'\nimport type {} from '@deepseek-ai/dsh-agent'\nimport type {} from '@deepseek-ai/dsh-experimental-worktree'\nimport type {} from '@deepseek-ai/dsh-subprocess'\nimport type {} from '@deepseek-ai/dsh-working-directory'\nimport { installSourceCheckoutGuard } from './host/guard.ts'\nimport { createWorktreeToggle, detectSessionScope } from './host/handlers.ts'\nimport { registerWorktreeRoutes } from './host/routes.ts'\nimport { installWorktreeScope, SessionScopeRegistry } from './host/scope.ts'\n\n/** Plugin id: package name, bundle row id, browser module id. */\nexport const name = 'dsh-worktree-switch'\n\n/**\n * Services this plugin cannot work without.\n *\n * `worktrees` is provided by the row this bundle's patch inserts next to its\n * own, so Cordis holds this plugin until that row has mounted.\n */\nexport const inject = ['agents', 'connection', 'subprocess', 'worktrees', 'workingDirectory']\n\n/** Row config accepted from `cordis.patch.yml`. */\nexport interface Config {\n /** Default for a project the user has never switched either way. */\n defaultEnabled: boolean\n /** Refuse shell commands that run Git against the project checkout while the session is in a copy. */\n guardSourceCheckout: boolean\n}\n\n/** Schemastery schema for {@link Config}. */\nexport const Config: Schema<Config> = Schema.object({\n defaultEnabled: Schema.boolean()\n .default(true)\n .description('Start a new session in a worktree copy until the user turns the switch off for that project.'),\n guardSourceCheckout: Schema.boolean()\n .default(true)\n .description('While a session is in a copy, deny shell commands that run Git against the project checkout.'),\n})\n\n/**\n * Mount the host half.\n * @param ctx - Host context.\n * @param config - validated row config.\n */\nexport function apply(ctx: Context, config: Config): void {\n const scopes = new SessionScopeRegistry()\n const toggle = createWorktreeToggle(ctx, {\n defaultEnabled: config.defaultEnabled,\n onScope: (sessionId, scope) => {\n if (scope === null) scopes.forget(sessionId)\n else scopes.remember(sessionId, scope)\n },\n })\n installWorktreeScope(ctx, {\n registry: scopes,\n detect: (agent, signal) => detectSessionScope(ctx, agent, signal),\n })\n if (config.guardSourceCheckout) installSourceCheckoutGuard(ctx, scopes)\n registerWorktreeRoutes(ctx, toggle)\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-worktree-switch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A New Session switch that puts the conversation into a throwaway Git worktree under the project's worktree pool (`<project>/.dsh/worktrees` by default), plus a panel to inspect and discard the copies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"@deepseek-ai/dsh-client-ui-slots": ">=0.2.1-rc.5 <0.2.2-0",
|
|
74
74
|
"@deepseek-ai/dsh-client-ui-workspace": ">=0.2.1-rc.5 <0.2.2-0",
|
|
75
75
|
"@deepseek-ai/dsh-experimental-worktree": ">=0.2.1-rc.5 <0.2.2-0",
|
|
76
|
+
"@deepseek-ai/dsh-tools": ">=0.2.1-rc.5 <0.2.2-0",
|
|
76
77
|
"@deepseek-ai/schemastery": "^3.18.2"
|
|
77
78
|
},
|
|
78
79
|
"devDependencies": {
|
|
@@ -103,6 +104,7 @@
|
|
|
103
104
|
"@deepseek-ai/dsh-subprocess": "0.2.1-rc.5",
|
|
104
105
|
"@deepseek-ai/dsh-subprocess-local": "0.2.1-rc.5",
|
|
105
106
|
"@deepseek-ai/dsh-system-prompt": "0.2.1-rc.5",
|
|
107
|
+
"@deepseek-ai/dsh-tools": "0.2.1-rc.5",
|
|
106
108
|
"@deepseek-ai/dsh-working-directory": "0.2.1-rc.5",
|
|
107
109
|
"@deepseek-ai/schemastery": "3.18.5",
|
|
108
110
|
"@testing-library/react": "^16.3.3",
|