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.
- package/PLAN.md +43 -0
- package/README.md +108 -0
- package/extensions/index.ts +641 -0
- package/package.json +36 -0
- package/src/core/executor.ts +200 -0
- package/src/core/registry.ts +281 -0
- package/src/core/types.ts +104 -0
- package/src/dashboard.ts +223 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
4
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
5
|
+
import { ThreadRegistry, formatElapsed } from "../src/core/registry.js";
|
|
6
|
+
import { ThreadExecutor } from "../src/core/executor.js";
|
|
7
|
+
import { createDashboard } from "../src/dashboard.js";
|
|
8
|
+
import type { Thread, ThreadType, Story, StoryPhase } from "../src/core/types.js";
|
|
9
|
+
|
|
10
|
+
export default function (pi: ExtensionAPI) {
|
|
11
|
+
const registry = new ThreadRegistry();
|
|
12
|
+
const executor = new ThreadExecutor(pi, registry);
|
|
13
|
+
|
|
14
|
+
// ── Session persistence ──────────────────────────────────────
|
|
15
|
+
// Persist thread/story state so it survives compaction and /fork
|
|
16
|
+
|
|
17
|
+
function persistState() {
|
|
18
|
+
const threads = registry.all().filter((t) => t.state !== "killed");
|
|
19
|
+
const stories = registry.allStories();
|
|
20
|
+
if (threads.length > 0 || stories.length > 0) {
|
|
21
|
+
pi.appendEntry("pi-threads-state", { threads, stories, timestamp: Date.now() });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Save state on thread events
|
|
26
|
+
registry.on((event) => {
|
|
27
|
+
if (event.type === "thread_completed" || event.type === "thread_failed" ||
|
|
28
|
+
event.type === "story_completed" || event.type === "story_failed") {
|
|
29
|
+
persistState();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Restore state on session start
|
|
34
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
35
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
36
|
+
if (entry.type === "custom" && entry.customType === "pi-threads-state") {
|
|
37
|
+
const data = entry.data as any;
|
|
38
|
+
if (data?.threads) {
|
|
39
|
+
registry.restore(data.threads, data.stories ?? []);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ── Status bar ───────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
48
|
+
registry.on(() => {
|
|
49
|
+
const running = registry.byState("running");
|
|
50
|
+
const stories = registry.allStories().filter((s) => s.state === "executing" || s.state === "planning");
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
|
|
53
|
+
if (stories.length > 0) {
|
|
54
|
+
parts.push(`📖${stories.length}`);
|
|
55
|
+
}
|
|
56
|
+
if (running.length > 0) {
|
|
57
|
+
parts.push(
|
|
58
|
+
...running.map((t) => {
|
|
59
|
+
const sum = registry.summarize(t);
|
|
60
|
+
return `🧵${sum.type[0].toUpperCase()}:${sum.progress}`;
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
ctx.ui.setStatus("pi-threads", parts.length > 0 ? parts.join(" ") : undefined);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── Keyboard shortcut ────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
pi.registerShortcut("ctrl+shift+t", {
|
|
72
|
+
description: "Open thread dashboard",
|
|
73
|
+
handler: async (ctx) => {
|
|
74
|
+
// Trigger the /threads command
|
|
75
|
+
pi.sendUserMessage("/threads", { deliverAs: "followUp" });
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── Helpers ──────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function parseTaskArgs(args: string): string[] {
|
|
82
|
+
const tasks: string[] = [];
|
|
83
|
+
const re = /"([^"]+)"|'([^']+)'|(\S+)/g;
|
|
84
|
+
let m: RegExpExecArray | null;
|
|
85
|
+
while ((m = re.exec(args))) {
|
|
86
|
+
tasks.push(m[1] ?? m[2] ?? m[3]);
|
|
87
|
+
}
|
|
88
|
+
return tasks;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function pad(s: string, n: number): string {
|
|
92
|
+
return s.length >= n ? s.slice(0, n) : s + " ".repeat(n - s.length);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function stateIcon(state: string): string {
|
|
96
|
+
switch (state) {
|
|
97
|
+
case "running": return "⟳";
|
|
98
|
+
case "completed": return "✓";
|
|
99
|
+
case "failed": return "✗";
|
|
100
|
+
case "killed": return "☠";
|
|
101
|
+
case "pending": return "·";
|
|
102
|
+
default: return "?";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function stateColor(state: string): string {
|
|
107
|
+
switch (state) {
|
|
108
|
+
case "running": return "warning";
|
|
109
|
+
case "completed": return "success";
|
|
110
|
+
case "failed": case "killed": return "error";
|
|
111
|
+
default: return "muted";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function typeIcon(type: string): string {
|
|
116
|
+
switch (type) {
|
|
117
|
+
case "parallel": return "⫘";
|
|
118
|
+
case "chained": return "⟶";
|
|
119
|
+
case "fusion": return "⊕";
|
|
120
|
+
case "meta": return "◎";
|
|
121
|
+
case "long": return "∞";
|
|
122
|
+
case "zero": return "⊘";
|
|
123
|
+
default: return "·";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── /threads — unified TUI dashboard ─────────────────────────
|
|
128
|
+
|
|
129
|
+
pi.registerCommand("threads", {
|
|
130
|
+
description: "Thread dashboard — interactive TUI to view/manage threads and stories",
|
|
131
|
+
handler: async (args, ctx) => {
|
|
132
|
+
const subcmd = args?.trim().split(/\s+/)[0] ?? "";
|
|
133
|
+
const rest = args?.trim().slice(subcmd.length).trim() ?? "";
|
|
134
|
+
|
|
135
|
+
// Quick subcommands (no TUI)
|
|
136
|
+
if (subcmd === "kill" && rest) {
|
|
137
|
+
const t = registry.get(rest);
|
|
138
|
+
if (!t) { ctx.ui.notify(`Thread ${rest} not found`, "error"); return; }
|
|
139
|
+
registry.kill(rest);
|
|
140
|
+
ctx.ui.notify(`Killed ${rest}`, "warning");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (subcmd === "prune") {
|
|
144
|
+
registry.prune();
|
|
145
|
+
ctx.ui.notify("Pruned finished threads", "info");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (subcmd === "status") {
|
|
149
|
+
// Quick text status (no TUI)
|
|
150
|
+
const threads = registry.all();
|
|
151
|
+
const stories = registry.allStories();
|
|
152
|
+
if (threads.length === 0 && stories.length === 0) {
|
|
153
|
+
ctx.ui.notify("No active threads or stories.", "info");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const lines: string[] = [];
|
|
157
|
+
for (const s of stories) {
|
|
158
|
+
const phases = s.phases.map((p) => `${stateIcon(p.state)}${p.name}`).join("→");
|
|
159
|
+
lines.push(`📖 ${s.id} [${s.state}] ${s.goal.slice(0, 50)} — ${phases}`);
|
|
160
|
+
}
|
|
161
|
+
for (const t of threads) {
|
|
162
|
+
const sum = registry.summarize(t);
|
|
163
|
+
lines.push(`🧵 ${sum.id} ${sum.type} [${sum.state}] ${sum.progress} (${sum.elapsed}) — ${sum.label}`);
|
|
164
|
+
}
|
|
165
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (subcmd === "review") {
|
|
169
|
+
const completed = registry.byState("completed");
|
|
170
|
+
if (completed.length === 0) { ctx.ui.notify("No completed threads to review", "info"); return; }
|
|
171
|
+
for (const t of completed) {
|
|
172
|
+
const lines: string[] = [`── Thread ${t.id} (${t.type}) ──`];
|
|
173
|
+
if (t.type === "fusion") {
|
|
174
|
+
lines.push("Fusion results — compare and pick the best:\n");
|
|
175
|
+
for (const tk of t.tasks) {
|
|
176
|
+
const modelTag = tk.model ? ` [${tk.model}]` : "";
|
|
177
|
+
lines.push(`═══ ${tk.id}${modelTag} (${tk.state}) ═══`);
|
|
178
|
+
lines.push(tk.result?.slice(0, 800) ?? tk.error ?? "(no output)");
|
|
179
|
+
lines.push("");
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
for (const tk of t.tasks) {
|
|
183
|
+
lines.push(` ${stateIcon(tk.state)} ${tk.id}: ${tk.label}`);
|
|
184
|
+
if (tk.result) lines.push(` ${tk.result.slice(0, 300)}`);
|
|
185
|
+
if (tk.error) lines.push(` ERROR: ${tk.error.slice(0, 200)}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Default: open interactive TUI dashboard
|
|
194
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
195
|
+
const dashboard = createDashboard(
|
|
196
|
+
registry,
|
|
197
|
+
theme,
|
|
198
|
+
() => done(),
|
|
199
|
+
(id) => {
|
|
200
|
+
registry.kill(id);
|
|
201
|
+
ctx.ui.notify(`Killed ${id}`, "warning");
|
|
202
|
+
tui.requestRender();
|
|
203
|
+
},
|
|
204
|
+
(id) => {
|
|
205
|
+
const t = registry.get(id);
|
|
206
|
+
if (t) {
|
|
207
|
+
const preview = t.tasks.map((tk) => `${tk.id}: ${tk.result?.slice(0, 100) ?? tk.error ?? "(pending)"}`).join("\n");
|
|
208
|
+
ctx.ui.notify(`Thread ${id} results:\n${preview}`, "info");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
render: (w: number) => dashboard.render(w),
|
|
215
|
+
invalidate: () => dashboard.invalidate(),
|
|
216
|
+
handleInput: (data: string) => { dashboard.handleInput(data); tui.requestRender(); },
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ── /pthread — parallel via subagent ─────────────────────────
|
|
223
|
+
|
|
224
|
+
pi.registerCommand("pthread", {
|
|
225
|
+
description: 'P-Thread: run N tasks in parallel via subagent. Usage: /pthread "task 1" "task 2" "task 3"',
|
|
226
|
+
handler: async (args, ctx) => {
|
|
227
|
+
if (!args?.trim()) { ctx.ui.notify('Usage: /pthread "task 1" "task 2"', "error"); return; }
|
|
228
|
+
const tasks = parseTaskArgs(args);
|
|
229
|
+
if (tasks.length === 0) { ctx.ui.notify("No tasks specified", "error"); return; }
|
|
230
|
+
|
|
231
|
+
const label = tasks.length === 1 ? tasks[0] : `${tasks.length} parallel tasks`;
|
|
232
|
+
const thread = registry.create("parallel", label, tasks, { cwd: ctx.cwd, backend: "subagent" });
|
|
233
|
+
|
|
234
|
+
ctx.ui.notify(`🧵 P-Thread ${thread.id}: Dispatching ${tasks.length} task(s) via subagent...`, "info");
|
|
235
|
+
executor.dispatch(thread);
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ── /cthread — chained via subagent ──────────────────────────
|
|
240
|
+
|
|
241
|
+
pi.registerCommand("cthread", {
|
|
242
|
+
description: 'C-Thread: sequential phases via subagent chain. Usage: /cthread "phase 1" "phase 2"',
|
|
243
|
+
handler: async (args, ctx) => {
|
|
244
|
+
if (!args?.trim()) { ctx.ui.notify('Usage: /cthread "phase 1" "phase 2"', "error"); return; }
|
|
245
|
+
const phases = parseTaskArgs(args);
|
|
246
|
+
if (phases.length < 2) { ctx.ui.notify("Need at least 2 phases", "error"); return; }
|
|
247
|
+
|
|
248
|
+
const thread = registry.create("chained", `Chain: ${phases.length} phases`, phases, { cwd: ctx.cwd, backend: "subagent" });
|
|
249
|
+
|
|
250
|
+
ctx.ui.notify(`🧵 C-Thread ${thread.id}: ${phases.length} phases via subagent chain...`, "info");
|
|
251
|
+
executor.dispatch(thread);
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── /bthread — meta via subagent (scout→plan→build→review) ──
|
|
256
|
+
|
|
257
|
+
pi.registerCommand("bthread", {
|
|
258
|
+
description: "B-Thread: scout → plan → build → review via subagent. Usage: /bthread <goal>",
|
|
259
|
+
handler: async (args, ctx) => {
|
|
260
|
+
if (!args?.trim()) { ctx.ui.notify("Usage: /bthread <goal>", "error"); return; }
|
|
261
|
+
|
|
262
|
+
const thread = registry.create("meta", `Meta: ${args.slice(0, 40)}`, [args.trim()], { cwd: ctx.cwd, backend: "subagent" });
|
|
263
|
+
|
|
264
|
+
ctx.ui.notify(`🧵 B-Thread ${thread.id}: scout → plan → build → review...`, "info");
|
|
265
|
+
executor.dispatch(thread);
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ── /fthread — FUSION (native, multi-model) ──────────────────
|
|
270
|
+
// This is unique to pi-threads — no other extension does this
|
|
271
|
+
|
|
272
|
+
pi.registerCommand("fthread", {
|
|
273
|
+
description: 'F-Thread: same prompt to N agents/models, compare results. Usage: /fthread "prompt" [--count N] [--models m1,m2,m3]',
|
|
274
|
+
handler: async (args, ctx) => {
|
|
275
|
+
if (!args?.trim()) {
|
|
276
|
+
ctx.ui.notify('Usage: /fthread "prompt" [--count 3] [--models sonnet,gemini,gpt]', "error");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let count = 3;
|
|
281
|
+
let models: string[] | undefined;
|
|
282
|
+
let prompt = args;
|
|
283
|
+
|
|
284
|
+
const countMatch = args.match(/--count\s+(\d+)/);
|
|
285
|
+
if (countMatch) {
|
|
286
|
+
count = parseInt(countMatch[1]);
|
|
287
|
+
prompt = prompt.replace(countMatch[0], "").trim();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const modelsMatch = args.match(/--models\s+([\w/,.:@-]+)/);
|
|
291
|
+
if (modelsMatch) {
|
|
292
|
+
models = modelsMatch[1].split(",");
|
|
293
|
+
count = models.length;
|
|
294
|
+
prompt = prompt.replace(modelsMatch[0], "").trim();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
prompt = prompt.replace(/^["']|["']$/g, "").trim();
|
|
298
|
+
if (!prompt) { ctx.ui.notify("No prompt specified", "error"); return; }
|
|
299
|
+
|
|
300
|
+
const prompts = Array(count).fill(prompt);
|
|
301
|
+
const modelList = models ?? Array(count).fill(undefined);
|
|
302
|
+
const thread = registry.create("fusion", `Fusion: ${prompt.slice(0, 40)}`, prompts, {
|
|
303
|
+
models: modelList,
|
|
304
|
+
cwd: ctx.cwd,
|
|
305
|
+
backend: "native",
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const modelDesc = models ? models.join(", ") : `${count} agents (same model)`;
|
|
309
|
+
ctx.ui.notify(`🧵 F-Thread ${thread.id}: "${prompt.slice(0, 50)}" → ${modelDesc}`, "info");
|
|
310
|
+
|
|
311
|
+
// Fusion runs natively — fire and forget
|
|
312
|
+
executor.dispatch(thread).then(() => {
|
|
313
|
+
if (thread.state === "completed") {
|
|
314
|
+
ctx.ui.notify(`✅ F-Thread ${thread.id} done! ${count} results. Use /threads review`, "info");
|
|
315
|
+
} else {
|
|
316
|
+
ctx.ui.notify(`❌ F-Thread ${thread.id} ${thread.state}`, "error");
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ── /zthread — ZERO-TOUCH (native + verification) ────────────
|
|
323
|
+
// Unique to pi-threads — autonomous with verify gate
|
|
324
|
+
|
|
325
|
+
pi.registerCommand("zthread", {
|
|
326
|
+
description: 'Z-Thread: autonomous + verify. Usage: /zthread "prompt" --verify "npm test"',
|
|
327
|
+
handler: async (args, ctx) => {
|
|
328
|
+
if (!args?.trim()) {
|
|
329
|
+
ctx.ui.notify('Usage: /zthread "prompt" --verify "npm test"', "error");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let prompt = args;
|
|
334
|
+
let verifyCommand: string | undefined;
|
|
335
|
+
|
|
336
|
+
const verifyMatch = args.match(/--verify\s+"([^"]+)"|--verify\s+'([^']+)'|--verify\s+(\S+)/);
|
|
337
|
+
if (verifyMatch) {
|
|
338
|
+
verifyCommand = verifyMatch[1] ?? verifyMatch[2] ?? verifyMatch[3];
|
|
339
|
+
prompt = prompt.replace(verifyMatch[0], "").trim();
|
|
340
|
+
}
|
|
341
|
+
prompt = prompt.replace(/^["']|["']$/g, "").trim();
|
|
342
|
+
|
|
343
|
+
const thread = registry.create("zero", `Zero: ${prompt.slice(0, 40)}`, [prompt], {
|
|
344
|
+
cwd: ctx.cwd,
|
|
345
|
+
backend: "native",
|
|
346
|
+
verify: verifyCommand,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const verifyDesc = verifyCommand ? ` → verify: ${verifyCommand}` : "";
|
|
350
|
+
ctx.ui.notify(`🧵 Z-Thread ${thread.id}: "${prompt.slice(0, 50)}"${verifyDesc}`, "info");
|
|
351
|
+
|
|
352
|
+
executor.dispatch(thread).then(() => {
|
|
353
|
+
if (thread.state === "completed") {
|
|
354
|
+
ctx.ui.notify(`✅ Z-Thread ${thread.id} shipped!${verifyCommand ? " ✓ Verification passed." : ""}`, "info");
|
|
355
|
+
} else {
|
|
356
|
+
const err = thread.tasks[0]?.error ?? "unknown error";
|
|
357
|
+
ctx.ui.notify(`❌ Z-Thread ${thread.id} failed: ${err.slice(0, 150)}`, "error");
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ── /lthread — long-running (native) ─────────────────────────
|
|
364
|
+
|
|
365
|
+
pi.registerCommand("lthread", {
|
|
366
|
+
description: "L-Thread: extended autonomous run. Usage: /lthread <prompt>",
|
|
367
|
+
handler: async (args, ctx) => {
|
|
368
|
+
if (!args?.trim()) { ctx.ui.notify("Usage: /lthread <prompt>", "error"); return; }
|
|
369
|
+
|
|
370
|
+
const thread = registry.create("long", `Long: ${args.slice(0, 40)}`, [args.trim()], {
|
|
371
|
+
cwd: ctx.cwd,
|
|
372
|
+
backend: "native",
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
ctx.ui.notify(`🧵 L-Thread ${thread.id}: Extended run starting...`, "info");
|
|
376
|
+
|
|
377
|
+
executor.dispatch(thread).then(() => {
|
|
378
|
+
if (thread.state === "completed") {
|
|
379
|
+
ctx.ui.notify(`✅ L-Thread ${thread.id} done!`, "info");
|
|
380
|
+
} else {
|
|
381
|
+
ctx.ui.notify(`❌ L-Thread ${thread.id} ${thread.state}`, "error");
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// ── /story — STORIES (the unique layer) ──────────────────────
|
|
388
|
+
|
|
389
|
+
pi.registerCommand("story", {
|
|
390
|
+
description: 'Story mode: auto-decompose goal into thread phases. Usage: /story "Add dark mode" [--verify "npm test"]',
|
|
391
|
+
handler: async (args, ctx) => {
|
|
392
|
+
if (!args?.trim()) {
|
|
393
|
+
ctx.ui.notify('Usage: /story "goal" [--verify "npm test"]', "error");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Parse verify flag
|
|
398
|
+
let goal = args;
|
|
399
|
+
let verify: string | undefined;
|
|
400
|
+
const verifyMatch = args.match(/--verify\s+"([^"]+)"|--verify\s+'([^']+)'|--verify\s+(\S+)/);
|
|
401
|
+
if (verifyMatch) {
|
|
402
|
+
verify = verifyMatch[1] ?? verifyMatch[2] ?? verifyMatch[3];
|
|
403
|
+
goal = goal.replace(verifyMatch[0], "").trim();
|
|
404
|
+
}
|
|
405
|
+
goal = goal.replace(/^["']|["']$/g, "").trim();
|
|
406
|
+
|
|
407
|
+
const story = registry.createStory(goal, verify);
|
|
408
|
+
|
|
409
|
+
// Auto-decompose into phases
|
|
410
|
+
const phases: StoryPhase[] = [
|
|
411
|
+
{ name: "scout", threadType: "meta", state: "pending", description: `Research: ${goal}` },
|
|
412
|
+
{ name: "plan", threadType: "fusion", state: "pending", description: `3 models brainstorm approaches for: ${goal}` },
|
|
413
|
+
{ name: "decide", threadType: "chained", state: "pending", description: "Human picks the best approach" },
|
|
414
|
+
{ name: "build", threadType: "parallel", state: "pending", description: `Implement: ${goal}` },
|
|
415
|
+
];
|
|
416
|
+
|
|
417
|
+
if (verify) {
|
|
418
|
+
phases.push({ name: "verify", threadType: "zero", state: "pending", description: `Verify: ${verify}` });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
for (const p of phases) {
|
|
422
|
+
registry.addPhase(story.id, p);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const phaseNames = phases.map((p) => p.name).join(" → ");
|
|
426
|
+
ctx.ui.notify(`📖 Story ${story.id}: "${goal.slice(0, 50)}"\n Phases: ${phaseNames}`, "info");
|
|
427
|
+
|
|
428
|
+
// Start with the scout phase — send it to the LLM to execute
|
|
429
|
+
registry.startPhase(story.id, 0, "pending");
|
|
430
|
+
|
|
431
|
+
// The story orchestration happens via the LLM — we give it the plan
|
|
432
|
+
const storyPrompt = [
|
|
433
|
+
`## Story ${story.id}: ${goal}`,
|
|
434
|
+
"",
|
|
435
|
+
"Execute this story phase by phase. Use the thread tools.",
|
|
436
|
+
"",
|
|
437
|
+
"### Phases:",
|
|
438
|
+
...phases.map((p, i) => `${i + 1}. **${p.name}** (${p.threadType}): ${p.description}`),
|
|
439
|
+
"",
|
|
440
|
+
"### Instructions:",
|
|
441
|
+
"1. Start with phase 1 (scout) — use `thread_spawn` with type 'meta'",
|
|
442
|
+
"2. For phase 2 (plan), use `thread_spawn` with type 'fusion' and --models if available",
|
|
443
|
+
"3. Present the fusion results to the user for phase 3 (decide)",
|
|
444
|
+
"4. Execute phase 4 (build) based on the chosen approach",
|
|
445
|
+
verify ? `5. Run verification: \`${verify}\`` : "",
|
|
446
|
+
"",
|
|
447
|
+
"Check progress with `thread_status`. Report when done.",
|
|
448
|
+
].filter(Boolean).join("\n");
|
|
449
|
+
|
|
450
|
+
pi.sendUserMessage(storyPrompt, { deliverAs: "followUp" });
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// ── /stories — list stories ──────────────────────────────────
|
|
455
|
+
|
|
456
|
+
pi.registerCommand("stories", {
|
|
457
|
+
description: "List all stories",
|
|
458
|
+
handler: async (_args, ctx) => {
|
|
459
|
+
const stories = registry.allStories();
|
|
460
|
+
if (stories.length === 0) {
|
|
461
|
+
ctx.ui.notify("No stories. Use /story to start one.", "info");
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const lines: string[] = ["📖 Stories", ""];
|
|
466
|
+
for (const s of stories) {
|
|
467
|
+
const phases = s.phases.map((p) => `${stateIcon(p.state)}${p.name}`).join(" → ");
|
|
468
|
+
lines.push(` ${s.id} [${s.state}] ${s.goal.slice(0, 60)}`);
|
|
469
|
+
lines.push(` ${phases}`);
|
|
470
|
+
}
|
|
471
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// ── LLM-callable tools ───────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
pi.registerTool({
|
|
478
|
+
name: "thread_spawn",
|
|
479
|
+
label: "Thread Spawn",
|
|
480
|
+
description: [
|
|
481
|
+
"Spawn a thread. Types:",
|
|
482
|
+
"- parallel: N independent tasks in parallel (via subagent)",
|
|
483
|
+
"- chained: sequential phases with checkpoints (via subagent)",
|
|
484
|
+
"- meta: scout→plan→build→review pipeline (via subagent)",
|
|
485
|
+
"- fusion: same prompt to N agents/models, compare results (native, UNIQUE)",
|
|
486
|
+
"- zero: autonomous + verification command gate (native, UNIQUE)",
|
|
487
|
+
"- long: extended autonomous run (native)",
|
|
488
|
+
].join("\n"),
|
|
489
|
+
parameters: Type.Object({
|
|
490
|
+
type: StringEnum(["parallel", "fusion", "chained", "meta", "long", "zero"] as const),
|
|
491
|
+
prompts: Type.Array(Type.String(), { description: "Task prompts" }),
|
|
492
|
+
models: Type.Optional(Type.Array(Type.String(), { description: "Models for fusion (e.g. ['anthropic/claude-sonnet-4', 'google/gemini-2.5-pro'])" })),
|
|
493
|
+
count: Type.Optional(Type.Number({ description: "Agent count for fusion (default 3)" })),
|
|
494
|
+
verify: Type.Optional(Type.String({ description: "Verification command for zero-touch (e.g. 'npm test')" })),
|
|
495
|
+
agent: Type.Optional(Type.String({ description: "Subagent agent name (default: worker)" })),
|
|
496
|
+
backend: Type.Optional(StringEnum(["subagent", "native"] as const)),
|
|
497
|
+
}),
|
|
498
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
499
|
+
const { type, prompts, models, count, verify, agent, backend: backendOverride } = params;
|
|
500
|
+
let taskPrompts = prompts;
|
|
501
|
+
|
|
502
|
+
if (type === "fusion") {
|
|
503
|
+
const n = count ?? models?.length ?? 3;
|
|
504
|
+
taskPrompts = Array(n).fill(prompts[0]);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (type === "meta" && prompts.length === 1) {
|
|
508
|
+
taskPrompts = [prompts[0]]; // Meta delegates to subagent chain internally
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Auto-select backend
|
|
512
|
+
const backend = backendOverride ?? (type === "fusion" || type === "zero" || type === "long" ? "native" : "subagent");
|
|
513
|
+
|
|
514
|
+
const label = type === "fusion"
|
|
515
|
+
? `Fusion: ${prompts[0]?.slice(0, 40)}`
|
|
516
|
+
: `${type}: ${taskPrompts.length} tasks`;
|
|
517
|
+
|
|
518
|
+
const thread = registry.create(type as any, label, taskPrompts, {
|
|
519
|
+
models,
|
|
520
|
+
cwd: ctx.cwd,
|
|
521
|
+
backend,
|
|
522
|
+
agent,
|
|
523
|
+
verify,
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// Dispatch (async — runs in background)
|
|
527
|
+
executor.dispatch(thread);
|
|
528
|
+
|
|
529
|
+
const modelInfo = models ? ` Models: ${models.join(", ")}` : "";
|
|
530
|
+
const verifyInfo = verify ? ` Verify: ${verify}` : "";
|
|
531
|
+
const backendInfo = backend === "subagent" ? " (via pi-subagents)" : " (native pi -p)";
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
content: [{
|
|
535
|
+
type: "text",
|
|
536
|
+
text: `Thread ${thread.id} (${type}) spawned.${backendInfo}${modelInfo}${verifyInfo}\n${taskPrompts.length} task(s). Use /threads or thread_status to monitor.`,
|
|
537
|
+
}],
|
|
538
|
+
details: { threadId: thread.id, type, taskCount: taskPrompts.length, backend },
|
|
539
|
+
};
|
|
540
|
+
},
|
|
541
|
+
renderCall(args, theme) {
|
|
542
|
+
return new Text(
|
|
543
|
+
theme.fg("toolTitle", theme.bold("thread_spawn ")) +
|
|
544
|
+
theme.fg("accent", args.type) +
|
|
545
|
+
theme.fg("muted", ` (${args.prompts?.length ?? "?"} tasks)`),
|
|
546
|
+
0, 0
|
|
547
|
+
);
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
pi.registerTool({
|
|
552
|
+
name: "thread_status",
|
|
553
|
+
label: "Thread Status",
|
|
554
|
+
description: "Get status of all threads and stories, or a specific thread/story by ID",
|
|
555
|
+
parameters: Type.Object({
|
|
556
|
+
id: Type.Optional(Type.String({ description: "Thread ID (t-001) or Story ID (s-001). Omit for all." })),
|
|
557
|
+
}),
|
|
558
|
+
async execute(_toolCallId, params) {
|
|
559
|
+
if (params.id) {
|
|
560
|
+
// Check threads first
|
|
561
|
+
const t = registry.get(params.id);
|
|
562
|
+
if (t) {
|
|
563
|
+
const sum = registry.summarize(t);
|
|
564
|
+
const taskDetails = t.tasks
|
|
565
|
+
.map((tk) => {
|
|
566
|
+
let line = ` ${stateIcon(tk.state)} ${tk.id} [${tk.state}] ${tk.label}`;
|
|
567
|
+
if (tk.model) line += ` [${tk.model}]`;
|
|
568
|
+
if (tk.result) line += `\n ${tk.result.slice(0, 300)}`;
|
|
569
|
+
if (tk.error) line += `\n ERROR: ${tk.error.slice(0, 200)}`;
|
|
570
|
+
return line;
|
|
571
|
+
})
|
|
572
|
+
.join("\n");
|
|
573
|
+
return {
|
|
574
|
+
content: [{
|
|
575
|
+
type: "text",
|
|
576
|
+
text: `Thread ${sum.id} (${sum.type}) [${sum.backend}] — ${sum.state} — ${sum.progress} — ${sum.elapsed}\n${taskDetails}`,
|
|
577
|
+
}],
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Check stories
|
|
582
|
+
const s = registry.getStory(params.id);
|
|
583
|
+
if (s) {
|
|
584
|
+
const phases = s.phases
|
|
585
|
+
.map((p) => ` ${stateIcon(p.state)} ${p.name} (${p.threadType}): ${p.description}${p.threadId ? ` [${p.threadId}]` : ""}`)
|
|
586
|
+
.join("\n");
|
|
587
|
+
return {
|
|
588
|
+
content: [{
|
|
589
|
+
type: "text",
|
|
590
|
+
text: `Story ${s.id} [${s.state}] — ${s.goal}\n${phases}`,
|
|
591
|
+
}],
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return { content: [{ type: "text", text: `ID ${params.id} not found` }], isError: true };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// All
|
|
599
|
+
const lines: string[] = [];
|
|
600
|
+
const stories = registry.allStories();
|
|
601
|
+
const threads = registry.all();
|
|
602
|
+
|
|
603
|
+
if (stories.length > 0) {
|
|
604
|
+
lines.push("📖 Stories:");
|
|
605
|
+
for (const s of stories) {
|
|
606
|
+
const phases = s.phases.map((p) => `${stateIcon(p.state)}${p.name}`).join("→");
|
|
607
|
+
lines.push(` ${s.id} [${s.state}] ${s.goal.slice(0, 50)} — ${phases}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (threads.length > 0) {
|
|
612
|
+
lines.push("🧵 Threads:");
|
|
613
|
+
for (const t of threads) {
|
|
614
|
+
const s = registry.summarize(t);
|
|
615
|
+
lines.push(` ${s.id} ${typeIcon(s.type)} ${s.type} [${s.state}] ${s.progress} (${s.elapsed}) [${s.backend}] — ${s.label}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (lines.length === 0) {
|
|
620
|
+
return { content: [{ type: "text", text: "No threads or stories." }] };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
pi.registerTool({
|
|
628
|
+
name: "thread_kill",
|
|
629
|
+
label: "Thread Kill",
|
|
630
|
+
description: "Kill a running thread by ID",
|
|
631
|
+
parameters: Type.Object({
|
|
632
|
+
id: Type.String({ description: "Thread ID to kill" }),
|
|
633
|
+
}),
|
|
634
|
+
async execute(_toolCallId, params) {
|
|
635
|
+
const t = registry.get(params.id);
|
|
636
|
+
if (!t) return { content: [{ type: "text", text: `Thread ${params.id} not found` }], isError: true };
|
|
637
|
+
registry.kill(params.id);
|
|
638
|
+
return { content: [{ type: "text", text: `Thread ${params.id} killed.` }] };
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-thread-engine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Thread engineering for pi — all 7 thread types, stories, fusion, zero-touch, TUI dashboard",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "artale",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/arosstale/pi-threads"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi-package",
|
|
14
|
+
"pi",
|
|
15
|
+
"threads",
|
|
16
|
+
"thread-engineering",
|
|
17
|
+
"multi-agent",
|
|
18
|
+
"fusion",
|
|
19
|
+
"orchestration"
|
|
20
|
+
],
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": ["./extensions/index.ts"]
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"extensions/",
|
|
26
|
+
"src/",
|
|
27
|
+
"README.md",
|
|
28
|
+
"PLAN.md"
|
|
29
|
+
],
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
32
|
+
"@mariozechner/pi-tui": "*",
|
|
33
|
+
"@sinclair/typebox": "*",
|
|
34
|
+
"@mariozechner/pi-ai": "*"
|
|
35
|
+
}
|
|
36
|
+
}
|