@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 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.11",
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,也能让用户在当前界面切换 worktree,或让 Agent 向 SDK Harness 发出结构化切换请求。
3
+ `pi-worktree` CLI、Pi Extension 与配套 skill。它既能启动独立 Pi,也能让用户或 Agent 在当前 Pi 界面中切换 worktree,并保留完整对话。
4
4
 
5
5
  ![Worktree 在真实临时 Git 仓库中的运行截图](docs/screenshot.png)
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 feature --json
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。它创建或复用目标后返回 `pi-worktree/switch-request` 结构化结果,并终止当前工具轮;兼容 Harness 应在 `agent_settled` 后读取 `sessionFile` 和 `path`,执行 `SessionManager.forkFrom(sessionFile, path)`,再把返回的新 session 文件交给 `runtime.switchSession()`,最后重新绑定 `runtime.session`。这样目标 cwd 会写入新 session header,重启后仍然有效。普通 Pi 没有宿主切换钩子时,Tool 只会生成请求;用户仍可使用 `/worktree start` 完成切换。
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 注册和 session 切换协议。
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` 返回带 `pi-worktree/switch-request`、源 session、目标路径和 dirty 状态的 structured content,并请求终止当前工具轮。
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 WorktreeSwitchRequest extends WorktreePrepareResult {
19
- kind: typeof WORKTREE_SWITCH_REQUEST;
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 SwitchRequestSchema = Type.Object({
26
- kind: Type.Literal(WORKTREE_SWITCH_REQUEST),
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
- const { name, base } = parseCommandArgs(input);
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
- prepared = await prepareWorktree(pi, ctx.cwd, name, base);
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 target = SessionManager.forkFrom(sourceSessionFile, prepared.path);
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
- const switched = await ctx.switchSession(targetSessionFile, {
121
- withSession: async (next) => {
122
- const dirtyNote = prepared.dirty ? "(已有未提交改动)" : "";
123
- next.ui.notify(`已切换到 ${prepared.branch}:${prepared.path}${dirtyNote}`, "success");
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 and request that the host Harness move this current conversation into it. Call this tool alone, then stop; the Harness performs the session switch after the tool completes.",
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: SwitchRequestSchema,
149
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
150
- const sourceSessionFile = ctx.sessionManager.getSessionFile();
151
- if (!sourceSessionFile) throw new Error("当前是临时会话,Harness 无法续接到 worktree");
152
-
153
- const prepared = await prepareWorktree(pi, ctx.cwd, params.name, params.base);
154
- const request: WorktreeSwitchRequest = {
155
- kind: WORKTREE_SWITCH_REQUEST,
156
- version: 1,
157
- action: "fork-and-switch",
158
- sessionFile: sourceSessionFile,
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: `Worktree ${prepared.status}: ${prepared.path}.${dirtyNote} 已请求 Harness 将当前会话切换到该目录;不要继续调用仓库工具。`,
236
+ text: alreadyActive
237
+ ? `已经位于 worktree:${prepared.path}.${dirtyNote}`
238
+ : `Worktree ${prepared.status}: ${prepared.path}.${dirtyNote} 当前 Agent run 结束后会自动切换到该目录;不要继续调用仓库工具。`,
166
239
  }],
167
- details: request,
168
- structuredContent: request,
240
+ details: result,
241
+ structuredContent: result,
169
242
  terminate: true,
170
243
  };
171
244
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-worktree",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Git worktree CLI, Pi extension, and skill for isolated coding tasks.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -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 ask a compatible Harness to move this conversation there. Call it alone and stop after it returns. |
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 so the Harness can switch after settlement. A user can instead run `/worktree start <name>`. Use the CLI `start` only when a separate Pi process is wanted.
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. Its `fork-and-switch` request requires Harness support: after `agent_settled`, the host forks the source session into the target cwd and switches to that persisted session. After the tool returns, do not read or modify repository files until the host has switched cwd.
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 { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
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 { join } from "node:path";
5
- import test from "node:test";
6
+ import { pathToFileURL } from "node:url";
6
7
 
7
- const root = await mkdtemp(join(tmpdir(), "pi-worktree-extension-"));
8
- process.env.PI_CODING_AGENT_DIR = join(root, "agent");
9
- const { default: register, WORKTREE_SWITCH_REQUEST } = await import("../extensions/worktree.ts");
8
+ const extensionUrl = pathToFileURL(
9
+ join(import.meta.dirname, "..", "extensions", "worktree.ts"),
10
+ ).href;
10
11
 
11
- test.after(async () => {
12
- await rm(root, { recursive: true, force: true });
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
- function setup(prepared) {
16
- const commands = new Map();
17
- const tools = new Map();
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
- registerCommand(name, definition) { commands.set(name, definition); },
20
- registerTool(definition) { tools.set(definition.name, definition); },
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
- assert.match(command, /packages\/worktree\/bin\/pi-worktree$/);
23
- assert.deepEqual(args, ["prepare", "--json", "--base", "main", "feature"]);
24
- assert.equal(options.cwd, prepared.cwd);
25
- return { code: 0, stdout: `${JSON.stringify(prepared.result)}\n`, stderr: "" };
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
- test("registers /worktree and switches a forked persistent session", async () => {
33
- const cwd = join(root, "main");
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
- test("removes the forked session when /worktree switching is cancelled", async () => {
61
- const cwd = join(root, "cancel-main");
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: { getSessionFile: () => sourceSession },
73
- waitForIdle: async () => {},
74
- ui: { notify: (...args) => notifications.push(args) },
75
- async switchSession(file) {
76
- forkedFile = file;
77
- return { cancelled: true };
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
- await assert.rejects(readFile(forkedFile, "utf8"), { code: "ENOENT" });
82
- assert.deepEqual(notifications.at(-1), ["worktree 会话切换已取消", "warning"]);
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
- test("registers enter_worktree with a durable Harness switch request", async () => {
86
- const cwd = join(root, "main");
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", base: "main" },
95
+ { name: "feature" },
97
96
  new AbortController().signal,
98
97
  undefined,
99
- { cwd, sessionManager: { getSessionFile: () => sourceSession } },
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
- assert.equal(result.terminate, true);
103
- assert.deepEqual(result.details, {
104
- kind: WORKTREE_SWITCH_REQUEST,
105
- version: 1,
106
- action: "fork-and-switch",
107
- sessionFile: sourceSession,
108
- status: "reused",
109
- path: target,
110
- branch: "feature",
111
- dirty: true,
112
- });
113
- assert.deepEqual(result.structuredContent, result.details);
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
  });