pi-thread-engine 0.4.2 → 0.4.4

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