pi-thread-engine 0.4.5 → 0.4.7
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/PLAN.md +53 -33
- package/extensions/index.ts +228 -3
- package/package.json +1 -1
- package/src/core/executor.ts +296 -220
- package/src/core/registry.ts +2 -0
- package/src/core/types.ts +4 -1
- package/src/core/worktree.ts +263 -0
- package/src/dashboard.ts +430 -421
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree management for pi-thread-engine
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Grok CLI's `isolation: "worktree"` pattern.
|
|
5
|
+
* Each worktree gets its own branch + working directory so parallel
|
|
6
|
+
* agents never conflict on file edits.
|
|
7
|
+
*
|
|
8
|
+
* Worktrees stored under `<repo>/.git/worktrees-pi/` for easy cleanup.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync, exec } from "child_process";
|
|
12
|
+
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "fs";
|
|
13
|
+
import { join, resolve } from "path";
|
|
14
|
+
import { homedir } from "os";
|
|
15
|
+
|
|
16
|
+
export interface WorktreeInfo {
|
|
17
|
+
path: string;
|
|
18
|
+
branch: string;
|
|
19
|
+
threadId: string;
|
|
20
|
+
createdAt: number;
|
|
21
|
+
ahead: number;
|
|
22
|
+
behind: number;
|
|
23
|
+
dirty: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const WORKTREE_DIR = ".git/worktrees-pi";
|
|
27
|
+
let worktreeRegistry: WorktreeInfo[] = [];
|
|
28
|
+
|
|
29
|
+
function loadRegistry(repoPath: string): WorktreeInfo[] {
|
|
30
|
+
const regPath = join(repoPath, WORKTREE_DIR, "registry.json");
|
|
31
|
+
if (existsSync(regPath)) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(regPath, "utf8"));
|
|
34
|
+
} catch { /* corrupt, start fresh */ }
|
|
35
|
+
}
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function saveRegistry(repoPath: string, reg: WorktreeInfo[]) {
|
|
40
|
+
const regDir = join(repoPath, WORKTREE_DIR);
|
|
41
|
+
if (!existsSync(regDir)) mkdirSync(regDir, { recursive: true });
|
|
42
|
+
writeFileSync(join(regDir, "registry.json"), JSON.stringify(reg, null, 2), "utf8");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolve the git repo root from a path */
|
|
46
|
+
export function findRepoRoot(cwd: string): string | null {
|
|
47
|
+
try {
|
|
48
|
+
return execSync("git rev-parse --show-toplevel", { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Check if we're in a git repo */
|
|
55
|
+
export function isGitRepo(cwd: string): boolean {
|
|
56
|
+
return findRepoRoot(cwd) !== null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Generate a unique branch name for a thread */
|
|
60
|
+
export function branchName(threadId: string): string {
|
|
61
|
+
const safe = threadId.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
62
|
+
return `pi-thread/${safe}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Worktree storage path for a thread */
|
|
66
|
+
export function worktreePath(repoPath: string, threadId: string): string {
|
|
67
|
+
return join(repoPath, WORKTREE_DIR, threadId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create a worktree for a thread.
|
|
72
|
+
* Creates a new branch off the current HEAD, checks it out in an isolated dir.
|
|
73
|
+
*/
|
|
74
|
+
export function createWorktree(cwd: string, threadId: string, baseBranch?: string): WorktreeInfo {
|
|
75
|
+
const repoPath = findRepoRoot(cwd);
|
|
76
|
+
if (!repoPath) throw new Error("Not in a git repository");
|
|
77
|
+
|
|
78
|
+
const branch = branchName(threadId);
|
|
79
|
+
const wtPath = worktreePath(repoPath, threadId);
|
|
80
|
+
|
|
81
|
+
// Create the branch if it doesn't exist
|
|
82
|
+
try {
|
|
83
|
+
execSync(`git branch ${branch} ${baseBranch ?? "HEAD"}`, { cwd: repoPath, encoding: "utf8", stdio: "pipe" });
|
|
84
|
+
} catch {
|
|
85
|
+
// Branch may already exist from a previous run — that's fine
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Create worktree
|
|
89
|
+
execSync(`git worktree add ${wtPath} ${branch}`, { cwd: repoPath, encoding: "utf8", stdio: "pipe" });
|
|
90
|
+
|
|
91
|
+
const info: WorktreeInfo = {
|
|
92
|
+
path: wtPath,
|
|
93
|
+
branch,
|
|
94
|
+
threadId,
|
|
95
|
+
createdAt: Date.now(),
|
|
96
|
+
ahead: 0,
|
|
97
|
+
behind: 0,
|
|
98
|
+
dirty: false,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const reg = loadRegistry(repoPath);
|
|
102
|
+
// Remove any stale entry for this thread
|
|
103
|
+
const filtered = reg.filter((w) => w.threadId !== threadId);
|
|
104
|
+
filtered.push(info);
|
|
105
|
+
saveRegistry(repoPath, filtered);
|
|
106
|
+
worktreeRegistry = filtered;
|
|
107
|
+
|
|
108
|
+
return info;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Remove a worktree and its branch
|
|
113
|
+
*/
|
|
114
|
+
export function removeWorktree(cwd: string, threadId: string): boolean {
|
|
115
|
+
const repoPath = findRepoRoot(cwd);
|
|
116
|
+
if (!repoPath) return false;
|
|
117
|
+
|
|
118
|
+
const branch = branchName(threadId);
|
|
119
|
+
const wtPath = worktreePath(repoPath, threadId);
|
|
120
|
+
|
|
121
|
+
let success = true;
|
|
122
|
+
|
|
123
|
+
// Remove worktree
|
|
124
|
+
try {
|
|
125
|
+
execSync(`git worktree remove ${wtPath}`, { cwd: repoPath, encoding: "utf8", stdio: "pipe" });
|
|
126
|
+
} catch {
|
|
127
|
+
// Force remove if locked
|
|
128
|
+
try {
|
|
129
|
+
execSync(`git worktree remove --force ${wtPath}`, { cwd: repoPath, encoding: "utf8", stdio: "pipe" });
|
|
130
|
+
} catch {
|
|
131
|
+
success = false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Delete branch
|
|
136
|
+
try {
|
|
137
|
+
execSync(`git branch -D ${branch}`, { cwd: repoPath, encoding: "utf8", stdio: "pipe" });
|
|
138
|
+
} catch {
|
|
139
|
+
// Branch may not exist
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Clean up directory
|
|
143
|
+
try {
|
|
144
|
+
rmSync(wtPath, { recursive: true, force: true });
|
|
145
|
+
} catch { /* best effort */ }
|
|
146
|
+
|
|
147
|
+
// Update registry
|
|
148
|
+
const reg = loadRegistry(repoPath);
|
|
149
|
+
const filtered = reg.filter((w) => w.threadId !== threadId);
|
|
150
|
+
saveRegistry(repoPath, filtered);
|
|
151
|
+
worktreeRegistry = filtered;
|
|
152
|
+
|
|
153
|
+
return success;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get divergence stats for a worktree branch
|
|
158
|
+
*/
|
|
159
|
+
function getDivergence(repoPath: string, branch: string): { ahead: number; behind: number } {
|
|
160
|
+
try {
|
|
161
|
+
const ahead = parseInt(
|
|
162
|
+
execSync(`git rev-list --count HEAD..origin/${branch}`, { cwd: repoPath, encoding: "utf8", stdio: "pipe" }).trim() || "0",
|
|
163
|
+
10
|
|
164
|
+
);
|
|
165
|
+
const behind = parseInt(
|
|
166
|
+
execSync(`git rev-list --count origin/${branch}..HEAD`, { cwd: repoPath, encoding: "utf8", stdio: "pipe" }).trim() || "0",
|
|
167
|
+
10
|
|
168
|
+
);
|
|
169
|
+
return { ahead, behind };
|
|
170
|
+
} catch {
|
|
171
|
+
return { ahead: 0, behind: 0 };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Check if a worktree has uncommitted changes
|
|
177
|
+
*/
|
|
178
|
+
function isDirty(path: string): boolean {
|
|
179
|
+
try {
|
|
180
|
+
const status = execSync("git status --porcelain", { cwd: path, encoding: "utf8", stdio: "pipe" }).trim();
|
|
181
|
+
return status.length > 0;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* List all worktrees with their divergence stats
|
|
189
|
+
*/
|
|
190
|
+
export function listWorktrees(cwd: string): WorktreeInfo[] {
|
|
191
|
+
const repoPath = findRepoRoot(cwd);
|
|
192
|
+
if (!repoPath) return [];
|
|
193
|
+
|
|
194
|
+
const reg = loadRegistry(repoPath);
|
|
195
|
+
|
|
196
|
+
// Refresh stats
|
|
197
|
+
return reg.map((w) => {
|
|
198
|
+
const div = getDivergence(repoPath, w.branch);
|
|
199
|
+
const dirty = existsSync(w.path) ? isDirty(w.path) : false;
|
|
200
|
+
return { ...w, ...div, dirty };
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Collect worktree results — reads the output file from a finished thread
|
|
206
|
+
*/
|
|
207
|
+
export function collectWorktreeResult(threadId: string, worktree: WorktreeInfo): string | null {
|
|
208
|
+
const resultFile = join(worktree.path, ".pi-thread-result.json");
|
|
209
|
+
if (existsSync(resultFile)) {
|
|
210
|
+
try {
|
|
211
|
+
return readFileSync(resultFile, "utf8");
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Clean up ALL worktrees for this repo (emergency / force cleanup)
|
|
221
|
+
*/
|
|
222
|
+
export function cleanupAll(cwd: string): { removed: number; failed: number } {
|
|
223
|
+
const repoPath = findRepoRoot(cwd);
|
|
224
|
+
if (!repoPath) return { removed: 0, failed: 0 };
|
|
225
|
+
|
|
226
|
+
const reg = loadRegistry(repoPath);
|
|
227
|
+
let removed = 0;
|
|
228
|
+
let failed = 0;
|
|
229
|
+
|
|
230
|
+
for (const w of reg) {
|
|
231
|
+
if (removeWorktree(cwd, w.threadId)) removed++;
|
|
232
|
+
else failed++;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
saveRegistry(repoPath, []);
|
|
236
|
+
worktreeRegistry = [];
|
|
237
|
+
|
|
238
|
+
return { removed, failed };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Push worktree changes back to the main repo
|
|
243
|
+
*/
|
|
244
|
+
export function pushWorktreeChanges(cwd: string, threadId: string, message?: string): boolean {
|
|
245
|
+
const repoPath = findRepoRoot(cwd);
|
|
246
|
+
if (!repoPath) return false;
|
|
247
|
+
|
|
248
|
+
const wtPath = worktreePath(repoPath, threadId);
|
|
249
|
+
if (!existsSync(wtPath)) return false;
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
// Commit any uncommitted changes
|
|
253
|
+
const status = execSync("git status --porcelain", { cwd: wtPath, encoding: "utf8", stdio: "pipe" }).trim();
|
|
254
|
+
if (status) {
|
|
255
|
+
execSync(`git add -A`, { cwd: wtPath, encoding: "utf8", stdio: "pipe" });
|
|
256
|
+
const defaultMsg = `[pi-threads] ${threadId} worktree changes`;
|
|
257
|
+
execSync(`git commit -m "${message ?? defaultMsg}"`, { cwd: wtPath, encoding: "utf8", stdio: "pipe" });
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
} catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|