@maxiaochao/pi-toolkit 0.10.11 → 0.10.13
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/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/packages/worktree/CHANGELOG.md +10 -0
- package/packages/worktree/README.md +3 -3
- package/packages/worktree/TESTING.md +4 -2
- package/packages/worktree/extensions/worktree.ts +110 -37
- package/packages/worktree/package.json +1 -1
- package/packages/worktree/skills/pi-worktree/SKILL.md +3 -3
- package/packages/worktree/tests/extension.test.mjs +192 -93
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2026-10-09 - v0.10.13
|
|
2
|
+
|
|
3
|
+
- bundle worktree v0.3.1: show a persistent in-conversation confirmation after switching worktrees
|
|
4
|
+
|
|
5
|
+
## 2026-10-09 - v0.10.12
|
|
6
|
+
|
|
7
|
+
- bundle worktree v0.3.0: automatically move Agent conversations into prepared worktrees without external Harness support
|
|
8
|
+
|
|
1
9
|
## 2026-10-09 - v0.10.11
|
|
2
10
|
|
|
3
11
|
- bundle herdr-subagents v0.4.4: return failed foreground batches early while surviving subagents continue in background
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-toolkit",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.13",
|
|
4
4
|
"description": "Personal pi coding agent toolkit: scheduler, /btw, /cache_export, apply_patch, browser automation, and bundled theme/skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026-10-09 - v0.3.1
|
|
4
|
+
|
|
5
|
+
- show a persistent in-conversation confirmation after switching worktrees
|
|
6
|
+
|
|
7
|
+
## 2026-10-09 - v0.3.0
|
|
8
|
+
|
|
9
|
+
- automatically move Agent conversations into prepared worktrees without external Harness support
|
|
10
|
+
- 修复 `enter_worktree` 只创建目录却不切换的问题;现在 Extension 会在 `agent_settled` 后自动 fork 并切换当前会话,不再依赖外部 Harness 消费协议。
|
|
11
|
+
- 移除 v0.2.0 的 `pi-worktree/switch-request` 常量及 `kind/version/action/sessionFile` 输出字段;自定义 Harness 若曾消费该协议,需要改用 Extension 内置自动切换。
|
|
12
|
+
|
|
3
13
|
## 2026-10-09 - v0.2.0
|
|
4
14
|
|
|
5
15
|
- add current-session worktree switching for users, Agents, and SDK Harnesses
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @maxiaochao/pi-worktree
|
|
2
2
|
|
|
3
|
-
`pi-worktree` CLI、Pi Extension 与配套 skill。它既能启动独立 Pi
|
|
3
|
+
`pi-worktree` CLI、Pi Extension 与配套 skill。它既能启动独立 Pi,也能让用户或 Agent 在当前 Pi 界面中切换 worktree,并保留完整对话。
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
pi-worktree start feature -p "implement the feature"
|
|
17
|
-
pi-worktree prepare
|
|
17
|
+
pi-worktree prepare --json feature
|
|
18
18
|
pi-worktree info feature
|
|
19
19
|
pi-worktree out
|
|
20
20
|
pi-worktree remove feature
|
|
@@ -31,7 +31,7 @@ pi-worktree remove feature
|
|
|
31
31
|
|
|
32
32
|
命令会把当前持久会话 fork 到目标 worktree,再通过 Pi 的公开 session replacement API 切换。临时的 `--no-session` 会话无法使用此入口。
|
|
33
33
|
|
|
34
|
-
Agent 使用 Extension 注册的 `enter_worktree` tool
|
|
34
|
+
Agent 使用 Extension 注册的 `enter_worktree` tool。它创建或复用目标、终止当前工具轮,并在 `agent_settled` 后自动触发内部 `/worktree` 命令。Extension 随后执行 `SessionManager.forkFrom()` 和 `switchSession()`;目标 cwd 会写入新 session header,当前界面继续显示完整对话,重启后也仍然有效。整个流程由 Extension 完成,不需要外部 Harness 适配。
|
|
35
35
|
|
|
36
36
|
## 安装
|
|
37
37
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## 流程
|
|
4
4
|
|
|
5
|
-
1. `npm --prefix packages/worktree test`:在临时 Git 仓库中运行 CLI,并验证 Extension
|
|
5
|
+
1. `npm --prefix packages/worktree test`:在临时 Git 仓库中运行 CLI,并验证 Extension 注册、延迟命令桥接和 session 切换生命周期。
|
|
6
6
|
2. `npm pack ./packages/worktree --dry-run`:确认 CLI、completion、skill 和安装脚本进入 tarball。
|
|
7
7
|
3. 手工验收发布包的 `pi-worktree start` 交互流程。
|
|
8
8
|
4. 确认 README 示例、真实临时 Git 仓库输出 `docs/screenshot.png` 和 `docs/demo.gif` 进入 tarball。
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
- `start` 从当前分支创建 `.worktrees/<name>` 并记录 base。
|
|
14
14
|
- `prepare --json` 覆盖 created、reused、already-active、dirty 和非法名称安全失败。
|
|
15
15
|
- `/worktree start` 把历史 fork 到 cwd 正确的新 session,再调用 `switchSession`。
|
|
16
|
-
- `enter_worktree`
|
|
16
|
+
- `enter_worktree` 串行准备目标,在 `agent_settled` 后触发内部命令,并 fork 当前 session 后调用 `switchSession`。
|
|
17
|
+
- Agent 轮被中止时不触发切换;用户取消 session replacement 时删除派生 session。
|
|
18
|
+
- 自定义 session 目录在 fork 后保持不变。
|
|
17
19
|
- 假 Pi 收到正确工作目录和参数。
|
|
18
20
|
- `list` 标记主 checkout 和子 worktree。
|
|
19
21
|
- `info` 报告 clean、branch、base 和 ahead。
|
|
@@ -5,8 +5,7 @@ import { SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-age
|
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
|
|
7
7
|
const CLI_PATH = fileURLToPath(new URL("../bin/pi-worktree", import.meta.url));
|
|
8
|
-
|
|
9
|
-
export const WORKTREE_SWITCH_REQUEST = "pi-worktree/switch-request";
|
|
8
|
+
const PENDING_SWITCH_ARGUMENT = "__enter_worktree_pending__";
|
|
10
9
|
|
|
11
10
|
export interface WorktreePrepareResult {
|
|
12
11
|
status: "created" | "attached" | "reused" | "already-active";
|
|
@@ -15,18 +14,12 @@ export interface WorktreePrepareResult {
|
|
|
15
14
|
dirty: boolean;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
export interface
|
|
19
|
-
|
|
20
|
-
version: 1;
|
|
21
|
-
action: "fork-and-switch";
|
|
22
|
-
sessionFile: string;
|
|
17
|
+
export interface EnterWorktreeResult extends WorktreePrepareResult {
|
|
18
|
+
switch: "scheduled" | "already-active";
|
|
23
19
|
}
|
|
24
20
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
version: Type.Literal(1),
|
|
28
|
-
action: Type.Literal("fork-and-switch"),
|
|
29
|
-
sessionFile: Type.String(),
|
|
21
|
+
const EnterWorktreeResultSchema = Type.Object({
|
|
22
|
+
switch: Type.Union([Type.Literal("scheduled"), Type.Literal("already-active")]),
|
|
30
23
|
status: Type.Union([
|
|
31
24
|
Type.Literal("created"),
|
|
32
25
|
Type.Literal("attached"),
|
|
@@ -76,53 +69,117 @@ async function prepareWorktree(
|
|
|
76
69
|
cwd: string,
|
|
77
70
|
name: string,
|
|
78
71
|
base?: string,
|
|
72
|
+
signal?: AbortSignal,
|
|
79
73
|
): Promise<WorktreePrepareResult> {
|
|
80
74
|
const args = ["prepare", "--json"];
|
|
81
75
|
if (base) args.push("--base", base);
|
|
82
76
|
args.push(name);
|
|
83
|
-
const result = await pi.exec(CLI_PATH, args, { cwd });
|
|
77
|
+
const result = await pi.exec(CLI_PATH, args, { cwd, signal });
|
|
84
78
|
if (result.code !== 0) {
|
|
85
79
|
throw new Error(result.stderr.trim() || result.stdout.trim() || `pi-worktree prepare 失败(退出码 ${result.code})`);
|
|
86
80
|
}
|
|
81
|
+
if (signal?.aborted) throw new Error("worktree 创建已取消");
|
|
87
82
|
return parsePrepareOutput(result.stdout);
|
|
88
83
|
}
|
|
89
84
|
|
|
90
85
|
export default function worktreeExtension(pi: ExtensionAPI) {
|
|
86
|
+
let pendingSwitch: WorktreePrepareResult | undefined;
|
|
87
|
+
let pendingDispatchScheduled = false;
|
|
88
|
+
let transitionReserved = false;
|
|
89
|
+
|
|
90
|
+
pi.on("agent_settled", event => {
|
|
91
|
+
if (!pendingSwitch || pendingDispatchScheduled) return;
|
|
92
|
+
if (event.aborted) {
|
|
93
|
+
pendingSwitch = undefined;
|
|
94
|
+
pendingDispatchScheduled = false;
|
|
95
|
+
transitionReserved = false;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
pendingDispatchScheduled = true;
|
|
99
|
+
// AgentSession defers prompts submitted from agent_settled until the event
|
|
100
|
+
// finishes. Command expansion then supplies a safe command context with
|
|
101
|
+
// switchSession(), after the tool result is persisted and the run is idle.
|
|
102
|
+
pi.sendUserMessage(`/worktree ${PENDING_SWITCH_ARGUMENT}`, {
|
|
103
|
+
expandPromptTemplates: true,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
91
107
|
pi.registerCommand("worktree", {
|
|
92
108
|
description: "创建或复用 worktree,并在当前界面切换过去",
|
|
93
109
|
handler: async (input, ctx) => {
|
|
110
|
+
const isPendingSwitch = input.trim() === PENDING_SWITCH_ARGUMENT;
|
|
94
111
|
let prepared: WorktreePrepareResult;
|
|
95
112
|
let targetSessionFile: string;
|
|
96
113
|
try {
|
|
97
|
-
|
|
114
|
+
if (!isPendingSwitch && transitionReserved) {
|
|
115
|
+
ctx.ui.notify("Agent 正在切换 worktree,请等待切换完成后再执行 /worktree。", "warning");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!isPendingSwitch) transitionReserved = true;
|
|
119
|
+
const requestedSwitch = isPendingSwitch ? pendingSwitch : undefined;
|
|
120
|
+
if (isPendingSwitch && !requestedSwitch) {
|
|
121
|
+
throw new Error("没有待处理的 Agent worktree 切换");
|
|
122
|
+
}
|
|
98
123
|
const sourceSessionFile = ctx.sessionManager.getSessionFile();
|
|
99
124
|
if (!sourceSessionFile) {
|
|
100
125
|
throw new Error("当前是临时会话;请启用 session 持久化后再切换 worktree");
|
|
101
126
|
}
|
|
102
127
|
|
|
103
128
|
await ctx.waitForIdle();
|
|
104
|
-
|
|
129
|
+
if (requestedSwitch) {
|
|
130
|
+
prepared = requestedSwitch;
|
|
131
|
+
} else {
|
|
132
|
+
const { name, base } = parseCommandArgs(input);
|
|
133
|
+
prepared = await prepareWorktree(pi, ctx.cwd, name, base);
|
|
134
|
+
}
|
|
105
135
|
if (resolve(ctx.cwd) === resolve(prepared.path)) {
|
|
136
|
+
if (isPendingSwitch) {
|
|
137
|
+
pendingSwitch = undefined;
|
|
138
|
+
pendingDispatchScheduled = false;
|
|
139
|
+
}
|
|
140
|
+
transitionReserved = false;
|
|
106
141
|
ctx.ui.notify(`已经位于 worktree:${prepared.path}`, "info");
|
|
107
142
|
return;
|
|
108
143
|
}
|
|
109
144
|
|
|
110
|
-
const
|
|
145
|
+
const sessionDir = ctx.sessionManager.usesDefaultSessionDir()
|
|
146
|
+
? undefined
|
|
147
|
+
: ctx.sessionManager.getSessionDir();
|
|
148
|
+
const target = SessionManager.forkFrom(sourceSessionFile, prepared.path, sessionDir);
|
|
111
149
|
targetSessionFile = target.getSessionFile() ?? "";
|
|
112
150
|
if (!targetSessionFile) throw new Error("无法为 worktree 创建持久会话");
|
|
113
151
|
} catch (error) {
|
|
152
|
+
if (isPendingSwitch) pendingSwitch = undefined;
|
|
153
|
+
pendingDispatchScheduled = false;
|
|
154
|
+
transitionReserved = false;
|
|
114
155
|
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
115
156
|
return;
|
|
116
157
|
}
|
|
117
158
|
|
|
118
159
|
// Do not catch replacement errors here: switchSession may already have
|
|
119
160
|
// invalidated this command context, so Pi must report such failures.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
next
|
|
124
|
-
|
|
125
|
-
|
|
161
|
+
let switched: { cancelled: boolean };
|
|
162
|
+
try {
|
|
163
|
+
switched = await ctx.switchSession(targetSessionFile, {
|
|
164
|
+
withSession: async (next) => {
|
|
165
|
+
const dirtyNote = prepared.dirty ? "(已有未提交改动)" : "";
|
|
166
|
+
await next.sendMessage({
|
|
167
|
+
customType: "worktree-switch",
|
|
168
|
+
content: `✓ 已进入 worktree:${prepared.path}\nBranch: ${prepared.branch}${dirtyNote}`,
|
|
169
|
+
display: true,
|
|
170
|
+
details: prepared,
|
|
171
|
+
}, { triggerTurn: false });
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
} catch (error) {
|
|
175
|
+
pendingSwitch = undefined;
|
|
176
|
+
pendingDispatchScheduled = false;
|
|
177
|
+
transitionReserved = false;
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
pendingSwitch = undefined;
|
|
181
|
+
pendingDispatchScheduled = false;
|
|
182
|
+
transitionReserved = false;
|
|
126
183
|
if (switched.cancelled) {
|
|
127
184
|
try {
|
|
128
185
|
unlinkSync(targetSessionFile);
|
|
@@ -139,33 +196,49 @@ export default function worktreeExtension(pi: ExtensionAPI) {
|
|
|
139
196
|
name: "enter_worktree",
|
|
140
197
|
label: "Enter Worktree",
|
|
141
198
|
exposure: "model-only",
|
|
199
|
+
executionMode: "sequential",
|
|
142
200
|
description:
|
|
143
|
-
"Create or reuse an isolated git worktree
|
|
201
|
+
"Create or reuse an isolated git worktree, then automatically move this current conversation into it after the current Agent run settles. Call this tool alone and do not call repository tools afterward.",
|
|
144
202
|
parameters: Type.Object({
|
|
145
203
|
name: Type.String({ description: "Single-segment worktree and branch name" }),
|
|
146
204
|
base: Type.Optional(Type.String({ description: "Optional git ref used only when creating a new branch" })),
|
|
147
205
|
}),
|
|
148
|
-
outputSchema:
|
|
149
|
-
async execute(_toolCallId, params,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
206
|
+
outputSchema: EnterWorktreeResultSchema,
|
|
207
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
208
|
+
if (!ctx.sessionManager.getSessionFile()) {
|
|
209
|
+
throw new Error("当前是临时会话,无法续接到 worktree");
|
|
210
|
+
}
|
|
211
|
+
if (transitionReserved) {
|
|
212
|
+
const target = pendingSwitch ? `:${pendingSwitch.path}` : "";
|
|
213
|
+
throw new Error(`已有 worktree 切换正在处理${target}`);
|
|
214
|
+
}
|
|
215
|
+
transitionReserved = true;
|
|
216
|
+
|
|
217
|
+
let prepared: WorktreePrepareResult;
|
|
218
|
+
try {
|
|
219
|
+
prepared = await prepareWorktree(pi, ctx.cwd, params.name, params.base, signal);
|
|
220
|
+
if (signal.aborted) throw new Error("worktree 创建已取消");
|
|
221
|
+
} catch (error) {
|
|
222
|
+
transitionReserved = false;
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
const alreadyActive = resolve(ctx.cwd) === resolve(prepared.path);
|
|
226
|
+
const result: EnterWorktreeResult = {
|
|
227
|
+
switch: alreadyActive ? "already-active" : "scheduled",
|
|
159
228
|
...prepared,
|
|
160
229
|
};
|
|
230
|
+
if (alreadyActive) transitionReserved = false;
|
|
231
|
+
else pendingSwitch = prepared;
|
|
161
232
|
const dirtyNote = prepared.dirty ? " 目标 worktree 已有未提交改动。" : "";
|
|
162
233
|
return {
|
|
163
234
|
content: [{
|
|
164
235
|
type: "text",
|
|
165
|
-
text:
|
|
236
|
+
text: alreadyActive
|
|
237
|
+
? `已经位于 worktree:${prepared.path}.${dirtyNote}`
|
|
238
|
+
: `Worktree ${prepared.status}: ${prepared.path}.${dirtyNote} 当前 Agent run 结束后会自动切换到该目录;不要继续调用仓库工具。`,
|
|
166
239
|
}],
|
|
167
|
-
details:
|
|
168
|
-
structuredContent:
|
|
240
|
+
details: result,
|
|
241
|
+
structuredContent: result,
|
|
169
242
|
terminate: true,
|
|
170
243
|
};
|
|
171
244
|
},
|
|
@@ -13,7 +13,7 @@ Installed by the package postinstall hook to `~/.local/bin/pi-worktree`, with ba
|
|
|
13
13
|
|
|
14
14
|
| Command | Effect |
|
|
15
15
|
|---|---|
|
|
16
|
-
| `enter_worktree({ name, base? })` | Agent entrypoint: create/reuse a worktree and
|
|
16
|
+
| `enter_worktree({ name, base? })` | Agent entrypoint: create/reuse a worktree and automatically move this conversation there after the run settles. Call it alone and stop after it returns. |
|
|
17
17
|
| `/worktree start <name> [--base <ref>]` | User entrypoint: fork the current persistent session into the worktree and switch the current Pi UI. |
|
|
18
18
|
| `pi-worktree prepare [--base <ref>] [--json] <name>` | Create/reuse without launching Pi; `--json` is the machine interface. |
|
|
19
19
|
| `pi-worktree start <name> [pi args...]` | Create or reuse `.worktrees/<name>`, then launch Pi inside it. Already inside a linked worktree, it just launches Pi there. |
|
|
@@ -27,14 +27,14 @@ Installed by the package postinstall hook to `~/.local/bin/pi-worktree`, with ba
|
|
|
27
27
|
## Isolating a task
|
|
28
28
|
|
|
29
29
|
1. **Survey** — `pi-worktree list`, plus `pi-worktree info <name>` when the name you want is already taken. Reuse is free; a removed worktree is gone for good, because `remove` deletes its branch.
|
|
30
|
-
2. **Enter** — as an Agent, call `enter_worktree` once and stop
|
|
30
|
+
2. **Enter** — as an Agent, call `enter_worktree` once and stop; the Extension switches automatically after settlement. A user can instead run `/worktree start <name>`. Use the CLI `start` only when a separate Pi process is wanted.
|
|
31
31
|
3. **Work** — inside the worktree: commit there, and check `pi-worktree info <name>` before claiming progress. `ahead: N commits since base` is the evidence.
|
|
32
32
|
4. **Land** — `pi-worktree out`, then `git merge <name>` in the main checkout. Done when the worktree's unique commits are in the target branch.
|
|
33
33
|
5. **Clean up** — `pi-worktree remove <name>`. A merged branch goes without prompting; anything unmerged asks once.
|
|
34
34
|
|
|
35
35
|
## Entry-point rules
|
|
36
36
|
|
|
37
|
-
- Prefer `enter_worktree` when it is available.
|
|
37
|
+
- Prefer `enter_worktree` when it is available. After the tool returns, do not read or modify repository files; the Extension waits for `agent_settled`, then forks the session into the target cwd and switches automatically.
|
|
38
38
|
- `/worktree` is user-facing and cannot be invoked by the model. It requires a persisted session because it forks history into a session whose header owns the worktree cwd.
|
|
39
39
|
- `pi-worktree start` **execs a new `pi`** in the worktree, and `out` execs a shell. Use this only for an intentionally separate process/session.
|
|
40
40
|
- `prepare` is idempotent. It reports whether the target was created, attached, reused, or already active, and reports dirty reuse rather than discarding anything.
|
|
@@ -1,114 +1,213 @@
|
|
|
1
|
+
import test from "node:test";
|
|
1
2
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
3
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
3
5
|
import { tmpdir } from "node:os";
|
|
4
|
-
import {
|
|
5
|
-
import test from "node:test";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
const extensionUrl = pathToFileURL(
|
|
9
|
+
join(import.meta.dirname, "..", "extensions", "worktree.ts"),
|
|
10
|
+
).href;
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
function sourceSession(path, cwd) {
|
|
13
|
+
const timestamp = new Date().toISOString();
|
|
14
|
+
writeFileSync(path, [
|
|
15
|
+
JSON.stringify({ type: "session", version: 3, id: "source", timestamp, cwd }),
|
|
16
|
+
JSON.stringify({ type: "message", id: "history-1", parentId: null, timestamp, message: { role: "user", content: "preserve me", timestamp: Date.now() } }),
|
|
17
|
+
"",
|
|
18
|
+
].join("\n"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function fixture({ cancelled = false, switchError, customSessionDir = true, deferExec = false } = {}) {
|
|
22
|
+
const root = mkdtempSync(join(tmpdir(), "pi-worktree-extension-"));
|
|
23
|
+
const cwd = join(root, "repo");
|
|
24
|
+
const worktree = join(root, "feature");
|
|
25
|
+
const sessions = join(root, "sessions");
|
|
26
|
+
mkdirSync(cwd);
|
|
27
|
+
mkdirSync(worktree);
|
|
28
|
+
mkdirSync(sessions);
|
|
29
|
+
const source = join(sessions, "source.jsonl");
|
|
30
|
+
sourceSession(source, cwd);
|
|
14
31
|
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
32
|
+
const handlers = {};
|
|
33
|
+
const sent = [];
|
|
34
|
+
const sessionMessages = [];
|
|
35
|
+
const notifications = [];
|
|
36
|
+
const execCalls = [];
|
|
37
|
+
let releaseExec;
|
|
38
|
+
let markExecStarted;
|
|
39
|
+
const execStarted = new Promise(resolve => { markExecStarted = resolve; });
|
|
18
40
|
const pi = {
|
|
19
|
-
|
|
20
|
-
|
|
41
|
+
on(event, handler) { handlers[event] = handler; },
|
|
42
|
+
registerCommand(name, command) { handlers[`command:${name}`] = command.handler; },
|
|
43
|
+
registerTool(tool) { handlers.tool = tool; },
|
|
21
44
|
async exec(command, args, options) {
|
|
22
|
-
|
|
23
|
-
assert.
|
|
24
|
-
|
|
25
|
-
|
|
45
|
+
execCalls.push({ command, args, options });
|
|
46
|
+
assert.equal(options.cwd, cwd);
|
|
47
|
+
markExecStarted();
|
|
48
|
+
if (deferExec) await new Promise(resolve => { releaseExec = resolve; });
|
|
49
|
+
return {
|
|
50
|
+
code: 0,
|
|
51
|
+
stdout: JSON.stringify({ status: "created", path: worktree, branch: "feature", dirty: false }),
|
|
52
|
+
stderr: "",
|
|
53
|
+
};
|
|
26
54
|
},
|
|
55
|
+
sendUserMessage(message, options) { sent.push({ message, options }); },
|
|
27
56
|
};
|
|
28
|
-
register(pi);
|
|
29
|
-
return { commands, tools };
|
|
30
|
-
}
|
|
31
57
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const target = join(root, "main", ".worktrees", "feature");
|
|
35
|
-
await mkdir(target, { recursive: true });
|
|
36
|
-
const sourceSession = join(root, "source.jsonl");
|
|
37
|
-
await writeFile(sourceSession, `${JSON.stringify({ type: "session", version: 3, id: "source", timestamp: new Date().toISOString(), cwd })}\n`);
|
|
38
|
-
const prepared = { cwd, result: { status: "created", path: target, branch: "feature", dirty: false } };
|
|
39
|
-
const { commands } = setup(prepared);
|
|
40
|
-
const notifications = [];
|
|
41
|
-
let switchedFile;
|
|
42
|
-
await commands.get("worktree").handler("start feature --base main", {
|
|
43
|
-
cwd,
|
|
44
|
-
sessionManager: { getSessionFile: () => sourceSession },
|
|
45
|
-
waitForIdle: async () => {},
|
|
46
|
-
ui: { notify: (...args) => notifications.push(args) },
|
|
47
|
-
async switchSession(file, options) {
|
|
48
|
-
switchedFile = file;
|
|
49
|
-
await options.withSession({ ui: { notify: (...args) => notifications.push(args) } });
|
|
50
|
-
return { cancelled: false };
|
|
51
|
-
},
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
const header = JSON.parse((await readFile(switchedFile, "utf8")).split("\n")[0]);
|
|
55
|
-
assert.equal(header.cwd, target);
|
|
56
|
-
assert.equal(header.parentSession, sourceSession);
|
|
57
|
-
assert.deepEqual(notifications.at(-1), [`已切换到 feature:${target}`, "success"]);
|
|
58
|
-
});
|
|
58
|
+
const extension = (await import(`${extensionUrl}?test=${Math.random()}`)).default;
|
|
59
|
+
extension(pi);
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const target = join(cwd, ".worktrees", "feature");
|
|
63
|
-
await mkdir(target, { recursive: true });
|
|
64
|
-
const sourceSession = join(root, "cancel-source.jsonl");
|
|
65
|
-
await writeFile(sourceSession, `${JSON.stringify({ type: "session", version: 3, id: "cancel-source", timestamp: new Date().toISOString(), cwd })}\n`);
|
|
66
|
-
const prepared = { cwd, result: { status: "created", path: target, branch: "feature", dirty: false } };
|
|
67
|
-
const { commands } = setup(prepared);
|
|
68
|
-
const notifications = [];
|
|
69
|
-
let forkedFile;
|
|
70
|
-
await commands.get("worktree").handler("start feature --base main", {
|
|
61
|
+
let switchedTo;
|
|
62
|
+
const ctx = {
|
|
71
63
|
cwd,
|
|
72
|
-
sessionManager: {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
64
|
+
sessionManager: {
|
|
65
|
+
getSessionFile: () => source,
|
|
66
|
+
usesDefaultSessionDir: () => !customSessionDir,
|
|
67
|
+
getSessionDir: () => sessions,
|
|
68
|
+
},
|
|
69
|
+
ui: { notify: (message, type) => notifications.push({ message, type }) },
|
|
70
|
+
async waitForIdle() {},
|
|
71
|
+
async switchSession(path, options) {
|
|
72
|
+
switchedTo = path;
|
|
73
|
+
if (cancelled) return { cancelled: true };
|
|
74
|
+
if (switchError) throw switchError;
|
|
75
|
+
await options.withSession({
|
|
76
|
+
ui: ctx.ui,
|
|
77
|
+
async sendMessage(message, options) { sessionMessages.push({ message, options }); },
|
|
78
|
+
});
|
|
79
|
+
return { cancelled: false };
|
|
78
80
|
},
|
|
79
|
-
}
|
|
81
|
+
};
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
return {
|
|
84
|
+
root, cwd, worktree, sessions, handlers, sent, sessionMessages, notifications, execCalls, ctx,
|
|
85
|
+
execStarted,
|
|
86
|
+
releaseExec: () => releaseExec(),
|
|
87
|
+
switchedTo: () => switchedTo,
|
|
88
|
+
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
84
91
|
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
const target = join(cwd, ".worktrees", "feature");
|
|
88
|
-
const sourceSession = join(root, "tool-source.jsonl");
|
|
89
|
-
const prepared = { cwd, result: { status: "reused", path: target, branch: "feature", dirty: true } };
|
|
90
|
-
const { tools } = setup(prepared);
|
|
91
|
-
const tool = tools.get("enter_worktree");
|
|
92
|
-
assert.equal(tool.exposure, "model-only");
|
|
93
|
-
assert.ok(tool.outputSchema);
|
|
94
|
-
const result = await tool.execute(
|
|
92
|
+
async function schedule(f) {
|
|
93
|
+
const result = await f.handlers.tool.execute(
|
|
95
94
|
"call-1",
|
|
96
|
-
{ name: "feature"
|
|
95
|
+
{ name: "feature" },
|
|
97
96
|
new AbortController().signal,
|
|
98
97
|
undefined,
|
|
99
|
-
|
|
98
|
+
f.ctx,
|
|
100
99
|
);
|
|
100
|
+
assert.equal(result.details.switch, "scheduled");
|
|
101
|
+
assert.equal(f.handlers.tool.executionMode, "sequential");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
test("enter_worktree defers, forks the current session, and switches automatically", async () => {
|
|
105
|
+
const f = await fixture();
|
|
106
|
+
try {
|
|
107
|
+
await schedule(f);
|
|
108
|
+
await f.handlers.agent_settled({ aborted: false });
|
|
109
|
+
assert.deepEqual(f.sent, [{
|
|
110
|
+
message: "/worktree __enter_worktree_pending__",
|
|
111
|
+
options: { expandPromptTemplates: true },
|
|
112
|
+
}]);
|
|
113
|
+
|
|
114
|
+
const internalArg = f.sent[0].message.slice("/worktree ".length);
|
|
115
|
+
await f.handlers["command:worktree"](internalArg, f.ctx);
|
|
116
|
+
|
|
117
|
+
const target = f.switchedTo();
|
|
118
|
+
assert.ok(target);
|
|
119
|
+
assert.equal(dirname(target), f.sessions, "custom session directory is preserved");
|
|
120
|
+
const header = JSON.parse(readFileSync(target, "utf8").split("\n")[0]);
|
|
121
|
+
assert.equal(header.cwd, f.worktree);
|
|
122
|
+
assert.equal(header.parentSession, join(f.sessions, "source.jsonl"));
|
|
123
|
+
assert.match(readFileSync(target, "utf8"), /"id":"history-1"/);
|
|
124
|
+
assert.deepEqual(f.sessionMessages, [{
|
|
125
|
+
message: {
|
|
126
|
+
customType: "worktree-switch",
|
|
127
|
+
content: `✓ 已进入 worktree:${f.worktree}\nBranch: feature`,
|
|
128
|
+
display: true,
|
|
129
|
+
details: { status: "created", path: f.worktree, branch: "feature", dirty: false },
|
|
130
|
+
},
|
|
131
|
+
options: { triggerTurn: false },
|
|
132
|
+
}]);
|
|
133
|
+
} finally {
|
|
134
|
+
f.cleanup();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("public /worktree forwards --base to the CLI and switches", async () => {
|
|
139
|
+
const f = await fixture();
|
|
140
|
+
try {
|
|
141
|
+
await f.handlers["command:worktree"]("start feature --base main", f.ctx);
|
|
142
|
+
assert.match(f.execCalls[0].command, /packages\/worktree\/bin\/pi-worktree$/);
|
|
143
|
+
assert.deepEqual(f.execCalls[0].args, ["prepare", "--json", "--base", "main", "feature"]);
|
|
144
|
+
assert.equal(f.execCalls[0].options.cwd, f.cwd);
|
|
145
|
+
assert.ok(f.switchedTo());
|
|
146
|
+
} finally {
|
|
147
|
+
f.cleanup();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("aborted agent settlement discards the scheduled switch", async () => {
|
|
152
|
+
const f = await fixture();
|
|
153
|
+
try {
|
|
154
|
+
await schedule(f);
|
|
155
|
+
await f.handlers.agent_settled({ aborted: true });
|
|
156
|
+
assert.equal(f.sent.length, 0);
|
|
157
|
+
} finally {
|
|
158
|
+
f.cleanup();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("a user command cannot race an Agent tool that is still preparing", async () => {
|
|
163
|
+
const f = await fixture({ deferExec: true });
|
|
164
|
+
try {
|
|
165
|
+
const toolResult = f.handlers.tool.execute(
|
|
166
|
+
"call-race",
|
|
167
|
+
{ name: "feature" },
|
|
168
|
+
new AbortController().signal,
|
|
169
|
+
undefined,
|
|
170
|
+
f.ctx,
|
|
171
|
+
);
|
|
172
|
+
await f.execStarted;
|
|
173
|
+
await f.handlers["command:worktree"]("start other", f.ctx);
|
|
174
|
+
assert.equal(f.execCalls.length, 1);
|
|
175
|
+
assert.equal(f.switchedTo(), undefined);
|
|
176
|
+
assert.equal(f.notifications.at(-1).type, "warning");
|
|
177
|
+
f.releaseExec();
|
|
178
|
+
await toolResult;
|
|
179
|
+
await f.handlers.agent_settled({ aborted: true });
|
|
180
|
+
} finally {
|
|
181
|
+
f.cleanup();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("cancelling session replacement deletes the derived session", async () => {
|
|
186
|
+
const f = await fixture({ cancelled: true });
|
|
187
|
+
try {
|
|
188
|
+
await schedule(f);
|
|
189
|
+
await f.handlers.agent_settled({ aborted: false });
|
|
190
|
+
await f.handlers["command:worktree"]("__enter_worktree_pending__", f.ctx);
|
|
191
|
+
|
|
192
|
+
assert.equal(existsSync(f.switchedTo()), false);
|
|
193
|
+
assert.match(f.notifications.at(-1).message, /切换已取消/);
|
|
194
|
+
assert.equal(f.notifications.at(-1).type, "warning");
|
|
195
|
+
} finally {
|
|
196
|
+
f.cleanup();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
101
199
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
200
|
+
test("a replacement error does not delete a session that may already be active", async () => {
|
|
201
|
+
const f = await fixture({ switchError: new Error("host rebind failed") });
|
|
202
|
+
try {
|
|
203
|
+
await schedule(f);
|
|
204
|
+
await f.handlers.agent_settled({ aborted: false });
|
|
205
|
+
await assert.rejects(
|
|
206
|
+
f.handlers["command:worktree"]("__enter_worktree_pending__", f.ctx),
|
|
207
|
+
/host rebind failed/,
|
|
208
|
+
);
|
|
209
|
+
assert.equal(existsSync(f.switchedTo()), true);
|
|
210
|
+
} finally {
|
|
211
|
+
f.cleanup();
|
|
212
|
+
}
|
|
114
213
|
});
|