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