pi-thread-engine 0.1.0

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.
@@ -0,0 +1,200 @@
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
+ }
@@ -0,0 +1,281 @@
1
+ import type { ExecutionBackend, Story, StoryPhase, Thread, ThreadEvent, ThreadState, ThreadSummary, ThreadTask, ThreadType } from "./types.js";
2
+
3
+ type EventHandler = (event: ThreadEvent) => void;
4
+
5
+ let nextThreadId = 1;
6
+ let nextStoryId = 1;
7
+
8
+ function genThreadId(): string {
9
+ return `t-${String(nextThreadId++).padStart(3, "0")}`;
10
+ }
11
+
12
+ function genStoryId(): string {
13
+ return `s-${String(nextStoryId++).padStart(3, "0")}`;
14
+ }
15
+
16
+ function genTaskId(threadId: string, index: number): string {
17
+ return `${threadId}.${index + 1}`;
18
+ }
19
+
20
+ /** Format elapsed time */
21
+ export function formatElapsed(ms: number): string {
22
+ if (ms < 1000) return `${ms}ms`;
23
+ const s = Math.floor(ms / 1000);
24
+ if (s < 60) return `${s}s`;
25
+ const m = Math.floor(s / 60);
26
+ const rem = s % 60;
27
+ if (m < 60) return `${m}m ${rem}s`;
28
+ const h = Math.floor(m / 60);
29
+ return `${h}h ${m % 60}m`;
30
+ }
31
+
32
+ /** Central registry for all threads and stories */
33
+ export class ThreadRegistry {
34
+ private threads = new Map<string, Thread>();
35
+ private stories = new Map<string, Story>();
36
+ private handlers: EventHandler[] = [];
37
+
38
+ on(handler: EventHandler): () => void {
39
+ this.handlers.push(handler);
40
+ return () => {
41
+ const idx = this.handlers.indexOf(handler);
42
+ if (idx >= 0) this.handlers.splice(idx, 1);
43
+ };
44
+ }
45
+
46
+ private emit(event: ThreadEvent) {
47
+ for (const h of this.handlers) {
48
+ try {
49
+ h(event);
50
+ } catch {}
51
+ }
52
+ }
53
+
54
+ // ── Threads ──────────────────────────────────────────────────
55
+
56
+ create(
57
+ type: ThreadType,
58
+ label: string,
59
+ prompts: string[],
60
+ opts?: { models?: string[]; cwd?: string; backend?: ExecutionBackend; agent?: string; verify?: string }
61
+ ): Thread {
62
+ const threadId = genThreadId();
63
+ const tasks: ThreadTask[] = prompts.map((prompt, i) => ({
64
+ id: genTaskId(threadId, i),
65
+ label: prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt,
66
+ prompt,
67
+ model: opts?.models?.[i],
68
+ state: "pending" as ThreadState,
69
+ }));
70
+
71
+ const thread: Thread = {
72
+ id: threadId,
73
+ type,
74
+ label,
75
+ state: "pending",
76
+ config: {
77
+ type,
78
+ tasks,
79
+ backend: opts?.backend ?? "native",
80
+ cwd: opts?.cwd,
81
+ models: opts?.models,
82
+ agent: opts?.agent,
83
+ verifyCommand: opts?.verify,
84
+ },
85
+ tasks,
86
+ createdAt: Date.now(),
87
+ };
88
+
89
+ this.threads.set(threadId, thread);
90
+ this.emit({ type: "thread_created", thread });
91
+ return thread;
92
+ }
93
+
94
+ get(id: string): Thread | undefined {
95
+ return this.threads.get(id);
96
+ }
97
+
98
+ all(): Thread[] {
99
+ return [...this.threads.values()];
100
+ }
101
+
102
+ byState(state: ThreadState): Thread[] {
103
+ return this.all().filter((t) => t.state === state);
104
+ }
105
+
106
+ startThread(id: string) {
107
+ const t = this.threads.get(id);
108
+ if (!t) return;
109
+ t.state = "running";
110
+ t.startedAt = Date.now();
111
+ this.emit({ type: "thread_started", thread: t });
112
+ }
113
+
114
+ startTask(threadId: string, taskId: string, sessionId?: string) {
115
+ const t = this.threads.get(threadId);
116
+ if (!t) return;
117
+ const task = t.tasks.find((tk) => tk.id === taskId);
118
+ if (!task) return;
119
+ task.state = "running";
120
+ task.startedAt = Date.now();
121
+ if (sessionId) task.sessionId = sessionId;
122
+ this.emit({ type: "task_started", thread: t, task });
123
+ }
124
+
125
+ completeTask(threadId: string, taskId: string, result?: string) {
126
+ const t = this.threads.get(threadId);
127
+ if (!t) return;
128
+ const task = t.tasks.find((tk) => tk.id === taskId);
129
+ if (!task) return;
130
+ task.state = "completed";
131
+ task.completedAt = Date.now();
132
+ if (result) task.result = result;
133
+ this.emit({ type: "task_completed", thread: t, task });
134
+
135
+ if (t.tasks.every((tk) => tk.state === "completed")) {
136
+ t.state = "completed";
137
+ t.completedAt = Date.now();
138
+ t.duration = t.completedAt - (t.startedAt ?? t.createdAt);
139
+ this.emit({ type: "thread_completed", thread: t });
140
+ }
141
+ }
142
+
143
+ failTask(threadId: string, taskId: string, error: string) {
144
+ const t = this.threads.get(threadId);
145
+ if (!t) return;
146
+ const task = t.tasks.find((tk) => tk.id === taskId);
147
+ if (!task) return;
148
+ task.state = "failed";
149
+ task.completedAt = Date.now();
150
+ task.error = error;
151
+ this.emit({ type: "task_failed", thread: t, task });
152
+
153
+ // For parallel/fusion: don't fail the whole thread if one task fails
154
+ if (t.type === "parallel" || t.type === "fusion") {
155
+ const allDone = t.tasks.every((tk) => tk.state === "completed" || tk.state === "failed");
156
+ if (allDone) {
157
+ const anySuccess = t.tasks.some((tk) => tk.state === "completed");
158
+ t.state = anySuccess ? "completed" : "failed";
159
+ t.completedAt = Date.now();
160
+ t.duration = t.completedAt - (t.startedAt ?? t.createdAt);
161
+ this.emit(anySuccess ? { type: "thread_completed", thread: t } : { type: "thread_failed", thread: t });
162
+ }
163
+ } else {
164
+ t.state = "failed";
165
+ t.completedAt = Date.now();
166
+ t.duration = t.completedAt - (t.startedAt ?? t.createdAt);
167
+ this.emit({ type: "thread_failed", thread: t });
168
+ }
169
+ }
170
+
171
+ kill(id: string) {
172
+ const t = this.threads.get(id);
173
+ if (!t) return;
174
+ t.state = "killed";
175
+ t.completedAt = Date.now();
176
+ t.duration = t.completedAt - (t.startedAt ?? t.createdAt);
177
+ for (const task of t.tasks) {
178
+ if (task.state === "running" || task.state === "pending") {
179
+ task.state = "killed";
180
+ task.completedAt = Date.now();
181
+ }
182
+ }
183
+ this.emit({ type: "thread_killed", thread: t });
184
+ }
185
+
186
+ summarize(thread: Thread): ThreadSummary {
187
+ const done = thread.tasks.filter((t) => t.state === "completed").length;
188
+ const failed = thread.tasks.filter((t) => t.state === "failed").length;
189
+ const total = thread.tasks.length;
190
+ const elapsed = thread.startedAt ? formatElapsed(Date.now() - thread.startedAt) : "-";
191
+ const progress = failed > 0 ? `${done}/${total} (${failed}✗)` : `${done}/${total}`;
192
+
193
+ return {
194
+ id: thread.id,
195
+ type: thread.type,
196
+ label: thread.label,
197
+ state: thread.state,
198
+ progress,
199
+ elapsed,
200
+ backend: thread.config.backend,
201
+ };
202
+ }
203
+
204
+ prune() {
205
+ for (const [id, t] of this.threads) {
206
+ if (t.state === "completed" || t.state === "failed" || t.state === "killed") {
207
+ this.threads.delete(id);
208
+ }
209
+ }
210
+ }
211
+
212
+ /** Restore state from session persistence */
213
+ restore(threads: Thread[], stories: Story[]) {
214
+ for (const t of threads) {
215
+ this.threads.set(t.id, t);
216
+ }
217
+ for (const s of stories) {
218
+ this.stories.set(s.id, s);
219
+ }
220
+ }
221
+
222
+ // ── Stories ──────────────────────────────────────────────────
223
+
224
+ createStory(goal: string, verify?: string): Story {
225
+ const story: Story = {
226
+ id: genStoryId(),
227
+ goal,
228
+ state: "planning",
229
+ phases: [],
230
+ createdAt: Date.now(),
231
+ verify,
232
+ artifacts: [],
233
+ };
234
+ this.stories.set(story.id, story);
235
+ this.emit({ type: "story_created", story });
236
+ return story;
237
+ }
238
+
239
+ getStory(id: string): Story | undefined {
240
+ return this.stories.get(id);
241
+ }
242
+
243
+ allStories(): Story[] {
244
+ return [...this.stories.values()];
245
+ }
246
+
247
+ addPhase(storyId: string, phase: StoryPhase) {
248
+ const s = this.stories.get(storyId);
249
+ if (!s) return;
250
+ s.phases.push(phase);
251
+ }
252
+
253
+ startPhase(storyId: string, phaseIndex: number, threadId: string) {
254
+ const s = this.stories.get(storyId);
255
+ if (!s || !s.phases[phaseIndex]) return;
256
+ s.phases[phaseIndex].state = "running";
257
+ s.phases[phaseIndex].threadId = threadId;
258
+ s.state = "executing";
259
+ this.emit({ type: "story_phase_started", story: s, phase: s.phases[phaseIndex] });
260
+ }
261
+
262
+ completePhase(storyId: string, phaseIndex: number) {
263
+ const s = this.stories.get(storyId);
264
+ if (!s || !s.phases[phaseIndex]) return;
265
+ s.phases[phaseIndex].state = "completed";
266
+
267
+ if (s.phases.every((p) => p.state === "completed")) {
268
+ s.state = "done";
269
+ s.completedAt = Date.now();
270
+ this.emit({ type: "story_completed", story: s });
271
+ }
272
+ }
273
+
274
+ failStory(storyId: string) {
275
+ const s = this.stories.get(storyId);
276
+ if (!s) return;
277
+ s.state = "failed";
278
+ s.completedAt = Date.now();
279
+ this.emit({ type: "story_failed", story: s });
280
+ }
281
+ }
@@ -0,0 +1,104 @@
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";
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
+ id: string;
13
+ label: string;
14
+ prompt: string;
15
+ model?: string;
16
+ state: ThreadState;
17
+ startedAt?: number;
18
+ completedAt?: number;
19
+ result?: string;
20
+ error?: string;
21
+ /** interactive_shell session ID or subagent run ID */
22
+ sessionId?: string;
23
+ }
24
+
25
+ /** Thread configuration */
26
+ export interface ThreadConfig {
27
+ type: ThreadType;
28
+ tasks: ThreadTask[];
29
+ /** Execution backend */
30
+ backend: ExecutionBackend;
31
+ /** For fusion threads: models to compete */
32
+ models?: string[];
33
+ /** For zero threads: verification command */
34
+ verifyCommand?: string;
35
+ /** For chained threads: require human checkpoint between phases */
36
+ checkpoints?: boolean;
37
+ /** Working directory */
38
+ cwd?: string;
39
+ /** Subagent agent name to use (default: "worker") */
40
+ agent?: string;
41
+ }
42
+
43
+ /** A thread — the fundamental unit of tracked work */
44
+ export interface Thread {
45
+ id: string;
46
+ type: ThreadType;
47
+ label: string;
48
+ state: ThreadState;
49
+ config: ThreadConfig;
50
+ tasks: ThreadTask[];
51
+ createdAt: number;
52
+ startedAt?: number;
53
+ completedAt?: number;
54
+ /** Duration in ms */
55
+ duration?: number;
56
+ }
57
+
58
+ /** A story — a goal decomposed into thread phases */
59
+ export interface Story {
60
+ id: string;
61
+ goal: string;
62
+ state: "planning" | "executing" | "verifying" | "done" | "failed";
63
+ phases: StoryPhase[];
64
+ createdAt: number;
65
+ completedAt?: number;
66
+ /** Verification command */
67
+ verify?: string;
68
+ /** Files changed */
69
+ artifacts: string[];
70
+ }
71
+
72
+ export interface StoryPhase {
73
+ name: string;
74
+ threadType: ThreadType;
75
+ threadId?: string;
76
+ state: ThreadState;
77
+ description: string;
78
+ }
79
+
80
+ /** Short status line for dashboard */
81
+ export interface ThreadSummary {
82
+ id: string;
83
+ type: ThreadType;
84
+ label: string;
85
+ state: ThreadState;
86
+ progress: string;
87
+ elapsed: string;
88
+ backend: ExecutionBackend;
89
+ }
90
+
91
+ /** Thread event for state changes */
92
+ export type ThreadEvent =
93
+ | { type: "thread_created"; thread: Thread }
94
+ | { type: "thread_started"; thread: Thread }
95
+ | { type: "task_started"; thread: Thread; task: ThreadTask }
96
+ | { type: "task_completed"; thread: Thread; task: ThreadTask }
97
+ | { type: "task_failed"; thread: Thread; task: ThreadTask }
98
+ | { type: "thread_completed"; thread: Thread }
99
+ | { type: "thread_failed"; thread: Thread }
100
+ | { type: "thread_killed"; thread: Thread }
101
+ | { type: "story_created"; story: Story }
102
+ | { type: "story_phase_started"; story: Story; phase: StoryPhase }
103
+ | { type: "story_completed"; story: Story }
104
+ | { type: "story_failed"; story: Story };