pi-thread-engine 0.4.2 → 0.4.3

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,699 @@
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
- // ── /cthreadchained 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
- // ── /bthreadmeta 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
+
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
+ // ── /threads export ──
193
+ if (subcmd === "export") {
194
+ const exportAll = rest === "--all" || rest === "-a";
195
+ const exportId = !exportAll && rest ? rest : null;
196
+ const src = exportAll ? registry.all() : exportId ? [registry.get(exportId)].filter(Boolean) : [];
197
+ const stSrc = exportAll ? registry.allStories() : exportId ? [registry.getStory(exportId)].filter(Boolean) : [];
198
+ if (src.length === 0 && stSrc.length === 0) {
199
+ ctx.ui.notify("Nothing to export", "info");
200
+ return;
201
+ }
202
+ const md = ["# pi-thread-engine Export", ""];
203
+ for (const s of stSrc) {
204
+ md.push("## Story: " + s.id);
205
+ md.push("Goal: " + s.goal);
206
+ md.push("State: " + s.state);
207
+ }
208
+ for (const t of src) {
209
+ const sum = registry.summarize(t);
210
+ md.push("## " + sum.id + ": " + sum.type);
211
+ md.push("Label: " + sum.label);
212
+ md.push("State: " + sum.state);
213
+ for (const tk of t.tasks) {
214
+ md.push("- " + tk.id + " [" + tk.state + "]: " + (tk.result ? tk.result.slice(0, 200) : tk.error ? "ERROR: " + tk.error.slice(0, 200) : "(no result)"));
215
+ }
216
+ }
217
+ const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
218
+ const fn = "threads-" + ts + ".md";
219
+ const outPath = require("path").join(ctx.cwd, fn);
220
+ require("fs").writeFileSync(outPath, md.join("\n"), "utf8");
221
+ ctx.ui.notify("Exported to " + outPath, "info");
222
+ return;
223
+ }
224
+
225
+ // Default: open interactive TUI dashboard
226
+ // ── /agents alias (Claude-style muscle memory) ──────────────
227
+ pi.registerCommand("agents", {
228
+ description: "Alias for /threads — Claude-style Agent View dashboard",
229
+ handler: async (a, c) => {
230
+ // Forward to /threads
231
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
232
+ const dashboard = createDashboard(
233
+ registry,
234
+ theme,
235
+ () => done(),
236
+ (id) => { registry.kill(id); ctx.ui.notify(`Killed ${id}`, "warning"); tui.requestRender(); },
237
+ (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"); } },
238
+ (id, message) => { executor.injectReply(id, message); ctx.ui.notify(`Replied to ${id}: ${message.slice(0, 50)}...`, "info"); tui.requestRender(); }
239
+ );
240
+ return { render: (w: number) => dashboard.render(w), invalidate: () => dashboard.invalidate(), handleInput: (data: string) => { dashboard.handleInput(data); tui.requestRender(); } };
241
+ });
242
+ },
243
+ });
244
+ // ── End /agents alias ───────────────────────────────────────
245
+
246
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
247
+ const dashboard = createDashboard(
248
+ registry,
249
+ theme,
250
+ () => done(),
251
+ (id) => {
252
+ registry.kill(id);
253
+ ctx.ui.notify(`Killed ${id}`, "warning");
254
+ tui.requestRender();
255
+ },
256
+ (id) => {
257
+ const t = registry.get(id);
258
+ if (t) {
259
+ const preview = t.tasks.map((tk) => `${tk.id}: ${tk.result?.slice(0, 100) ?? tk.error ?? "(pending)"}`).join("\n");
260
+ ctx.ui.notify(`Thread ${id} results:\n${preview}`, "info");
261
+ }
262
+ },
263
+ (id, message) => {
264
+ // Inline replysend message to blocked thread
265
+ executor.injectReply(id, message);
266
+ ctx.ui.notify(`Replied to ${id}: ${message.slice(0, 50)}...`, "info");
267
+ tui.requestRender();
268
+ }
269
+ );
270
+
271
+ return {
272
+ render: (w: number) => dashboard.render(w),
273
+ invalidate: () => dashboard.invalidate(),
274
+ handleInput: (data: string) => { dashboard.handleInput(data); tui.requestRender(); },
275
+ };
276
+ });
277
+ },
278
+ });
279
+
280
+ // ── /pthreadparallel via subagent ─────────────────────────
281
+
282
+ pi.registerCommand("pthread", {
283
+ description: 'P-Thread: run N tasks in parallel via subagent. Usage: /pthread "task 1" "task 2" "task 3"',
284
+ handler: async (args, ctx) => {
285
+ if (!args?.trim()) { ctx.ui.notify('Usage: /pthread "task 1" "task 2"', "error"); return; }
286
+ const tasks = parseTaskArgs(args);
287
+ if (tasks.length === 0) { ctx.ui.notify("No tasks specified", "error"); return; }
288
+
289
+ const label = tasks.length === 1 ? tasks[0] : `${tasks.length} parallel tasks`;
290
+ const thread = registry.create("parallel", label, tasks, { cwd: ctx.cwd, backend: "subagent" });
291
+
292
+ ctx.ui.notify(`🧵 P-Thread ${thread.id}: Dispatching ${tasks.length} task(s) via subagent...`, "info");
293
+ executor.dispatch(thread);
294
+ },
295
+ });
296
+
297
+ // ── /cthread — chained via subagent ──────────────────────────
298
+
299
+ pi.registerCommand("cthread", {
300
+ description: 'C-Thread: sequential phases via subagent chain. Usage: /cthread "phase 1" "phase 2"',
301
+ handler: async (args, ctx) => {
302
+ if (!args?.trim()) { ctx.ui.notify('Usage: /cthread "phase 1" "phase 2"', "error"); return; }
303
+ const phases = parseTaskArgs(args);
304
+ if (phases.length < 2) { ctx.ui.notify("Need at least 2 phases", "error"); return; }
305
+
306
+ const thread = registry.create("chained", `Chain: ${phases.length} phases`, phases, { cwd: ctx.cwd, backend: "subagent" });
307
+
308
+ ctx.ui.notify(`🧵 C-Thread ${thread.id}: ${phases.length} phases via subagent chain...`, "info");
309
+ executor.dispatch(thread);
310
+ },
311
+ });
312
+
313
+ // ── /bthread — meta via subagent (scout→plan→build→review) ──
314
+
315
+ pi.registerCommand("bthread", {
316
+ description: "B-Thread: scout → plan → build → review via subagent. Usage: /bthread <goal>",
317
+ handler: async (args, ctx) => {
318
+ if (!args?.trim()) { ctx.ui.notify("Usage: /bthread <goal>", "error"); return; }
319
+
320
+ const thread = registry.create("meta", `Meta: ${args.slice(0, 40)}`, [args.trim()], { cwd: ctx.cwd, backend: "subagent" });
321
+
322
+ ctx.ui.notify(`🧵 B-Thread ${thread.id}: scout → plan → build → review...`, "info");
323
+ executor.dispatch(thread);
324
+ },
325
+ });
326
+
327
+ // ── /fthread FUSION (native, multi-model) ──────────────────
328
+ // This is unique to pi-threads — no other extension does this
329
+
330
+ pi.registerCommand("fthread", {
331
+ description: 'F-Thread: same prompt to N agents/models, compare results. Usage: /fthread "prompt" [--count N] [--models m1,m2,m3]',
332
+ handler: async (args, ctx) => {
333
+ if (!args?.trim()) {
334
+ ctx.ui.notify('Usage: /fthread "prompt" [--count 3] [--models sonnet,gemini,gpt]', "error");
335
+ return;
336
+ }
337
+
338
+ let count = 3;
339
+ let models: string[] | undefined;
340
+ let prompt = args;
341
+
342
+ const countMatch = args.match(/--count\s+(\d+)/);
343
+ if (countMatch) {
344
+ count = parseInt(countMatch[1]);
345
+ prompt = prompt.replace(countMatch[0], "").trim();
346
+ }
347
+
348
+ const modelsMatch = args.match(/--models\s+([\w/,.:@-]+)/);
349
+ if (modelsMatch) {
350
+ models = modelsMatch[1].split(",");
351
+ count = models.length;
352
+ prompt = prompt.replace(modelsMatch[0], "").trim();
353
+ }
354
+
355
+ prompt = prompt.replace(/^["']|["']$/g, "").trim();
356
+ if (!prompt) { ctx.ui.notify("No prompt specified", "error"); return; }
357
+
358
+ const prompts = Array(count).fill(prompt);
359
+ const modelList = models ?? Array(count).fill(undefined);
360
+ const thread = registry.create("fusion", `Fusion: ${prompt.slice(0, 40)}`, prompts, {
361
+ models: modelList,
362
+ cwd: ctx.cwd,
363
+ backend: "native",
364
+ });
365
+
366
+ const modelDesc = models ? models.join(", ") : `${count} agents (same model)`;
367
+ ctx.ui.notify(`🧵 F-Thread ${thread.id}: "${prompt.slice(0, 50)}" → ${modelDesc}`, "info");
368
+
369
+ // Fusion runs natively — fire and forget
370
+ executor.dispatch(thread).then(() => {
371
+ if (thread.state === "completed") {
372
+ ctx.ui.notify(`✅ F-Thread ${thread.id} done! ${count} results. Use /threads review`, "info");
373
+ } else {
374
+ ctx.ui.notify(`❌ F-Thread ${thread.id} ${thread.state}`, "error");
375
+ }
376
+ });
377
+ },
378
+ });
379
+
380
+ // ── /zthread — ZERO-TOUCH (native + verification) ────────────
381
+ // Unique to pi-threads autonomous with verify gate
382
+
383
+ pi.registerCommand("zthread", {
384
+ description: 'Z-Thread: autonomous + verify. Usage: /zthread "prompt" --verify "npm test"',
385
+ handler: async (args, ctx) => {
386
+ if (!args?.trim()) {
387
+ ctx.ui.notify('Usage: /zthread "prompt" --verify "npm test"', "error");
388
+ return;
389
+ }
390
+
391
+ let prompt = args;
392
+ let verifyCommand: string | undefined;
393
+
394
+ const verifyMatch = args.match(/--verify\s+"([^"]+)"|--verify\s+'([^']+)'|--verify\s+(\S+)/);
395
+ if (verifyMatch) {
396
+ verifyCommand = verifyMatch[1] ?? verifyMatch[2] ?? verifyMatch[3];
397
+ prompt = prompt.replace(verifyMatch[0], "").trim();
398
+ }
399
+ prompt = prompt.replace(/^["']|["']$/g, "").trim();
400
+
401
+ const thread = registry.create("zero", `Zero: ${prompt.slice(0, 40)}`, [prompt], {
402
+ cwd: ctx.cwd,
403
+ backend: "native",
404
+ verify: verifyCommand,
405
+ });
406
+
407
+ const verifyDesc = verifyCommand ? ` → verify: ${verifyCommand}` : "";
408
+ ctx.ui.notify(`🧵 Z-Thread ${thread.id}: "${prompt.slice(0, 50)}"${verifyDesc}`, "info");
409
+
410
+ executor.dispatch(thread).then(() => {
411
+ if (thread.state === "completed") {
412
+ ctx.ui.notify(`✅ Z-Thread ${thread.id} shipped!${verifyCommand ? " Verification passed." : ""}`, "info");
413
+ } else {
414
+ const err = thread.tasks[0]?.error ?? "unknown error";
415
+ ctx.ui.notify(`❌ Z-Thread ${thread.id} failed: ${err.slice(0, 150)}`, "error");
416
+ }
417
+ });
418
+ },
419
+ });
420
+
421
+ // ── /lthread — long-running (native) ─────────────────────────
422
+
423
+ pi.registerCommand("lthread", {
424
+ description: "L-Thread: extended autonomous run. Usage: /lthread <prompt>",
425
+ handler: async (args, ctx) => {
426
+ if (!args?.trim()) { ctx.ui.notify("Usage: /lthread <prompt>", "error"); return; }
427
+
428
+ const thread = registry.create("long", `Long: ${args.slice(0, 40)}`, [args.trim()], {
429
+ cwd: ctx.cwd,
430
+ backend: "native",
431
+ });
432
+
433
+ ctx.ui.notify(`🧵 L-Thread ${thread.id}: Extended run starting...`, "info");
434
+
435
+ executor.dispatch(thread).then(() => {
436
+ if (thread.state === "completed") {
437
+ ctx.ui.notify(`✅ L-Thread ${thread.id} done!`, "info");
438
+ } else {
439
+ ctx.ui.notify(`❌ L-Thread ${thread.id} ${thread.state}`, "error");
440
+ }
441
+ });
442
+ },
443
+ });
444
+
445
+ // ── /story — STORIES (the unique layer) ──────────────────────
446
+
447
+ pi.registerCommand("story", {
448
+ description: 'Story mode: auto-decompose goal into thread phases. Usage: /story "Add dark mode" [--verify "npm test"]',
449
+ handler: async (args, ctx) => {
450
+ if (!args?.trim()) {
451
+ ctx.ui.notify('Usage: /story "goal" [--verify "npm test"]', "error");
452
+ return;
453
+ }
454
+
455
+ // Parse verify flag
456
+ let goal = args;
457
+ let verify: string | undefined;
458
+ const verifyMatch = args.match(/--verify\s+"([^"]+)"|--verify\s+'([^']+)'|--verify\s+(\S+)/);
459
+ if (verifyMatch) {
460
+ verify = verifyMatch[1] ?? verifyMatch[2] ?? verifyMatch[3];
461
+ goal = goal.replace(verifyMatch[0], "").trim();
462
+ }
463
+ goal = goal.replace(/^["']|["']$/g, "").trim();
464
+
465
+ const story = registry.createStory(goal, verify);
466
+
467
+ // Auto-decompose into phases
468
+ const phases: StoryPhase[] = [
469
+ { name: "scout", threadType: "meta", state: "pending", description: `Research: ${goal}` },
470
+ { name: "plan", threadType: "fusion", state: "pending", description: `3 models brainstorm approaches for: ${goal}` },
471
+ { name: "decide", threadType: "chained", state: "pending", description: "Human picks the best approach" },
472
+ { name: "build", threadType: "parallel", state: "pending", description: `Implement: ${goal}` },
473
+ ];
474
+
475
+ if (verify) {
476
+ phases.push({ name: "verify", threadType: "zero", state: "pending", description: `Verify: ${verify}` });
477
+ }
478
+
479
+ for (const p of phases) {
480
+ registry.addPhase(story.id, p);
481
+ }
482
+
483
+ const phaseNames = phases.map((p) => p.name).join(" → ");
484
+ ctx.ui.notify(`📖 Story ${story.id}: "${goal.slice(0, 50)}"\n Phases: ${phaseNames}`, "info");
485
+
486
+ // Start with the scout phase — send it to the LLM to execute
487
+ registry.startPhase(story.id, 0, "pending");
488
+
489
+ // The story orchestration happens via the LLM — we give it the plan
490
+ const storyPrompt = [
491
+ `## Story ${story.id}: ${goal}`,
492
+ "",
493
+ "Execute this story phase by phase. Use the thread tools.",
494
+ "",
495
+ "### Phases:",
496
+ ...phases.map((p, i) => `${i + 1}. **${p.name}** (${p.threadType}): ${p.description}`),
497
+ "",
498
+ "### Instructions:",
499
+ "1. Start with phase 1 (scout) — use `thread_spawn` with type 'meta'",
500
+ "2. For phase 2 (plan), use `thread_spawn` with type 'fusion' and --models if available",
501
+ "3. Present the fusion results to the user for phase 3 (decide)",
502
+ "4. Execute phase 4 (build) based on the chosen approach",
503
+ verify ? `5. Run verification: \`${verify}\`` : "",
504
+ "",
505
+ "Check progress with `thread_status`. Report when done.",
506
+ ].filter(Boolean).join("\n");
507
+
508
+ pi.sendUserMessage(storyPrompt, { deliverAs: "followUp" });
509
+ },
510
+ });
511
+
512
+ // ── /stories list stories ──────────────────────────────────
513
+
514
+ pi.registerCommand("stories", {
515
+ description: "List all stories",
516
+ handler: async (_args, ctx) => {
517
+ const stories = registry.allStories();
518
+ if (stories.length === 0) {
519
+ ctx.ui.notify("No stories. Use /story to start one.", "info");
520
+ return;
521
+ }
522
+
523
+ const lines: string[] = ["📖 Stories", ""];
524
+ for (const s of stories) {
525
+ const phases = s.phases.map((p) => `${stateIcon(p.state)}${p.name}`).join(" → ");
526
+ lines.push(` ${s.id} [${s.state}] ${s.goal.slice(0, 60)}`);
527
+ lines.push(` ${phases}`);
528
+ }
529
+ ctx.ui.notify(lines.join("\n"), "info");
530
+ },
531
+ });
532
+
533
+ // ── LLM-callable tools ───────────────────────────────────────
534
+
535
+ pi.registerTool({
536
+ name: "thread_spawn",
537
+ label: "Thread Spawn",
538
+ description: [
539
+ "Spawn a thread. Types:",
540
+ "- parallel: N independent tasks in parallel (via subagent)",
541
+ "- chained: sequential phases with checkpoints (via subagent)",
542
+ "- meta: scout→plan→build→review pipeline (via subagent)",
543
+ "- fusion: same prompt to N agents/models, compare results (native, UNIQUE)",
544
+ "- zero: autonomous + verification command gate (native, UNIQUE)",
545
+ "- long: extended autonomous run (native)",
546
+ ].join("\n"),
547
+ parameters: Type.Object({
548
+ type: StringEnum(["parallel", "fusion", "chained", "meta", "long", "zero"] as const),
549
+ prompts: Type.Array(Type.String(), { description: "Task prompts" }),
550
+ models: Type.Optional(Type.Array(Type.String(), { description: "Models for fusion (e.g. ['anthropic/claude-sonnet-4', 'google/gemini-2.5-pro'])" })),
551
+ count: Type.Optional(Type.Number({ description: "Agent count for fusion (default 3)" })),
552
+ verify: Type.Optional(Type.String({ description: "Verification command for zero-touch (e.g. 'npm test')" })),
553
+ agent: Type.Optional(Type.String({ description: "Subagent agent name (default: worker)" })),
554
+ backend: Type.Optional(StringEnum(["subagent", "native"] as const)),
555
+ }),
556
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
557
+ const { type, prompts, models, count, verify, agent, backend: backendOverride } = params;
558
+ let taskPrompts = prompts;
559
+
560
+ if (type === "fusion") {
561
+ const n = count ?? models?.length ?? 3;
562
+ taskPrompts = Array(n).fill(prompts[0]);
563
+ }
564
+
565
+ if (type === "meta" && prompts.length === 1) {
566
+ taskPrompts = [prompts[0]]; // Meta delegates to subagent chain internally
567
+ }
568
+
569
+ // Auto-select backend
570
+ const backend = backendOverride ?? (type === "fusion" || type === "zero" || type === "long" ? "native" : "subagent");
571
+
572
+ const label = type === "fusion"
573
+ ? `Fusion: ${prompts[0]?.slice(0, 40)}`
574
+ : `${type}: ${taskPrompts.length} tasks`;
575
+
576
+ const thread = registry.create(type as ThreadType, label, taskPrompts, {
577
+ models,
578
+ cwd: ctx.cwd,
579
+ backend,
580
+ agent,
581
+ verify,
582
+ });
583
+
584
+ // Dispatch (async — runs in background)
585
+ executor.dispatch(thread);
586
+
587
+ const modelInfo = models ? ` Models: ${models.join(", ")}` : "";
588
+ const verifyInfo = verify ? ` Verify: ${verify}` : "";
589
+ const backendInfo = backend === "subagent" ? " (via pi-subagents)" : " (native pi -p)";
590
+
591
+ return {
592
+ content: [{
593
+ type: "text",
594
+ text: `Thread ${thread.id} (${type}) spawned.${backendInfo}${modelInfo}${verifyInfo}\n${taskPrompts.length} task(s). Use /threads or thread_status to monitor.`,
595
+ }],
596
+ details: { threadId: thread.id, type, taskCount: taskPrompts.length, backend },
597
+ };
598
+ },
599
+ renderCall(args, theme) {
600
+ return new Text(
601
+ theme.fg("toolTitle", theme.bold("thread_spawn ")) +
602
+ theme.fg("accent", args.type) +
603
+ theme.fg("muted", ` (${args.prompts?.length ?? "?"} tasks)`),
604
+ 0, 0
605
+ );
606
+ },
607
+ });
608
+
609
+ pi.registerTool({
610
+ name: "thread_status",
611
+ label: "Thread Status",
612
+ description: "Get status of all threads and stories, or a specific thread/story by ID",
613
+ parameters: Type.Object({
614
+ id: Type.Optional(Type.String({ description: "Thread ID (t-001) or Story ID (s-001). Omit for all." })),
615
+ }),
616
+ async execute(_toolCallId, params, _signal?: any, _onUpdate?: any, _ctx?: any) {
617
+ if (params.id) {
618
+ // Check threads first
619
+ const t = registry.get(params.id);
620
+ if (t) {
621
+ const sum = registry.summarize(t);
622
+ const taskDetails = t.tasks
623
+ .map((tk) => {
624
+ let line = ` ${stateIcon(tk.state)} ${tk.id} [${tk.state}] ${tk.label}`;
625
+ if (tk.model) line += ` [${tk.model}]`;
626
+ if (tk.result) line += `\n ${tk.result.slice(0, 300)}`;
627
+ if (tk.error) line += `\n ERROR: ${tk.error.slice(0, 200)}`;
628
+ return line;
629
+ })
630
+ .join("\n");
631
+ return {
632
+ content: [{
633
+ type: "text",
634
+ text: `Thread ${sum.id} (${sum.type}) [${sum.backend}] — ${sum.state} — ${sum.progress} — ${sum.elapsed}\n${taskDetails}`,
635
+ }],
636
+ };
637
+ }
638
+
639
+ // Check stories
640
+ const s = registry.getStory(params.id);
641
+ if (s) {
642
+ const phases = s.phases
643
+ .map((p) => ` ${stateIcon(p.state)} ${p.name} (${p.threadType}): ${p.description}${p.threadId ? ` [${p.threadId}]` : ""}`)
644
+ .join("\n");
645
+ return {
646
+ content: [{
647
+ type: "text",
648
+ text: `Story ${s.id} [${s.state}] ${s.goal}\n${phases}`,
649
+ }],
650
+ };
651
+ }
652
+
653
+ return { content: [{ type: "text", text: `ID ${params.id} not found` }], isError: true } as any;
654
+ }
655
+
656
+ // All
657
+ const lines: string[] = [];
658
+ const stories = registry.allStories();
659
+ const threads = registry.all();
660
+
661
+ if (stories.length > 0) {
662
+ lines.push("📖 Stories:");
663
+ for (const s of stories) {
664
+ const phases = s.phases.map((p) => `${stateIcon(p.state)}${p.name}`).join("→");
665
+ lines.push(` ${s.id} [${s.state}] ${s.goal.slice(0, 50)} — ${phases}`);
666
+ }
667
+ }
668
+
669
+ if (threads.length > 0) {
670
+ lines.push("🧵 Threads:");
671
+ for (const t of threads) {
672
+ const s = registry.summarize(t);
673
+ lines.push(` ${s.id} ${typeIcon(s.type)} ${s.type} [${s.state}] ${s.progress} (${s.elapsed}) [${s.backend}] — ${s.label}`);
674
+ }
675
+ }
676
+
677
+ if (lines.length === 0) {
678
+ return { content: [{ type: "text", text: "No threads or stories." }] } as any;
679
+ }
680
+
681
+ return { content: [{ type: "text", text: lines.join("\n") }] } as any;
682
+ },
683
+ });
684
+
685
+ pi.registerTool({
686
+ name: "thread_kill",
687
+ label: "Thread Kill",
688
+ description: "Kill a running thread by ID",
689
+ parameters: Type.Object({
690
+ id: Type.String({ description: "Thread ID to kill" }),
691
+ }),
692
+ async execute(_toolCallId, params, _signal?: any, _onUpdate?: any, _ctx?: any) {
693
+ const t = registry.get(params.id);
694
+ if (!t) return { content: [{ type: "text", text: `Thread ${params.id} not found` }], isError: true };
695
+ registry.kill(params.id);
696
+ return { content: [{ type: "text", text: `Thread ${params.id} killed.` }] } as any;
697
+ },
698
+ });
699
+ }