@maxiaochao/pi-toolkit 0.10.7 → 0.10.9
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 +10 -0
- package/README.md +1 -1
- package/package.json +4 -3
- package/packages/herdr-subagents/CHANGELOG.md +7 -0
- package/packages/herdr-subagents/README.md +4 -2
- package/packages/herdr-subagents/TESTING.md +6 -1
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +83 -19
- package/packages/herdr-subagents/src/batch-runner.ts +3 -1
- package/packages/herdr-subagents/src/dispatcher.ts +8 -1
- package/packages/herdr-subagents/tests/extension-contract.ts +198 -2
- package/packages/herdr-subagents/tests/run-tests.mjs +25 -0
- package/packages/scheduler/CHANGELOG.md +6 -0
- package/packages/scheduler/README.md +4 -2
- package/packages/scheduler/TESTING.md +1 -1
- package/packages/scheduler/docs/demo.gif +0 -0
- package/packages/scheduler/docs/screenshot.png +0 -0
- package/packages/scheduler/extensions/scheduler.ts +87 -14
- package/packages/scheduler/package.json +22 -5
- package/packages/scheduler/src/scheduler.ts +4 -2
- package/packages/scheduler/tests/scheduler-core.mjs +7 -0
- package/packages/scheduler/tests/scheduler-extension.mjs +38 -2
- package/packages/worktree/CHANGELOG.md +7 -0
- package/packages/worktree/README.md +18 -3
- package/packages/worktree/TESTING.md +5 -2
- package/packages/worktree/bin/pi-worktree +118 -23
- package/packages/worktree/bin/pi-worktree-completion.bash +2 -2
- package/packages/worktree/extensions/worktree.ts +173 -0
- package/packages/worktree/package.json +37 -8
- package/packages/worktree/skills/pi-worktree/SKILL.md +11 -6
- package/packages/worktree/tests/extension.test.mjs +114 -0
- package/packages/worktree/tests/run-tests.sh +86 -0
|
Binary file
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
import { Scheduler, systemClock, type ScheduleSnapshot } from "../src/scheduler.ts";
|
|
6
6
|
|
|
7
7
|
const Params = Type.Object({
|
|
8
|
-
action: StringEnum(["add", "list", "trigger", "cancel"] as const),
|
|
8
|
+
action: StringEnum(["add", "list", "trigger", "cancel", "clear"] as const),
|
|
9
9
|
every: Type.Optional(Type.String({ description: "Interval for add, for example 30s, 5m, or 1h30m" })),
|
|
10
10
|
prompt: Type.Optional(Type.String({ description: "Prompt delivered to the main agent on each trigger" })),
|
|
11
11
|
id: Type.Optional(Type.String({ description: "Schedule ID for trigger or cancel" })),
|
|
@@ -22,29 +22,94 @@ function formatList(schedules: ScheduleSnapshot[]): string {
|
|
|
22
22
|
).join("\n");
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
type ScheduleAction = "add" | "list" | "trigger" | "cancel" | "clear";
|
|
26
|
+
|
|
27
|
+
function parseCommand(input: string): { action: ScheduleAction; every?: string; prompt?: string; id?: string } {
|
|
26
28
|
const text = input.trim();
|
|
27
29
|
if (!text || text === "list") return { action: "list" };
|
|
28
30
|
const [action, first, ...rest] = text.split(/\s+/);
|
|
29
31
|
if (action === "add") return { action, every: first, prompt: rest.join(" ") };
|
|
30
32
|
if (action === "trigger" || action === "cancel") return { action, id: first };
|
|
31
|
-
|
|
33
|
+
if (action === "clear") return { action };
|
|
34
|
+
throw new Error("usage: /schedule add <interval> <prompt> | list | trigger <id> | cancel <id> | clear");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const WIDGET_KEY = "scheduler";
|
|
38
|
+
|
|
39
|
+
export class ScheduleWidget {
|
|
40
|
+
private cachedWidth?: number;
|
|
41
|
+
private cachedLines?: string[];
|
|
42
|
+
private readonly getSchedules: () => ScheduleSnapshot[];
|
|
43
|
+
private readonly theme: Theme;
|
|
44
|
+
|
|
45
|
+
constructor(getSchedules: () => ScheduleSnapshot[], theme: Theme) {
|
|
46
|
+
this.getSchedules = getSchedules;
|
|
47
|
+
this.theme = theme;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
render(width: number): string[] {
|
|
51
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
52
|
+
const schedules = this.getSchedules();
|
|
53
|
+
this.cachedLines = [
|
|
54
|
+
truncateToWidth(this.theme.fg("accent", `⏱ ${schedules.length} active schedule${schedules.length === 1 ? "" : "s"}`), width),
|
|
55
|
+
...schedules.map((item) => {
|
|
56
|
+
const marker = item.pending ? this.theme.fg("warning", "●") : this.theme.fg("dim", "○");
|
|
57
|
+
const state = item.pending ? " · pending" : "";
|
|
58
|
+
return truncateToWidth(` ${marker} ${this.theme.fg("accent", item.id)} · ${item.every} · next ${formatTime(item.nextRunAt)}${state} · ${this.theme.fg("text", item.prompt)}`, width);
|
|
59
|
+
}),
|
|
60
|
+
];
|
|
61
|
+
this.cachedWidth = width;
|
|
62
|
+
return this.cachedLines;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
invalidate(): void {
|
|
66
|
+
this.cachedWidth = undefined;
|
|
67
|
+
this.cachedLines = undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface MountedWidget {
|
|
72
|
+
component: ScheduleWidget;
|
|
73
|
+
tui: { requestRender(): void };
|
|
32
74
|
}
|
|
33
75
|
|
|
34
76
|
export default function (pi: ExtensionAPI) {
|
|
35
77
|
if (process.env.PI_HERDR_SUBAGENTS_CHILD === "1") return;
|
|
36
78
|
let context: ExtensionContext | undefined;
|
|
79
|
+
let mountedWidget: MountedWidget | undefined;
|
|
37
80
|
|
|
38
|
-
const
|
|
81
|
+
const refreshUI = () => {
|
|
39
82
|
if (!context?.hasUI) return;
|
|
40
83
|
const schedules = scheduler.list();
|
|
41
|
-
|
|
42
|
-
|
|
84
|
+
if (context.mode !== "tui") {
|
|
85
|
+
const next = schedules[0];
|
|
86
|
+
context.ui.setStatus(WIDGET_KEY, next ? `⏱ ${schedules.length} schedule${schedules.length === 1 ? "" : "s"} · next ${formatTime(next.nextRunAt)}` : undefined);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
context.ui.setStatus(WIDGET_KEY, undefined);
|
|
90
|
+
if (schedules.length === 0) {
|
|
91
|
+
if (mountedWidget) {
|
|
92
|
+
context.ui.setWidget(WIDGET_KEY, undefined);
|
|
93
|
+
mountedWidget.tui.requestRender();
|
|
94
|
+
mountedWidget = undefined;
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (mountedWidget) {
|
|
99
|
+
mountedWidget.component.invalidate();
|
|
100
|
+
mountedWidget.tui.requestRender();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
context.ui.setWidget(WIDGET_KEY, (tui, theme) => {
|
|
104
|
+
const component = new ScheduleWidget(() => scheduler.list(), theme);
|
|
105
|
+
mountedWidget = { component, tui };
|
|
106
|
+
return component;
|
|
107
|
+
}, { placement: "aboveEditor" });
|
|
43
108
|
};
|
|
44
109
|
|
|
45
110
|
const scheduler = new Scheduler({
|
|
46
111
|
clock: systemClock,
|
|
47
|
-
onChange:
|
|
112
|
+
onChange: refreshUI,
|
|
48
113
|
onTrigger(schedule, source) {
|
|
49
114
|
pi.sendMessage({
|
|
50
115
|
customType: "scheduler-trigger",
|
|
@@ -55,13 +120,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
55
120
|
},
|
|
56
121
|
});
|
|
57
122
|
|
|
58
|
-
const perform = (params: { action:
|
|
123
|
+
const perform = (params: { action: ScheduleAction; every?: string; prompt?: string; id?: string }) => {
|
|
59
124
|
if (params.action === "add") {
|
|
60
125
|
if (!params.every || !params.prompt) throw new Error("add requires every and prompt");
|
|
61
126
|
const item = scheduler.add(params.every, params.prompt);
|
|
62
127
|
return `Created ${item.id}: every ${item.every}; next ${formatTime(item.nextRunAt)}.`;
|
|
63
128
|
}
|
|
64
129
|
if (params.action === "list") return formatList(scheduler.list());
|
|
130
|
+
if (params.action === "clear") {
|
|
131
|
+
const count = scheduler.clear();
|
|
132
|
+
return count === 0 ? "No active schedules to clear." : `Cleared ${count} schedule${count === 1 ? "" : "s"}. Already queued prompts cannot be withdrawn.`;
|
|
133
|
+
}
|
|
65
134
|
if (!params.id) throw new Error(`${params.action} requires id`);
|
|
66
135
|
if (params.action === "cancel") {
|
|
67
136
|
if (!scheduler.cancel(params.id)) throw new Error(`unknown schedule: ${params.id}`);
|
|
@@ -77,7 +146,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
77
146
|
|
|
78
147
|
pi.on("session_start", async (_event, ctx) => {
|
|
79
148
|
context = ctx;
|
|
80
|
-
|
|
149
|
+
refreshUI();
|
|
81
150
|
});
|
|
82
151
|
pi.on("message_start", async (event) => {
|
|
83
152
|
if (event.message.role !== "custom" || event.message.customType !== "scheduler-trigger") return;
|
|
@@ -91,14 +160,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
91
160
|
});
|
|
92
161
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
93
162
|
scheduler.clear();
|
|
94
|
-
if (ctx.hasUI)
|
|
163
|
+
if (ctx.hasUI) {
|
|
164
|
+
ctx.ui.setStatus(WIDGET_KEY, undefined);
|
|
165
|
+
if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
166
|
+
}
|
|
167
|
+
mountedWidget = undefined;
|
|
95
168
|
context = undefined;
|
|
96
169
|
});
|
|
97
170
|
|
|
98
171
|
pi.registerTool({
|
|
99
172
|
name: "schedule",
|
|
100
173
|
label: "Schedule",
|
|
101
|
-
description: "Manage session-scoped interval prompts for the main agent. Use add when the user asks the agent to check something repeatedly. Use trigger for one immediate check without changing the regular cadence. Schedules end with the current session.",
|
|
174
|
+
description: "Manage session-scoped interval prompts for the main agent. Use add when the user asks the agent to check something repeatedly, cancel for one schedule, and clear for all schedules. Use trigger for one immediate check without changing the regular cadence. Schedules end with the current session.",
|
|
102
175
|
parameters: Params,
|
|
103
176
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
104
177
|
async execute(_toolCallId, params) {
|
|
@@ -120,7 +193,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
120
193
|
});
|
|
121
194
|
|
|
122
195
|
pi.registerCommand("schedule", {
|
|
123
|
-
description: "Add, list, trigger, or
|
|
196
|
+
description: "Add, list, trigger, cancel, or clear session-scoped interval prompts",
|
|
124
197
|
handler: async (args, ctx) => {
|
|
125
198
|
try {
|
|
126
199
|
const text = perform(parseCommand(args));
|
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-scheduler",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Session-scoped interval prompts for Pi agents.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"keywords": [
|
|
7
|
-
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"scheduler"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
"extensions",
|
|
13
|
+
"src",
|
|
14
|
+
"docs",
|
|
15
|
+
"README.md",
|
|
16
|
+
"TESTING.md",
|
|
17
|
+
"CHANGELOG.md"
|
|
18
|
+
],
|
|
8
19
|
"repository": {
|
|
9
20
|
"type": "git",
|
|
10
21
|
"url": "git+https://github.com/chaochaoxiaochao/pi-toolkit.git",
|
|
11
22
|
"directory": "packages/scheduler"
|
|
12
23
|
},
|
|
13
|
-
"pi": {
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./extensions/scheduler.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
14
29
|
"peerDependencies": {
|
|
15
30
|
"@earendil-works/pi-ai": "*",
|
|
16
31
|
"@earendil-works/pi-coding-agent": "*",
|
|
@@ -21,5 +36,7 @@
|
|
|
21
36
|
"test": "node --experimental-strip-types tests/scheduler-core.mjs && node --experimental-strip-types tests/scheduler-extension.mjs",
|
|
22
37
|
"load-test": "pi -ne -e ./extensions/scheduler.ts"
|
|
23
38
|
},
|
|
24
|
-
"publishConfig": {
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
25
42
|
}
|
|
@@ -122,12 +122,14 @@ export class Scheduler {
|
|
|
122
122
|
return released;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
clear():
|
|
125
|
+
clear(): number {
|
|
126
|
+
const count = this.schedules.size;
|
|
126
127
|
for (const schedule of this.schedules.values()) {
|
|
127
128
|
if (schedule.timer !== undefined) this.clock.clearTimeout(schedule.timer);
|
|
128
129
|
}
|
|
129
130
|
this.schedules.clear();
|
|
130
|
-
this.onChange();
|
|
131
|
+
if (count > 0) this.onChange();
|
|
132
|
+
return count;
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
private arm(schedule: Schedule): void {
|
|
@@ -59,5 +59,12 @@ assert.equal(scheduler.trigger(added.id).ok, true, "a settled run releases a fol
|
|
|
59
59
|
assert.equal(scheduler.cancel(added.id), true);
|
|
60
60
|
assert.equal(scheduler.cancel(added.id), false);
|
|
61
61
|
assert.equal(scheduler.list().length, 0);
|
|
62
|
+
const firstClear = scheduler.add("1h", "Check build");
|
|
63
|
+
const secondClear = scheduler.add("2h", "Check deployment");
|
|
64
|
+
assert.notEqual(firstClear.id, secondClear.id);
|
|
65
|
+
assert.equal(clock.timers.size, 2);
|
|
66
|
+
assert.equal(scheduler.clear(), 2);
|
|
67
|
+
assert.equal(clock.timers.size, 0, "clear cancels every timer");
|
|
68
|
+
assert.equal(scheduler.clear(), 0);
|
|
62
69
|
assert.ok(changes >= 5);
|
|
63
70
|
console.log("Scheduler core tests passed");
|
|
@@ -6,6 +6,9 @@ const commands = new Map();
|
|
|
6
6
|
const messages = [];
|
|
7
7
|
const statuses = new Map();
|
|
8
8
|
let tool;
|
|
9
|
+
const widgets = new Map([["herdr-subagents", { render: () => ["Herdr Fleet"] }]]);
|
|
10
|
+
const widgetOptions = new Map([["herdr-subagents", { placement: "belowEditor" }]]);
|
|
11
|
+
let renderRequests = 0;
|
|
9
12
|
const originalSetTimeout = globalThis.setTimeout;
|
|
10
13
|
const originalClearTimeout = globalThis.clearTimeout;
|
|
11
14
|
let nextTimer = 1;
|
|
@@ -34,18 +37,34 @@ const notifications = [];
|
|
|
34
37
|
const asAppMessage = ({ message }) => ({ ...message, role: "custom" });
|
|
35
38
|
const context = {
|
|
36
39
|
hasUI: true,
|
|
40
|
+
mode: "tui",
|
|
37
41
|
ui: {
|
|
38
42
|
setStatus(key, value) { if (value === undefined) statuses.delete(key); else statuses.set(key, value); },
|
|
43
|
+
setWidget(key, value, options) {
|
|
44
|
+
if (value === undefined) {
|
|
45
|
+
widgets.delete(key);
|
|
46
|
+
widgetOptions.delete(key);
|
|
47
|
+
} else {
|
|
48
|
+
widgets.set(key, value({ requestRender() { renderRequests += 1; } }, { fg(_color, text) { return text; } }));
|
|
49
|
+
widgetOptions.set(key, options);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
39
52
|
notify(message, level) { notifications.push({ message, level }); },
|
|
40
53
|
},
|
|
41
54
|
};
|
|
42
55
|
await handlers.get("session_start")({}, context);
|
|
43
56
|
assert.ok(tool);
|
|
44
57
|
assert.ok(commands.has("schedule"));
|
|
58
|
+
assert.deepEqual(tool.parameters.properties.action.enum, ["add", "list", "trigger", "cancel", "clear"]);
|
|
45
59
|
|
|
46
60
|
const add = await tool.execute("add", { action: "add", every: "1h", prompt: "Check CI" });
|
|
47
61
|
assert.match(add.content[0].text, /Created schedule-1/);
|
|
48
|
-
assert.
|
|
62
|
+
assert.equal(statuses.has("scheduler"), false, "scheduler no longer competes for footer status space");
|
|
63
|
+
assert.deepEqual(widgetOptions.get("scheduler"), { placement: "aboveEditor" });
|
|
64
|
+
assert.deepEqual(widgetOptions.get("herdr-subagents"), { placement: "belowEditor" });
|
|
65
|
+
assert.match(widgets.get("scheduler").render(100).join("\n"), /1 active schedule/);
|
|
66
|
+
assert.match(widgets.get("scheduler").render(100).join("\n"), /schedule-1 · 1h · next/);
|
|
67
|
+
assert.ok(widgets.has("herdr-subagents"), "Scheduler mounting preserves the Herdr Fleet widget");
|
|
49
68
|
|
|
50
69
|
const trigger = await tool.execute("trigger", { action: "trigger", id: "schedule-1" });
|
|
51
70
|
assert.match(trigger.content[0].text, /Triggered schedule-1/);
|
|
@@ -71,11 +90,28 @@ await handlers.get("message_start")({ message: asAppMessage(messages[2]) });
|
|
|
71
90
|
await commands.get("schedule").handler("cancel schedule-1", context);
|
|
72
91
|
assert.match(notifications.at(-1).message, /Already queued prompts cannot be withdrawn/);
|
|
73
92
|
assert.equal(statuses.has("scheduler"), false);
|
|
74
|
-
|
|
93
|
+
assert.equal(widgets.has("scheduler"), false, "removing the final schedule removes the multi-line widget");
|
|
94
|
+
assert.ok(widgets.has("herdr-subagents"), "Scheduler cleanup preserves the Herdr Fleet widget");
|
|
95
|
+
await tool.execute("clear-add-one", { action: "add", every: "2h", prompt: "Check deployment" });
|
|
96
|
+
await tool.execute("clear-add-two", { action: "add", every: "3h", prompt: "Check release" });
|
|
97
|
+
assert.match(widgets.get("scheduler").render(100).join("\n"), /2 active schedules/);
|
|
98
|
+
assert.match(widgets.get("scheduler").render(100).join("\n"), /schedule-2/);
|
|
99
|
+
assert.match(widgets.get("scheduler").render(100).join("\n"), /schedule-3/);
|
|
100
|
+
await commands.get("schedule").handler("clear", context);
|
|
101
|
+
assert.match(notifications.at(-1).message, /Cleared 2 schedules/);
|
|
102
|
+
assert.equal(timers.size, 0, "clear cancels every active timer");
|
|
103
|
+
assert.equal(widgets.has("scheduler"), false, "clear removes the widget");
|
|
104
|
+
assert.ok(widgets.has("herdr-subagents"), "clear does not remove Herdr Fleet");
|
|
105
|
+
const emptyClear = await tool.execute("empty-clear", { action: "clear" });
|
|
106
|
+
assert.equal(emptyClear.content[0].text, "No active schedules to clear.");
|
|
107
|
+
await tool.execute("shutdown-add", { action: "add", every: "4h", prompt: "Check shutdown" });
|
|
75
108
|
assert.equal(timers.size, 1, "active schedule owns one timer");
|
|
76
109
|
await handlers.get("session_shutdown")({}, context);
|
|
77
110
|
assert.equal(statuses.has("scheduler"), false);
|
|
78
111
|
assert.equal(timers.size, 0, "session shutdown clears active timers");
|
|
112
|
+
assert.equal(widgets.has("scheduler"), false);
|
|
113
|
+
assert.ok(widgets.has("herdr-subagents"), "Scheduler shutdown does not remove Herdr Fleet");
|
|
114
|
+
assert.ok(renderRequests >= 4, "schedule changes request widget rerenders");
|
|
79
115
|
globalThis.setTimeout = originalSetTimeout;
|
|
80
116
|
globalThis.clearTimeout = originalClearTimeout;
|
|
81
117
|
console.log("Scheduler extension integration tests passed");
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026-10-09 - v0.2.0
|
|
4
|
+
|
|
5
|
+
- add current-session worktree switching for users, Agents, and SDK Harnesses
|
|
6
|
+
- 新增 `/worktree start` 用户命令,在当前 Pi 界面中把持久会话 fork 并切换到 worktree。
|
|
7
|
+
- 新增 `enter_worktree` Agent Tool 和 `pi-worktree/switch-request` Harness 协议。
|
|
8
|
+
- 新增幂等的 `pi-worktree prepare --json` 机器接口,安全处理创建、挂载、复用、dirty 和 stale 状态。
|
|
9
|
+
|
|
3
10
|
## 2026-09-30 - v0.1.0
|
|
4
11
|
|
|
5
12
|
- 将 pi-worktree CLI、bash completion 和 Pi skill 合并为可独立安装的子包。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @maxiaochao/pi-worktree
|
|
2
2
|
|
|
3
|
-
`pi-worktree` CLI 与配套 Pi
|
|
3
|
+
`pi-worktree` CLI、Pi Extension 与配套 skill。它既能启动独立 Pi,也能让用户在当前界面切换 worktree,或让 Agent 向 SDK Harness 发出结构化切换请求。
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
@@ -14,16 +14,28 @@
|
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
pi-worktree start feature -p "implement the feature"
|
|
17
|
+
pi-worktree prepare feature --json
|
|
17
18
|
pi-worktree info feature
|
|
18
19
|
pi-worktree out
|
|
19
20
|
pi-worktree remove feature
|
|
20
21
|
```
|
|
21
22
|
|
|
22
|
-
`start` 从当前分支创建或复用 `.worktrees/feature`,记录 base,并在该目录启动 Pi。
|
|
23
|
+
`start` 从当前分支创建或复用 `.worktrees/feature`,记录 base,并在该目录启动 Pi。`prepare` 复用同一创建逻辑,但只返回目标,不启动嵌套 Pi。
|
|
24
|
+
|
|
25
|
+
在交互式 Pi 中,用户可以保留当前界面和完整对话:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
/worktree start feature
|
|
29
|
+
/worktree start feature --base main
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
命令会把当前持久会话 fork 到目标 worktree,再通过 Pi 的公开 session replacement API 切换。临时的 `--no-session` 会话无法使用此入口。
|
|
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` 完成切换。
|
|
23
35
|
|
|
24
36
|
## 安装
|
|
25
37
|
|
|
26
|
-
完整工具包已经包含 CLI 和 skill:
|
|
38
|
+
完整工具包已经包含 CLI、Extension 和 skill:
|
|
27
39
|
|
|
28
40
|
```bash
|
|
29
41
|
pi install npm:@maxiaochao/pi-toolkit
|
|
@@ -50,6 +62,7 @@ pi update npm:@maxiaochao/pi-worktree # 更新独立子包
|
|
|
50
62
|
|
|
51
63
|
```bash
|
|
52
64
|
pi-worktree start <name> [pi args...]
|
|
65
|
+
pi-worktree prepare [--base <ref>] [--json] <name>
|
|
53
66
|
pi-worktree list
|
|
54
67
|
pi-worktree info <name>
|
|
55
68
|
pi-worktree out
|
|
@@ -57,4 +70,6 @@ pi-worktree remove <name>
|
|
|
57
70
|
pi-worktree prune
|
|
58
71
|
```
|
|
59
72
|
|
|
73
|
+
`prepare --json` 是 Extension 和其他宿主使用的幂等机器接口,返回 `created`、`attached`、`reused` 或 `already-active`,以及绝对路径、分支和 dirty 状态。已有 worktree 会复用;已存在但未挂载的同名分支会挂载;stale 注册、非法名称和路径冲突会显式失败。
|
|
74
|
+
|
|
60
75
|
Agent 用法见 `skills/pi-worktree/SKILL.md`。测试流程、夹具和结果见 [TESTING.md](TESTING.md)。
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## 流程
|
|
4
4
|
|
|
5
|
-
1. `npm --prefix packages/worktree test`:在临时 Git
|
|
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。
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
- `--help` 输出命令说明。
|
|
13
13
|
- `start` 从当前分支创建 `.worktrees/<name>` 并记录 base。
|
|
14
|
+
- `prepare --json` 覆盖 created、reused、already-active、dirty 和非法名称安全失败。
|
|
15
|
+
- `/worktree start` 把历史 fork 到 cwd 正确的新 session,再调用 `switchSession`。
|
|
16
|
+
- `enter_worktree` 返回带 `pi-worktree/switch-request`、源 session、目标路径和 dirty 状态的 structured content,并请求终止当前工具轮。
|
|
14
17
|
- 假 Pi 收到正确工作目录和参数。
|
|
15
18
|
- `list` 标记主 checkout 和子 worktree。
|
|
16
19
|
- `info` 报告 clean、branch、base 和 ahead。
|
|
@@ -18,4 +21,4 @@
|
|
|
18
21
|
|
|
19
22
|
## 最近验证结果
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
验收要求全部 Node/shell 断言通过,Extension 能被 Pi 单独加载,且 `npm pack --dry-run` 包含 `bin/`、`extensions/`、`skills/` 和 `scripts/install.mjs`。
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
# Usage:
|
|
5
5
|
# pi-worktree start <name> [pi args...] # enter worktree <name> and start pi
|
|
6
6
|
# pi-worktree start --base <ref> <name> [pi args...] # branch from <ref> instead of current branch
|
|
7
|
+
# pi-worktree prepare [--base <ref>] [--json] <name> # create/reuse without launching pi
|
|
7
8
|
# pi-worktree -l | --list # list worktrees (main checkout marked "main checkout")
|
|
8
9
|
# pi-worktree -i <name> # show base commit, ahead/behind, unique commits
|
|
9
10
|
# pi-worktree -o | --out # cd back to the main checkout
|
|
@@ -18,7 +19,7 @@ else
|
|
|
18
19
|
C_BOLD='' C_DIM='' C_RED='' C_GREEN='' C_YELLOW='' C_CYAN='' C_RESET=''
|
|
19
20
|
fi
|
|
20
21
|
|
|
21
|
-
ok() { printf '%spi-worktree:%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
|
|
22
|
+
ok() { [[ "${QUIET:-0}" == 1 ]] || printf '%spi-worktree:%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
|
|
22
23
|
warn() { printf '%s⚠️ %s%s\n' "$C_YELLOW" "$C_RESET" "$*"; }
|
|
23
24
|
die() { printf '%spi-worktree: %s%s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; }
|
|
24
25
|
|
|
@@ -40,6 +41,12 @@ in_linked_worktree() {
|
|
|
40
41
|
|
|
41
42
|
wt_dir() { echo ".worktrees/$1"; }
|
|
42
43
|
|
|
44
|
+
validate_name() {
|
|
45
|
+
local name="$1"
|
|
46
|
+
[[ "$name" != */* ]] || die "worktree name must not contain '/': $name"
|
|
47
|
+
git check-ref-format --branch "$name" >/dev/null 2>&1 || die "invalid worktree/branch name: $name"
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
# Best available base ref: current branch > HEAD (worktrees should branch
|
|
44
51
|
# from where you actually are; a stale origin/main must not win)
|
|
45
52
|
pick_base() {
|
|
@@ -63,12 +70,14 @@ resolve_base() {
|
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
ensure_gitignore() {
|
|
73
|
+
local root
|
|
74
|
+
root=$(main_checkout)
|
|
66
75
|
# The trailing slash is load-bearing: `.worktrees/` is a directory-only
|
|
67
76
|
# pattern, and git check-ignore cannot match it against a path that does not
|
|
68
77
|
# exist yet — so `.worktrees` would look unignored on every first run and the
|
|
69
78
|
# rule would be appended again.
|
|
70
|
-
if ! git check-ignore -q .worktrees/ 2>/dev/null; then
|
|
71
|
-
printf '.worktrees/\n' >>
|
|
79
|
+
if ! git -C "$root" check-ignore -q .worktrees/ 2>/dev/null; then
|
|
80
|
+
printf '.worktrees/\n' >> "$root/.gitignore"
|
|
72
81
|
ok "added .worktrees/ to .gitignore"
|
|
73
82
|
fi
|
|
74
83
|
}
|
|
@@ -226,55 +235,140 @@ cmd_remove() {
|
|
|
226
235
|
fi
|
|
227
236
|
}
|
|
228
237
|
|
|
229
|
-
|
|
230
|
-
local name="$1"
|
|
238
|
+
prepare_wt() {
|
|
239
|
+
local name="$1"
|
|
231
240
|
require_repo
|
|
241
|
+
validate_name "$name"
|
|
232
242
|
|
|
233
|
-
#
|
|
243
|
+
# A linked checkout cannot safely create another `.worktrees/` tree relative
|
|
244
|
+
# to itself. Re-entering the active branch is nevertheless idempotent.
|
|
234
245
|
if in_linked_worktree; then
|
|
235
|
-
|
|
236
|
-
|
|
246
|
+
local current_branch
|
|
247
|
+
current_branch=$(git branch --show-current)
|
|
248
|
+
[[ "$current_branch" == "$name" ]] \
|
|
249
|
+
|| die "already inside linked worktree '$current_branch'; return to the main checkout first"
|
|
250
|
+
PREPARE_STATUS="already-active"
|
|
251
|
+
PREPARE_DIR=$(pwd -P)
|
|
252
|
+
PREPARE_BRANCH="$current_branch"
|
|
253
|
+
PREPARE_DIRTY=false
|
|
254
|
+
if [[ -n "$(git status --porcelain)" ]]; then PREPARE_DIRTY=true; fi
|
|
255
|
+
return 0
|
|
237
256
|
fi
|
|
238
257
|
|
|
239
|
-
local
|
|
258
|
+
local root worktrees_dir dir
|
|
259
|
+
root=$(main_checkout)
|
|
260
|
+
worktrees_dir="$root/.worktrees"
|
|
261
|
+
dir="$worktrees_dir/$name"
|
|
262
|
+
[[ ! -L "$worktrees_dir" ]] || die "$worktrees_dir must be a real directory, not a symlink"
|
|
263
|
+
[[ ! -L "$dir" ]] || die "$dir must not be a symlink"
|
|
240
264
|
|
|
241
265
|
if [[ -d "$dir" ]]; then
|
|
242
266
|
# Reuse: must be a registered worktree for this repo.
|
|
243
267
|
# Skip the dirty-workspace prompt — the worktree already exists, nothing new to create.
|
|
268
|
+
local registered_path
|
|
269
|
+
registered_path=$(git worktree list --porcelain | sed -n 's/^worktree //p' | grep -Fx "$dir" || true)
|
|
270
|
+
[[ -n "$registered_path" ]] || die "$dir exists but is not a registered git worktree"
|
|
244
271
|
git -C "$dir" rev-parse --git-dir >/dev/null 2>&1 || die "$dir exists but is not a git worktree"
|
|
272
|
+
[[ "$(git -C "$dir" rev-parse --path-format=absolute --git-common-dir)" == "$(git rev-parse --path-format=absolute --git-common-dir)" ]] \
|
|
273
|
+
|| die "$dir belongs to a different git repository"
|
|
274
|
+
[[ "$(git -C "$dir" branch --show-current)" == "$name" ]] \
|
|
275
|
+
|| die "$dir is not checked out on expected branch '$name'"
|
|
245
276
|
ensure_gitignore
|
|
246
277
|
ok "reusing existing worktree $dir"
|
|
278
|
+
PREPARE_STATUS="reused"
|
|
247
279
|
else
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
[[ "$ans" =~ ^[Yy]$ ]] || die "aborted"
|
|
254
|
-
fi
|
|
255
|
-
|
|
256
|
-
# After the check above, so the tool's own .gitignore edit is not reported
|
|
257
|
-
# as (and does not prompt about) the user's uncommitted work.
|
|
280
|
+
local target_abs registered_path
|
|
281
|
+
target_abs="$dir"
|
|
282
|
+
registered_path=$(git worktree list --porcelain | sed -n 's/^worktree //p' | grep -Fx "$target_abs" || true)
|
|
283
|
+
[[ -z "$registered_path" ]] \
|
|
284
|
+
|| die "stale worktree registration for $dir; run 'pi-worktree prune' first"
|
|
258
285
|
ensure_gitignore
|
|
259
|
-
mkdir -p
|
|
286
|
+
mkdir -p "$worktrees_dir"
|
|
260
287
|
# Reuse branch if it exists, else create it
|
|
261
288
|
if git show-ref --verify --quiet "refs/heads/$name"; then
|
|
262
|
-
|
|
289
|
+
if [[ "${QUIET:-0}" == 1 ]]; then
|
|
290
|
+
git worktree add "$dir" "$name" >/dev/null
|
|
291
|
+
else
|
|
292
|
+
git worktree add "$dir" "$name"
|
|
293
|
+
fi
|
|
263
294
|
ok "checked out existing branch '$name'"
|
|
295
|
+
PREPARE_STATUS="attached"
|
|
264
296
|
# record a best-effort base for -i display
|
|
265
297
|
local mb; mb=$(git merge-base main "$name" 2>/dev/null || true)
|
|
266
298
|
[[ -n "$mb" ]] && git config "branch.$name.base" "$(git rev-parse --short "$mb")" \
|
|
267
299
|
&& git config "branch.$name.baseRef" main
|
|
268
300
|
else
|
|
269
301
|
local base; base=$(resolve_base)
|
|
270
|
-
|
|
302
|
+
if [[ "${QUIET:-0}" == 1 ]]; then
|
|
303
|
+
git worktree add "$dir" -b "$name" "$base" >/dev/null
|
|
304
|
+
else
|
|
305
|
+
git worktree add "$dir" -b "$name" "$base"
|
|
306
|
+
fi
|
|
271
307
|
git config "branch.$name.base" "$(git rev-parse --short "$base")"
|
|
272
308
|
git config "branch.$name.baseRef" "$base"
|
|
273
309
|
ok "created branch '$name' from $base @ $(git rev-parse --short "$base")"
|
|
310
|
+
PREPARE_STATUS="created"
|
|
274
311
|
fi
|
|
275
312
|
fi
|
|
276
313
|
|
|
277
|
-
cd "$dir"
|
|
314
|
+
PREPARE_DIR=$(cd "$dir" && pwd -P)
|
|
315
|
+
PREPARE_BRANCH=$(git -C "$dir" branch --show-current)
|
|
316
|
+
PREPARE_DIRTY=false
|
|
317
|
+
if [[ -n "$(git -C "$dir" status --porcelain)" ]]; then PREPARE_DIRTY=true; fi
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
print_prepare_json() {
|
|
321
|
+
PI_WORKTREE_STATUS="$PREPARE_STATUS" \
|
|
322
|
+
PI_WORKTREE_PATH="$PREPARE_DIR" \
|
|
323
|
+
PI_WORKTREE_BRANCH="$PREPARE_BRANCH" \
|
|
324
|
+
PI_WORKTREE_DIRTY="$PREPARE_DIRTY" \
|
|
325
|
+
node -e 'process.stdout.write(JSON.stringify({status:process.env.PI_WORKTREE_STATUS,path:process.env.PI_WORKTREE_PATH,branch:process.env.PI_WORKTREE_BRANCH,dirty:process.env.PI_WORKTREE_DIRTY==="true"})+"\n")'
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
cmd_prepare() {
|
|
329
|
+
BASE_OVERRIDE=""
|
|
330
|
+
local json=0
|
|
331
|
+
while [[ $# -gt 0 ]]; do
|
|
332
|
+
case "$1" in
|
|
333
|
+
--base)
|
|
334
|
+
shift; [[ $# -ge 1 ]] || die "usage: pi-worktree prepare [--base <ref>] [--json] <name>"
|
|
335
|
+
BASE_OVERRIDE="$1"; shift ;;
|
|
336
|
+
--json) json=1; QUIET=1; shift ;;
|
|
337
|
+
--*) die "unknown prepare option: $1" ;;
|
|
338
|
+
*) break ;;
|
|
339
|
+
esac
|
|
340
|
+
done
|
|
341
|
+
[[ $# -eq 1 ]] || die "usage: pi-worktree prepare [--base <ref>] [--json] <name>"
|
|
342
|
+
prepare_wt "$1"
|
|
343
|
+
if [[ "$json" == 1 ]]; then
|
|
344
|
+
print_prepare_json
|
|
345
|
+
else
|
|
346
|
+
ok "$PREPARE_STATUS worktree: $PREPARE_DIR ($PREPARE_BRANCH)"
|
|
347
|
+
fi
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
cd_wt() {
|
|
351
|
+
local name="$1"; shift
|
|
352
|
+
require_repo
|
|
353
|
+
|
|
354
|
+
# Already isolated? Preserve the original start behavior and launch Pi here.
|
|
355
|
+
if in_linked_worktree; then
|
|
356
|
+
ok "already in an isolated worktree ($(pwd)), launching pi here"
|
|
357
|
+
exec pi "$@"
|
|
358
|
+
fi
|
|
359
|
+
|
|
360
|
+
# Dirty workspace: uncommitted changes do NOT follow into the new worktree.
|
|
361
|
+
local dir; dir="$(main_checkout)/.worktrees/$name"
|
|
362
|
+
if [[ ! -d "$dir" ]] && { ! git diff --quiet || ! git diff --cached --quiet || [[ -n "$(git ls-files --others --exclude-standard)" ]]; }; then
|
|
363
|
+
warn "Uncommitted changes stay in THIS directory; they will NOT appear in the worktree:"
|
|
364
|
+
git status --short | head -20
|
|
365
|
+
read -rp "Continue? [y/N] " ans
|
|
366
|
+
[[ "$ans" =~ ^[Yy]$ ]] || die "aborted"
|
|
367
|
+
fi
|
|
368
|
+
|
|
369
|
+
prepare_wt "$name"
|
|
370
|
+
|
|
371
|
+
cd "$PREPARE_DIR"
|
|
278
372
|
exec pi "$@"
|
|
279
373
|
}
|
|
280
374
|
|
|
@@ -285,6 +379,7 @@ case "${1:-}" in
|
|
|
285
379
|
-r|--remove|rm|remove) shift; cmd_remove "$@" ;;
|
|
286
380
|
-p|--prune|prune) shift; subcmd_prune ;;
|
|
287
381
|
-h|--help|help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//' ;;
|
|
382
|
+
prepare) shift; cmd_prepare "$@" ;;
|
|
288
383
|
start) shift
|
|
289
384
|
BASE_OVERRIDE=""
|
|
290
385
|
if [[ "${1:-}" == "--base" ]]; then
|
|
@@ -4,7 +4,7 @@ _pi_worktree() {
|
|
|
4
4
|
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
5
5
|
first="${COMP_WORDS[1]}"
|
|
6
6
|
|
|
7
|
-
local SUBS="start list ls info out remove rm prune -l --list -i --info -o --out -r --remove -p --prune -h --help"
|
|
7
|
+
local SUBS="start prepare list ls info out remove rm prune -l --list -i --info -o --out -r --remove -p --prune -h --help"
|
|
8
8
|
|
|
9
9
|
# existing worktree names (only linked worktrees, i.e. under .worktrees/)
|
|
10
10
|
local wts=""
|
|
@@ -23,7 +23,7 @@ _pi_worktree() {
|
|
|
23
23
|
fi
|
|
24
24
|
|
|
25
25
|
case "$first" in
|
|
26
|
-
-i|--info|info|-r|--remove|remove|rm|start)
|
|
26
|
+
-i|--info|info|-r|--remove|remove|rm|start|prepare)
|
|
27
27
|
# only the second word of info/remove completes worktree names;
|
|
28
28
|
# later words offer nothing (info/remove take no further args)
|
|
29
29
|
if (( COMP_CWORD == 2 )); then
|