pi-thread-engine 0.4.6 → 0.4.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/src/core/types.ts CHANGED
@@ -1,107 +1,107 @@
1
- /** Thread types from the thread engineering framework */
2
- export type ThreadType = "base" | "parallel" | "chained" | "fusion" | "meta" | "long" | "zero";
3
-
4
- /** Thread lifecycle states */
5
- export type ThreadState = "pending" | "running" | "paused" | "completed" | "failed" | "killed" | "needs_input";
6
-
7
- /** How a thread is executed */
8
- export type ExecutionBackend = "subagent" | "native";
9
-
10
- /** A single unit of work within a thread */
11
- export interface ThreadTask {
12
- usage?: { inputTokens: number; outputTokens: number; cacheRead: number; totalTokens: number; cost: number };
13
- id: string;
14
- label: string;
15
- prompt: string;
16
- model?: string;
17
- state: ThreadState;
18
- startedAt?: number;
19
- completedAt?: number;
20
- result?: string;
21
- error?: string;
22
- /** interactive_shell session ID or subagent run ID */
23
- sessionId?: string;
24
- }
25
-
26
- /** Thread configuration */
27
- export interface ThreadConfig {
28
- type: ThreadType;
29
- tasks: ThreadTask[];
30
- /** Execution backend */
31
- backend: ExecutionBackend;
32
- /** For fusion threads: models to compete */
33
- models?: string[];
34
- /** For zero threads: verification command */
35
- verifyCommand?: string;
36
- /** For chained threads: require human checkpoint between phases */
37
- checkpoints?: boolean;
38
- /** Working directory */
39
- cwd?: string;
40
- /** Subagent agent name to use (default: "worker") */
41
- agent?: string;
42
- }
43
-
44
- /** A thread — the fundamental unit of tracked work */
45
- export interface Thread {
46
- id: string;
47
- type: ThreadType;
48
- label: string;
49
- state: ThreadState;
50
- config: ThreadConfig;
51
- tasks: ThreadTask[];
52
- createdAt: number;
53
- startedAt?: number;
54
- completedAt?: number;
55
- /** Duration in ms */
56
- duration?: number;
57
- }
58
-
59
- /** A story — a goal decomposed into thread phases */
60
- export interface Story {
61
- id: string;
62
- goal: string;
63
- state: "planning" | "executing" | "verifying" | "done" | "failed";
64
- phases: StoryPhase[];
65
- createdAt: number;
66
- completedAt?: number;
67
- /** Verification command */
68
- verify?: string;
69
- /** Files changed */
70
- artifacts: string[];
71
- }
72
-
73
- export interface StoryPhase {
74
- name: string;
75
- threadType: ThreadType;
76
- threadId?: string;
77
- state: ThreadState;
78
- description: string;
79
- }
80
-
81
- /** Short status line for dashboard */
82
- export interface ThreadSummary {
83
- id: string;
84
- type: ThreadType;
85
- label: string;
86
- state: ThreadState;
87
- progress: string;
88
- elapsed: string;
89
- totalTokens: number;
90
- totalCost: number;
91
- backend: ExecutionBackend;
92
- }
93
-
94
- /** Thread event for state changes */
95
- export type ThreadEvent =
96
- | { type: "thread_created"; thread: Thread }
97
- | { type: "thread_started"; thread: Thread }
98
- | { type: "task_started"; thread: Thread; task: ThreadTask }
99
- | { type: "task_completed"; thread: Thread; task: ThreadTask }
100
- | { type: "task_failed"; thread: Thread; task: ThreadTask }
101
- | { type: "thread_completed"; thread: Thread }
102
- | { type: "thread_failed"; thread: Thread }
103
- | { type: "thread_killed"; thread: Thread }
104
- | { type: "story_created"; story: Story }
105
- | { type: "story_phase_started"; story: Story; phase: StoryPhase }
106
- | { type: "story_completed"; story: Story }
107
- | { type: "story_failed"; story: Story };
1
+ /** Thread types from the thread engineering framework */
2
+ export type ThreadType = "base" | "parallel" | "chained" | "fusion" | "meta" | "long" | "zero" | "worktree" | "plan" | "scheduled" | "monitor";
3
+
4
+ /** Thread lifecycle states */
5
+ export type ThreadState = "pending" | "running" | "paused" | "completed" | "failed" | "killed" | "needs_input";
6
+
7
+ /** How a thread is executed */
8
+ export type ExecutionBackend = "subagent" | "native";
9
+
10
+ /** A single unit of work within a thread */
11
+ export interface ThreadTask {
12
+ usage?: { inputTokens: number; outputTokens: number; cacheRead: number; totalTokens: number; cost: number };
13
+ id: string;
14
+ label: string;
15
+ prompt: string;
16
+ model?: string;
17
+ state: ThreadState;
18
+ startedAt?: number;
19
+ completedAt?: number;
20
+ result?: string;
21
+ error?: string;
22
+ /** interactive_shell session ID or subagent run ID */
23
+ sessionId?: string;
24
+ }
25
+
26
+ /** Thread configuration */
27
+ export interface ThreadConfig {
28
+ type: ThreadType;
29
+ tasks: ThreadTask[];
30
+ /** Execution backend */
31
+ backend: ExecutionBackend;
32
+ /** For fusion threads: models to compete */
33
+ models?: string[];
34
+ /** For zero threads: verification command */
35
+ verifyCommand?: string;
36
+ /** For chained threads: require human checkpoint between phases */
37
+ checkpoints?: boolean;
38
+ /** Working directory */
39
+ cwd?: string;
40
+ /** Subagent agent name to use (default: "worker") */
41
+ agent?: string;
42
+ }
43
+
44
+ /** A thread — the fundamental unit of tracked work */
45
+ export interface Thread {
46
+ id: string;
47
+ type: ThreadType;
48
+ label: string;
49
+ state: ThreadState;
50
+ config: ThreadConfig;
51
+ tasks: ThreadTask[];
52
+ createdAt: number;
53
+ startedAt?: number;
54
+ completedAt?: number;
55
+ /** Duration in ms */
56
+ duration?: number;
57
+ }
58
+
59
+ /** A story — a goal decomposed into thread phases */
60
+ export interface Story {
61
+ id: string;
62
+ goal: string;
63
+ state: "planning" | "executing" | "verifying" | "done" | "failed";
64
+ phases: StoryPhase[];
65
+ createdAt: number;
66
+ completedAt?: number;
67
+ /** Verification command */
68
+ verify?: string;
69
+ /** Files changed */
70
+ artifacts: string[];
71
+ }
72
+
73
+ export interface StoryPhase {
74
+ name: string;
75
+ threadType: ThreadType;
76
+ threadId?: string;
77
+ state: ThreadState;
78
+ description: string;
79
+ }
80
+
81
+ /** Short status line for dashboard */
82
+ export interface ThreadSummary {
83
+ id: string;
84
+ type: ThreadType;
85
+ label: string;
86
+ state: ThreadState;
87
+ progress: string;
88
+ elapsed: string;
89
+ totalTokens: number;
90
+ totalCost: number;
91
+ backend: ExecutionBackend;
92
+ }
93
+
94
+ /** Thread event for state changes */
95
+ export type ThreadEvent =
96
+ | { type: "thread_created"; thread: Thread }
97
+ | { type: "thread_started"; thread: Thread }
98
+ | { type: "task_started"; thread: Thread; task: ThreadTask }
99
+ | { type: "task_completed"; thread: Thread; task: ThreadTask }
100
+ | { type: "task_failed"; thread: Thread; task: ThreadTask }
101
+ | { type: "thread_completed"; thread: Thread }
102
+ | { type: "thread_failed"; thread: Thread }
103
+ | { type: "thread_killed"; thread: Thread }
104
+ | { type: "story_created"; story: Story }
105
+ | { type: "story_phase_started"; story: Story; phase: StoryPhase }
106
+ | { type: "story_completed"; story: Story }
107
+ | { type: "story_failed"; story: Story };
@@ -0,0 +1,309 @@
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 { execFileSync } from "child_process";
12
+ import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "fs";
13
+ import { join, resolve, relative, sep, isAbsolute } from "path";
14
+
15
+ export interface WorktreeInfo {
16
+ path: string;
17
+ branch: string;
18
+ threadId: string;
19
+ createdAt: number;
20
+ ahead: number;
21
+ behind: number;
22
+ dirty: boolean;
23
+ }
24
+
25
+ const WORKTREE_DIR = ".git/worktrees-pi";
26
+ let worktreeRegistry: WorktreeInfo[] = [];
27
+
28
+ function git(args: string[], cwd: string): string {
29
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
30
+ }
31
+
32
+ function shortHash(input: string): string {
33
+ let hash = 0x811c9dc5;
34
+ for (let i = 0; i < input.length; i++) {
35
+ hash ^= input.charCodeAt(i);
36
+ hash = Math.imul(hash, 0x01000193);
37
+ }
38
+ return (hash >>> 0).toString(36);
39
+ }
40
+
41
+ function sanitizeThreadId(threadId: string): string {
42
+ const safe = threadId.replace(/[^a-zA-Z0-9-]/g, "-").replace(/^-+|-+$/g, "");
43
+ if (!safe) throw new Error("Invalid threadId");
44
+ const suffix = shortHash(threadId);
45
+ return safe.length > 48 ? `${safe.slice(0, 48)}-${suffix}` : `${safe}-${suffix}`;
46
+ }
47
+
48
+ function isContained(parent: string, child: string): boolean {
49
+ const rel = relative(resolve(parent), resolve(child));
50
+ return rel === "" || (!rel.startsWith("..") && !rel.includes(`..${sep}`) && !isAbsolute(rel));
51
+ }
52
+
53
+ function assertContainedWorktreePath(repoPath: string, wtPath: string): string {
54
+ const root = resolve(repoPath, WORKTREE_DIR);
55
+ const resolved = resolve(wtPath);
56
+ if (!isContained(root, resolved)) throw new Error("Worktree path escapes worktree directory");
57
+ return resolved;
58
+ }
59
+
60
+ function validateBranchRef(ref: string, cwd: string, label: string): string {
61
+ if (!ref || ref.startsWith("-") || ref.includes("\0")) throw new Error(`Invalid ${label}`);
62
+ try {
63
+ git(["check-ref-format", "--branch", ref], cwd);
64
+ return ref;
65
+ } catch {
66
+ throw new Error(`Invalid ${label}`);
67
+ }
68
+ }
69
+
70
+ function validateStartPoint(ref: string, cwd: string, label: string): string {
71
+ if (!ref || ref.startsWith("-") || /[\0\r\n]/.test(ref)) throw new Error(`Invalid ${label}`);
72
+ try {
73
+ git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd);
74
+ return ref;
75
+ } catch {
76
+ throw new Error(`Invalid ${label}`);
77
+ }
78
+ }
79
+
80
+ function loadRegistry(repoPath: string): WorktreeInfo[] {
81
+ const regPath = join(repoPath, WORKTREE_DIR, "registry.json");
82
+ if (existsSync(regPath)) {
83
+ try {
84
+ return JSON.parse(readFileSync(regPath, "utf8"));
85
+ } catch { /* corrupt, start fresh */ }
86
+ }
87
+ return [];
88
+ }
89
+
90
+ function saveRegistry(repoPath: string, reg: WorktreeInfo[]) {
91
+ const regDir = join(repoPath, WORKTREE_DIR);
92
+ if (!existsSync(regDir)) mkdirSync(regDir, { recursive: true });
93
+ writeFileSync(join(regDir, "registry.json"), JSON.stringify(reg, null, 2), "utf8");
94
+ }
95
+
96
+ /** Resolve the git repo root from a path */
97
+ export function findRepoRoot(cwd: string): string | null {
98
+ try {
99
+ return git(["rev-parse", "--show-toplevel"], cwd);
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /** Check if we're in a git repo */
106
+ export function isGitRepo(cwd: string): boolean {
107
+ return findRepoRoot(cwd) !== null;
108
+ }
109
+
110
+ /** Generate a unique branch name for a thread */
111
+ export function branchName(threadId: string): string {
112
+ return `pi-thread/${sanitizeThreadId(threadId)}`;
113
+ }
114
+
115
+ /** Worktree storage path for a thread */
116
+ export function worktreePath(repoPath: string, threadId: string): string {
117
+ return assertContainedWorktreePath(repoPath, join(repoPath, WORKTREE_DIR, sanitizeThreadId(threadId)));
118
+ }
119
+
120
+ /**
121
+ * Create a worktree for a thread.
122
+ * Creates a new branch off the current HEAD, checks it out in an isolated dir.
123
+ */
124
+ export function createWorktree(cwd: string, threadId: string, baseBranch?: string): WorktreeInfo {
125
+ const repoPath = findRepoRoot(cwd);
126
+ if (!repoPath) throw new Error("Not in a git repository");
127
+
128
+ const branch = validateBranchRef(branchName(threadId), repoPath, "branch ref");
129
+ const base = validateStartPoint(baseBranch ?? "HEAD", repoPath, "base branch ref");
130
+ const wtPath = worktreePath(repoPath, threadId);
131
+
132
+ // Create the branch if it doesn't exist
133
+ try {
134
+ git(["branch", branch, base], repoPath);
135
+ } catch {
136
+ // Branch may already exist from a previous run — that's fine
137
+ }
138
+
139
+ // Create worktree
140
+ git(["worktree", "add", wtPath, branch], repoPath);
141
+
142
+ const info: WorktreeInfo = {
143
+ path: wtPath,
144
+ branch,
145
+ threadId,
146
+ createdAt: Date.now(),
147
+ ahead: 0,
148
+ behind: 0,
149
+ dirty: false,
150
+ };
151
+
152
+ const reg = loadRegistry(repoPath);
153
+ // Remove any stale entry for this thread
154
+ const filtered = reg.filter((w) => w.threadId !== threadId);
155
+ filtered.push(info);
156
+ saveRegistry(repoPath, filtered);
157
+ worktreeRegistry = filtered;
158
+
159
+ return info;
160
+ }
161
+
162
+ /**
163
+ * Remove a worktree and its branch
164
+ */
165
+ export function removeWorktree(cwd: string, threadId: string): boolean {
166
+ const repoPath = findRepoRoot(cwd);
167
+ if (!repoPath) return false;
168
+
169
+ const branch = validateBranchRef(branchName(threadId), repoPath, "branch ref");
170
+ const wtPath = worktreePath(repoPath, threadId);
171
+
172
+ let success = true;
173
+
174
+ // Remove worktree
175
+ try {
176
+ git(["worktree", "remove", wtPath], repoPath);
177
+ } catch {
178
+ // Force remove if locked
179
+ try {
180
+ git(["worktree", "remove", "--force", wtPath], repoPath);
181
+ } catch {
182
+ success = false;
183
+ }
184
+ }
185
+
186
+ // Delete branch
187
+ try {
188
+ git(["branch", "-D", branch], repoPath);
189
+ } catch {
190
+ // Branch may not exist
191
+ }
192
+
193
+ // Clean up directory after re-checking containment
194
+ try {
195
+ rmSync(assertContainedWorktreePath(repoPath, wtPath), { recursive: true, force: true });
196
+ } catch { /* best effort */ }
197
+
198
+ // Update registry
199
+ const reg = loadRegistry(repoPath);
200
+ const filtered = reg.filter((w) => w.threadId !== threadId);
201
+ saveRegistry(repoPath, filtered);
202
+ worktreeRegistry = filtered;
203
+
204
+ return success;
205
+ }
206
+
207
+ /**
208
+ * Get divergence stats for a worktree branch
209
+ */
210
+ function getDivergence(repoPath: string, branch: string): { ahead: number; behind: number } {
211
+ try {
212
+ const ref = validateBranchRef(branch, repoPath, "branch ref");
213
+ const ahead = parseInt(git(["rev-list", "--count", `HEAD..origin/${ref}`], repoPath) || "0", 10);
214
+ const behind = parseInt(git(["rev-list", "--count", `origin/${ref}..HEAD`], repoPath) || "0", 10);
215
+ return { ahead, behind };
216
+ } catch {
217
+ return { ahead: 0, behind: 0 };
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Check if a worktree has uncommitted changes
223
+ */
224
+ function isDirty(path: string): boolean {
225
+ try {
226
+ const status = git(["status", "--porcelain"], path);
227
+ return status.length > 0;
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * List all worktrees with their divergence stats
235
+ */
236
+ export function listWorktrees(cwd: string): WorktreeInfo[] {
237
+ const repoPath = findRepoRoot(cwd);
238
+ if (!repoPath) return [];
239
+
240
+ const reg = loadRegistry(repoPath);
241
+
242
+ // Refresh stats
243
+ return reg.map((w) => {
244
+ const div = getDivergence(repoPath, w.branch);
245
+ const dirty = existsSync(w.path) ? isDirty(w.path) : false;
246
+ return { ...w, ...div, dirty };
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Collect worktree results — reads the output file from a finished thread
252
+ */
253
+ export function collectWorktreeResult(threadId: string, worktree: WorktreeInfo): string | null {
254
+ const resultFile = join(worktree.path, ".pi-thread-result.json");
255
+ if (existsSync(resultFile)) {
256
+ try {
257
+ return readFileSync(resultFile, "utf8");
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+ return null;
263
+ }
264
+
265
+ /**
266
+ * Clean up ALL worktrees for this repo (emergency / force cleanup)
267
+ */
268
+ export function cleanupAll(cwd: string): { removed: number; failed: number } {
269
+ const repoPath = findRepoRoot(cwd);
270
+ if (!repoPath) return { removed: 0, failed: 0 };
271
+
272
+ const reg = loadRegistry(repoPath);
273
+ let removed = 0;
274
+ let failed = 0;
275
+
276
+ for (const w of reg) {
277
+ if (removeWorktree(cwd, w.threadId)) removed++;
278
+ else failed++;
279
+ }
280
+
281
+ saveRegistry(repoPath, []);
282
+ worktreeRegistry = [];
283
+
284
+ return { removed, failed };
285
+ }
286
+
287
+ /**
288
+ * Push worktree changes back to the main repo
289
+ */
290
+ export function pushWorktreeChanges(cwd: string, threadId: string, message?: string): boolean {
291
+ const repoPath = findRepoRoot(cwd);
292
+ if (!repoPath) return false;
293
+
294
+ const wtPath = worktreePath(repoPath, threadId);
295
+ if (!existsSync(wtPath)) return false;
296
+
297
+ try {
298
+ // Commit any uncommitted changes
299
+ const status = git(["status", "--porcelain"], wtPath);
300
+ if (status) {
301
+ git(["add", "-A"], wtPath);
302
+ const defaultMsg = `[pi-threads] ${threadId} worktree changes`;
303
+ git(["commit", "-m", message ?? defaultMsg], wtPath);
304
+ }
305
+ return true;
306
+ } catch {
307
+ return false;
308
+ }
309
+ }