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.
@@ -1,36 +1,43 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import type { ThreadRegistry } from "./registry.js";
3
- import type { Thread, ThreadTask } from "./types.js";
4
-
5
- /**
6
- * Executor — runs thread tasks.
7
- *
8
- * Two backends:
9
- * - "subagent": delegates to pi-subagents via sendUserMessage (inherits agent specialization, TUI, artifacts)
10
- * - "native": spawns pi -p directly (for fusion/zero where we need raw control)
11
- */
12
- export class ThreadExecutor {
13
- constructor(
14
- private pi: ExtensionAPI,
15
- private registry: ThreadRegistry
16
- ) {}
17
-
18
- // ── Native execution (pi -p) ────────────────────────────────
19
-
20
- private async runTaskNative(thread: Thread, task: ThreadTask): Promise<void> {
21
- const cwd = thread.config.cwd ?? process.cwd();
22
- this.registry.startTask(thread.id, task.id);
23
-
24
- try {
25
- const args = ["-p", task.prompt];
26
- if (task.model) args.unshift("-m", task.model);
27
-
28
- const result = await this.pi.exec("pi", args, {
29
- cwd,
30
- timeout: 10 * 60 * 1000,
31
- });
32
-
33
- if (result.code === 0) {
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { ThreadRegistry } from "./registry.js";
3
+ import type { Thread, ThreadTask } from "./types.js";
4
+
5
+ /**
6
+ * Executor — runs thread tasks.
7
+ *
8
+ * Two backends:
9
+ * - "subagent": delegates to pi-subagents via sendUserMessage (inherits agent specialization, TUI, artifacts)
10
+ * - "native": spawns pi -p directly (for fusion/zero where we need raw control)
11
+ */
12
+ export class ThreadExecutor {
13
+ constructor(
14
+ private pi: ExtensionAPI,
15
+ private registry: ThreadRegistry
16
+ ) {}
17
+
18
+ // ── Native execution (pi -p) ────────────────────────────────
19
+
20
+ private async execPi(args: string[], opts: { cwd: string; timeout: number }) {
21
+ if (process.platform === "win32") {
22
+ return this.pi.exec("cmd.exe", ["/d", "/s", "/c", "pi", ...args], opts);
23
+ }
24
+ return this.pi.exec("pi", args, opts);
25
+ }
26
+
27
+ private async runTaskNative(thread: Thread, task: ThreadTask): Promise<void> {
28
+ const cwd = thread.config.cwd ?? process.cwd();
29
+ this.registry.startTask(thread.id, task.id);
30
+
31
+ try {
32
+ const args = ["--no-session", "--no-extensions", "--no-skills", "--no-context-files", "-p", task.prompt];
33
+ if (task.model) args.unshift("-m", task.model);
34
+
35
+ const result = await this.execPi(args, {
36
+ cwd,
37
+ timeout: 10 * 60 * 1000,
38
+ });
39
+
40
+ if (result.code === 0) {
34
41
  this.registry.completeTask(thread.id, task.id, result.stdout);
35
42
  // Parse --mode json output for token/cost data
36
43
  try {
@@ -49,190 +56,247 @@ export class ThreadExecutor {
49
56
  };
50
57
  }
51
58
  }
52
- } catch {}
53
- } else {
54
- this.registry.failTask(thread.id, task.id, result.stderr || `Exit code: ${result.code}`);
55
- }
56
- } catch (err: any) {
57
- this.registry.failTask(thread.id, task.id, err.message ?? String(err));
58
- }
59
- }
60
-
61
- // ── Subagent execution (via sendUserMessage) ────────────────
62
-
63
- private launchSubagentParallel(thread: Thread): void {
64
- const agent = thread.config.agent ?? "worker";
65
- const tasks = thread.tasks.map((t) => ({
66
- agent,
67
- task: t.prompt,
68
- ...(t.model ? { model: t.model } : {}),
69
- }));
70
-
71
- // Send as a user message that pi-subagents will pick up
72
- this.pi.sendUserMessage(
73
- `Run these tasks in parallel using subagent:\n\`\`\`json\n${JSON.stringify({ tasks }, null, 2)}\n\`\`\``,
74
- { deliverAs: "followUp" }
75
- );
76
-
77
- // Mark all tasks as running (subagent handles the rest)
78
- this.registry.startThread(thread.id);
79
- for (const task of thread.tasks) {
80
- this.registry.startTask(thread.id, task.id);
81
- }
82
- }
83
-
84
- private launchSubagentChain(thread: Thread): void {
85
- const agent = thread.config.agent ?? "worker";
86
- const chain = thread.tasks.map((t, i) => ({
87
- agent,
88
- task: i === 0 ? t.prompt : `Continue: ${t.prompt}. Previous context: {previous}`,
89
- ...(t.model ? { model: t.model } : {}),
90
- }));
91
-
92
- this.pi.sendUserMessage(
93
- `Run this chain using subagent:\n\`\`\`json\n${JSON.stringify({ chain }, null, 2)}\n\`\`\``,
94
- { deliverAs: "followUp" }
95
- );
96
-
97
- this.registry.startThread(thread.id);
98
- this.registry.startTask(thread.id, thread.tasks[0].id);
99
- }
100
-
101
- private launchSubagentMeta(thread: Thread): void {
102
- const chain = [
103
- { agent: "scout", task: thread.tasks[0]?.prompt ?? "Scout the codebase" },
104
- { agent: "planner", task: "{previous}" },
105
- { agent: "worker", task: "{previous}" },
106
- { agent: "reviewer", task: "{previous}" },
107
- ];
108
-
109
- this.pi.sendUserMessage(
110
- `Run this meta pipeline using subagent:\n\`\`\`json\n${JSON.stringify({ chain }, null, 2)}\n\`\`\``,
111
- { deliverAs: "followUp" }
112
- );
113
-
114
- this.registry.startThread(thread.id);
115
- this.registry.startTask(thread.id, thread.tasks[0].id);
116
- }
117
-
118
- // ── Fusion (native, multi-model) ────────────────────────────
119
-
120
- async execFusion(thread: Thread): Promise<void> {
121
- this.registry.startThread(thread.id);
122
- // All tasks run in parallel with potentially different models
123
- await Promise.allSettled(thread.tasks.map((task) => this.runTaskNative(thread, task)));
124
- }
125
-
126
- // ── Zero-touch (native + verification) ──────────────────────
127
-
128
- async execZero(thread: Thread): Promise<void> {
129
- this.registry.startThread(thread.id);
130
- const task = thread.tasks[0];
131
- await this.runTaskNative(thread, task);
132
-
133
- // If task succeeded and we have a verify command, run it
134
- if (task.state === "completed" && thread.config.verifyCommand) {
135
- const cwd = thread.config.cwd ?? process.cwd();
136
- try {
137
- const verify = await this.pi.exec("bash", ["-c", thread.config.verifyCommand], {
138
- cwd,
139
- timeout: 5 * 60 * 1000,
140
- });
141
- if (verify.code !== 0) {
142
- // Override the completed state to failed
143
- task.state = "failed";
144
- task.error = `Verification failed (${thread.config.verifyCommand}): ${verify.stderr || verify.stdout}`;
145
- thread.state = "failed";
146
- thread.completedAt = Date.now();
147
- thread.duration = thread.completedAt - (thread.startedAt ?? thread.createdAt);
148
- }
149
- } catch (err: any) {
150
- task.state = "failed";
151
- task.error = `Verification error: ${err.message}`;
152
- thread.state = "failed";
153
- thread.completedAt = Date.now();
154
- }
155
- }
156
- }
157
-
158
- // ── Dispatch ─────────────────────────────────────────────────
159
-
160
- async dispatch(
161
- thread: Thread,
162
- opts?: { onCheckpoint?: (phase: number, task: ThreadTask) => Promise<boolean> }
163
- ): Promise<void> {
164
- const backend = thread.config.backend;
165
-
166
- if (backend === "subagent") {
167
- switch (thread.type) {
168
- case "parallel":
169
- return this.launchSubagentParallel(thread);
170
- case "chained":
171
- return this.launchSubagentChain(thread);
172
- case "meta":
173
- return this.launchSubagentMeta(thread);
174
- default:
175
- // Fall through to native for unsupported subagent types
176
- break;
177
- }
178
- }
179
-
180
- // Native execution
181
- switch (thread.type) {
182
- case "base":
183
- case "long":
184
- this.registry.startThread(thread.id);
185
- return this.runTaskNative(thread, thread.tasks[0]);
186
- case "parallel":
187
- this.registry.startThread(thread.id);
188
- await Promise.allSettled(thread.tasks.map((t) => this.runTaskNative(thread, t)));
189
- return;
190
- case "fusion":
191
- return this.execFusion(thread);
192
- case "zero":
193
- return this.execZero(thread);
194
- case "chained": {
195
- this.registry.startThread(thread.id);
196
- for (let i = 0; i < thread.tasks.length; i++) {
197
- if (i > 0 && opts?.onCheckpoint) {
198
- const proceed = await opts.onCheckpoint(i, thread.tasks[i]);
199
- if (!proceed) {
200
- this.registry.kill(thread.id);
201
- return;
202
- }
203
- }
204
- await this.runTaskNative(thread, thread.tasks[i]);
205
- if (thread.tasks[i].state === "failed") return;
206
- }
207
- return;
208
- }
209
- case "meta":
210
- this.registry.startThread(thread.id);
211
- for (const task of thread.tasks) {
212
- await this.runTaskNative(thread, task);
213
- if (task.state === "failed") return;
214
- }
215
- return;
216
- }
217
- }
218
-
219
- /** Inject a reply into a running thread — sends message to its session */
220
- injectReply(threadId: string, message: string): void {
221
- const thread = this.registry.get(threadId);
222
- if (!thread) return;
223
-
224
- // Mark the blocked task as no longer needing input
225
- for (const task of thread.tasks) {
226
- if (task.state === "needs_input") {
227
- task.state = "running";
228
- }
229
- }
230
-
231
- // Forward the reply to the running subagent session via sendUserMessage
232
- // The subagent will pick up the new context and continue
233
- this.pi.sendUserMessage(
234
- `[Thread ${threadId}] Reply to blocked thread: ${message}`,
235
- { deliverAs: "followUp" }
236
- );
237
- }
238
- }
59
+ } catch {}
60
+ } else {
61
+ this.registry.failTask(thread.id, task.id, result.stderr || `Exit code: ${result.code}`);
62
+ }
63
+ } catch (err: any) {
64
+ this.registry.failTask(thread.id, task.id, err.message ?? String(err));
65
+ }
66
+ }
67
+
68
+ // ── Subagent execution (via sendUserMessage) ────────────────
69
+
70
+ private launchSubagentParallel(thread: Thread): void {
71
+ const agent = thread.config.agent ?? "worker";
72
+ const tasks = thread.tasks.map((t) => ({
73
+ agent,
74
+ task: t.prompt,
75
+ ...(t.model ? { model: t.model } : {}),
76
+ }));
77
+
78
+ // Send as a user message that pi-subagents will pick up
79
+ this.pi.sendUserMessage(
80
+ `Run these tasks in parallel using subagent:\n\`\`\`json\n${JSON.stringify({ tasks }, null, 2)}\n\`\`\``,
81
+ { deliverAs: "followUp" }
82
+ );
83
+
84
+ // Mark all tasks as running (subagent handles the rest)
85
+ this.registry.startThread(thread.id);
86
+ for (const task of thread.tasks) {
87
+ this.registry.startTask(thread.id, task.id);
88
+ }
89
+ }
90
+
91
+ private launchSubagentChain(thread: Thread): void {
92
+ const agent = thread.config.agent ?? "worker";
93
+ const chain = thread.tasks.map((t, i) => ({
94
+ agent,
95
+ task: i === 0 ? t.prompt : `Continue: ${t.prompt}. Previous context: {previous}`,
96
+ ...(t.model ? { model: t.model } : {}),
97
+ }));
98
+
99
+ this.pi.sendUserMessage(
100
+ `Run this chain using subagent:\n\`\`\`json\n${JSON.stringify({ chain }, null, 2)}\n\`\`\``,
101
+ { deliverAs: "followUp" }
102
+ );
103
+
104
+ this.registry.startThread(thread.id);
105
+ this.registry.startTask(thread.id, thread.tasks[0].id);
106
+ }
107
+
108
+ private launchSubagentMeta(thread: Thread): void {
109
+ const chain = [
110
+ { agent: "scout", task: thread.tasks[0]?.prompt ?? "Scout the codebase" },
111
+ { agent: "planner", task: "{previous}" },
112
+ { agent: "worker", task: "{previous}" },
113
+ { agent: "reviewer", task: "{previous}" },
114
+ ];
115
+
116
+ this.pi.sendUserMessage(
117
+ `Run this meta pipeline using subagent:\n\`\`\`json\n${JSON.stringify({ chain }, null, 2)}\n\`\`\``,
118
+ { deliverAs: "followUp" }
119
+ );
120
+
121
+ this.registry.startThread(thread.id);
122
+ this.registry.startTask(thread.id, thread.tasks[0].id);
123
+ }
124
+
125
+ // ── Fusion (native, multi-model) ────────────────────────────
126
+
127
+ async execFusion(thread: Thread): Promise<void> {
128
+ this.registry.startThread(thread.id);
129
+ // All tasks run in parallel with potentially different models
130
+ await Promise.allSettled(thread.tasks.map((task) => this.runTaskNative(thread, task)));
131
+ }
132
+
133
+ // ── Zero-touch (native + verification) ──────────────────────
134
+
135
+ async execZero(thread: Thread): Promise<void> {
136
+ this.registry.startThread(thread.id);
137
+ const task = thread.tasks[0];
138
+ await this.runTaskNative(thread, task);
139
+
140
+ // If task succeeded and we have a verify command, run it
141
+ if (task.state === "completed" && thread.config.verifyCommand) {
142
+ const cwd = thread.config.cwd ?? process.cwd();
143
+ try {
144
+ const verify = await this.pi.exec("bash", ["-c", thread.config.verifyCommand], {
145
+ cwd,
146
+ timeout: 5 * 60 * 1000,
147
+ });
148
+ if (verify.code !== 0) {
149
+ // Override the completed state to failed
150
+ task.state = "failed";
151
+ task.error = `Verification failed (${thread.config.verifyCommand}): ${verify.stderr || verify.stdout}`;
152
+ thread.state = "failed";
153
+ thread.completedAt = Date.now();
154
+ thread.duration = thread.completedAt - (thread.startedAt ?? thread.createdAt);
155
+ }
156
+ } catch (err: any) {
157
+ task.state = "failed";
158
+ task.error = `Verification error: ${err.message}`;
159
+ thread.state = "failed";
160
+ thread.completedAt = Date.now();
161
+ }
162
+ }
163
+ }
164
+
165
+ // ── Dispatch ─────────────────────────────────────────────────
166
+
167
+ async dispatch(
168
+ thread: Thread,
169
+ opts?: { onCheckpoint?: (phase: number, task: ThreadTask) => Promise<boolean> }
170
+ ): Promise<void> {
171
+ const backend = thread.config.backend;
172
+
173
+ if (backend === "subagent") {
174
+ switch (thread.type) {
175
+ case "parallel":
176
+ return this.launchSubagentParallel(thread);
177
+ case "chained":
178
+ return this.launchSubagentChain(thread);
179
+ case "meta":
180
+ return this.launchSubagentMeta(thread);
181
+ default:
182
+ // Fall through to native for unsupported subagent types
183
+ break;
184
+ }
185
+ }
186
+
187
+ // Native execution
188
+ switch (thread.type) {
189
+ case "base":
190
+ case "long":
191
+ case "plan":
192
+ case "scheduled":
193
+ this.registry.startThread(thread.id);
194
+ return this.runTaskNative(thread, thread.tasks[0]);
195
+ case "parallel":
196
+ this.registry.startThread(thread.id);
197
+ await Promise.allSettled(thread.tasks.map((t) => this.runTaskNative(thread, t)));
198
+ return;
199
+ case "fusion":
200
+ return this.execFusion(thread);
201
+ case "zero":
202
+ return this.execZero(thread);
203
+ case "worktree":
204
+ return this.execWorktree(thread);
205
+ case "chained": {
206
+ this.registry.startThread(thread.id);
207
+ for (let i = 0; i < thread.tasks.length; i++) {
208
+ if (i > 0 && opts?.onCheckpoint) {
209
+ const proceed = await opts.onCheckpoint(i, thread.tasks[i]);
210
+ if (!proceed) {
211
+ this.registry.kill(thread.id);
212
+ return;
213
+ }
214
+ }
215
+ await this.runTaskNative(thread, thread.tasks[i]);
216
+ if (thread.tasks[i].state === "failed") return;
217
+ }
218
+ return;
219
+ }
220
+ case "meta":
221
+ this.registry.startThread(thread.id);
222
+ for (const task of thread.tasks) {
223
+ await this.runTaskNative(thread, task);
224
+ if (task.state === "failed") return;
225
+ }
226
+ return;
227
+ }
228
+ }
229
+
230
+ /** Execute a thread in an isolated git worktree */
231
+ private async execWorktree(thread: Thread): Promise<void> {
232
+ const cwd = thread.config.cwd ?? process.cwd();
233
+ this.registry.startThread(thread.id);
234
+
235
+ try {
236
+ const { createWorktree, findRepoRoot } = await import("./worktree.js");
237
+ const repoPath = findRepoRoot(cwd);
238
+
239
+ if (!repoPath) {
240
+ this.registry.failTask(thread.id, thread.tasks[0].id, "Not in a git repository");
241
+ return;
242
+ }
243
+
244
+ // Create worktree
245
+ const wt = createWorktree(cwd, thread.id);
246
+ const task = thread.tasks[0];
247
+
248
+ // Write task prompt to worktree
249
+ const { writeFileSync } = await import("fs");
250
+ const { join } = await import("path");
251
+ writeFileSync(join(wt.path, ".pi-thread-task.md"), task.prompt, "utf8");
252
+
253
+ // Run the task inside the worktree using pi -p
254
+ try {
255
+ const result = await this.execPi(["--no-session", "--no-extensions", "--no-skills", "--no-context-files", "-p", task.prompt], {
256
+ cwd: wt.path,
257
+ timeout: 30 * 60 * 1000, // 30 min max
258
+ });
259
+
260
+ if (result.code === 0) {
261
+ // Write result marker
262
+ writeFileSync(join(wt.path, ".pi-thread-result.json"), JSON.stringify({
263
+ threadId: thread.id,
264
+ status: "completed",
265
+ output: result.stdout,
266
+ timestamp: Date.now(),
267
+ }, null, 2), "utf8");
268
+
269
+ this.registry.completeTask(thread.id, task.id, result.stdout);
270
+ } else {
271
+ this.registry.failTask(thread.id, task.id, result.stderr || `Exit code: ${result.code}`);
272
+ }
273
+ } catch (err: any) {
274
+ this.registry.failTask(thread.id, task.id, err.message ?? String(err));
275
+ }
276
+
277
+ // Preserve the worktree/branch for explicit /wthread push, discard, or cleanup.
278
+ } catch (err: any) {
279
+ this.registry.failTask(thread.id, thread.tasks[0].id, err.message ?? String(err));
280
+ }
281
+ }
282
+
283
+ /** Inject a reply into a running thread — sends message to its session */
284
+ injectReply(threadId: string, message: string): void {
285
+ const thread = this.registry.get(threadId);
286
+ if (!thread) return;
287
+
288
+ // Mark the blocked task as no longer needing input
289
+ for (const task of thread.tasks) {
290
+ if (task.state === "needs_input") {
291
+ task.state = "running";
292
+ }
293
+ }
294
+
295
+ // Forward the reply to the running subagent session via sendUserMessage
296
+ // The subagent will pick up the new context and continue
297
+ this.pi.sendUserMessage(
298
+ `[Thread ${threadId}] Reply to blocked thread: ${message}`,
299
+ { deliverAs: "followUp" }
300
+ );
301
+ }
302
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ThreadRegistry } from "./registry.js";
3
+
4
+ describe("ThreadRegistry kill semantics", () => {
5
+ it("does not resurrect killed threads when async work completes late", () => {
6
+ const registry = new ThreadRegistry();
7
+ const thread = registry.create("base", "late finish", ["prompt"], { backend: "native" });
8
+
9
+ registry.startThread(thread.id);
10
+ registry.startTask(thread.id, thread.tasks[0].id);
11
+ registry.kill(thread.id);
12
+ registry.completeTask(thread.id, thread.tasks[0].id, "late result");
13
+
14
+ expect(thread.state).toBe("killed");
15
+ expect(thread.tasks[0].state).toBe("killed");
16
+ expect(thread.tasks[0].result).toBeUndefined();
17
+ });
18
+
19
+ it("does not resurrect killed threads when async work fails late", () => {
20
+ const registry = new ThreadRegistry();
21
+ const thread = registry.create("base", "late failure", ["prompt"], { backend: "native" });
22
+
23
+ registry.startThread(thread.id);
24
+ registry.startTask(thread.id, thread.tasks[0].id);
25
+ registry.kill(thread.id);
26
+ registry.failTask(thread.id, thread.tasks[0].id, "late error");
27
+
28
+ expect(thread.state).toBe("killed");
29
+ expect(thread.tasks[0].state).toBe("killed");
30
+ expect(thread.tasks[0].error).toBeUndefined();
31
+ });
32
+ });