pi-thread-engine 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PLAN.md CHANGED
@@ -1,33 +1,53 @@
1
- # pi-thread-engine
2
-
3
- ## Current State (v0.4.4)
4
-
5
- ### Working
6
- - All 7 thread types: Base (via /pthread), P (parallel), C (chained), B (branch/meta), F (fusion), Z (zero-touch), L (long)
7
- - Stories: multi-phase orchestration with planned phase execution
8
- - Dashboard v3: 3-column grouping (Needs Input | Working | Done), progress bars, output snippets, search, inline reply, `/agents` alias
9
- - `/threads export [id|--all]` — export to Markdown
10
- - `E` key in dashboard — quick export
11
- - Keyboard shortcut: `ctrl+shift+t` opens dashboard
12
- - Session persistence: survives compaction and `/fork`
13
- - 3 LLM tools: `thread_spawn`, `thread_status`, `thread_kill`
14
- - IndyDevDan framework branding: README + THREADS.md
15
-
16
- ## Next Steps (ranked by impact/effort)
17
-
18
- ### P0Required for completeness
19
- 1. **Live progress with % + ETA** Wire event emitter from executor to dashboard for real-time progress
20
- 2. **Token/cost counters** Hook into pi's usage tracking per thread (use `pi.exec` output parsing)
21
-
22
- ### P1 — High value, low risk
23
- 3. **pi.dev gallery listing** — Already has `pi-package` keyword. Add `pi.video` or `pi.image` to package.json for preview. Ensure repository URL is correct.
24
- 4. **@IndyDevDan outreach** Draft DM/message saying pi-thread-engine implements his framework
25
-
26
- ### P2 Nice to have
27
- 5. **Pinning** `P` key to pin threads to top
28
- 6. **Git worktree support** — `--worktree` flag on spawn for isolated branches
29
- 7. **pi-memory integration** — Auto-recall from `~/.pi-memory/` before spawning
30
-
31
- ### P3 Long term
32
- 8. **ACP support** Agent Client Protocol interoperability
33
- 9. **Session sharing** Export/import threads via URL
1
+ # Plan: Git Worktree Threads for pi-thread-engine
2
+
3
+ ## Why
4
+ Orca and pi-builder both have worktree support. pi-thread-engine doesn't. Worktrees give each thread its own isolated branch + working directory, so parallel agents never conflict.
5
+
6
+ ## What We're Building
7
+ A new `/wthread` command that creates a git worktree, runs a thread inside it, and tracks divergence from main.
8
+
9
+ ## Files to Change
10
+
11
+ ### New: `src/core/worktree.ts` (~60 lines)
12
+ - `createWorktree(repoPath, threadId)` creates worktree + branch
13
+ - `removeWorktree(threadId)` removes worktree + deletes branch
14
+ - `listWorktrees()` returns worktree info (branch, path, ahead/behind, dirty)
15
+ - Uses `git worktree` CLI commands (no Rust dependency)
16
+
17
+ ### Modify: `extensions/index.ts` (+80 lines)
18
+ - Add `/wthread "task"` spawn thread in a worktree
19
+ - Add `/wthread list`show worktrees with divergence
20
+ - Register as ThreadType "worktree" in registry
21
+ - Wire cleanup on thread completion/kill
22
+
23
+ ### Modify: `src/core/registry.ts` (+5 lines)
24
+ - Add "worktree" as accepted ThreadType
25
+
26
+ ### Modify: `src/core/types.ts` (+5 lines)
27
+ - Add "worktree" to ExecutionBackend or ThreadType
28
+
29
+ ## API
30
+ ```
31
+ /wthread "refactor auth" → create worktree, run task, report results
32
+ /wthread "fix parser" -b fix/parse use custom branch name
33
+ /wthread list → show all active worktrees
34
+ ```
35
+
36
+ ## Worktree Lifecycle
37
+ 1. `git worktree add -b pi-thread/t-001 <path>` — create
38
+ 2. Thread runs in worktree directory using `pi -p "task"` (native backend)
39
+ 3. On completion: results captured, divergence tracked
40
+ 4. On cleanup: `git worktree remove <path>`, `git branch -D <branch>`
41
+ 5. Worktrees stored under `<repo>/.git/worktrees-pi/`
42
+
43
+ ## Test Strategy
44
+ 1. Run `/wthread "echo hello"` in a git repo
45
+ 2. Verify worktree appears (`git worktree list`)
46
+ 3. Run thread, verify results captured
47
+ 4. Remove worktree, verify cleanup
48
+ 5. Test with non-git repo (graceful error)
49
+
50
+ ## Risks
51
+ - Windows path handling in git worktree (tested: works)
52
+ - Cleanup on pi crash (worktrees persist, need manual cleanup)
53
+ - Branch name collisions (use unique names with thread ID)
@@ -18,8 +18,9 @@ import { ThreadRegistry, formatElapsed } from "../src/core/registry.js";
18
18
  import { ThreadExecutor } from "../src/core/executor.js";
19
19
  import { createDashboard } from "../src/dashboard.js";
20
20
  import type { Thread, ThreadType, Story, StoryPhase } from "../src/core/types.js";
21
- import { writeFileSync } from "fs";
22
- import { join } from "path";
21
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
22
+ import { join, resolve } from "path";
23
+ import { createWorktree, removeWorktree, listWorktrees, pushWorktreeChanges, cleanupAll, isGitRepo, branchName, findRepoRoot } from "../src/core/worktree.js";
23
24
 
24
25
  // ── Export helper ───────────────────────────────────────────
25
26
  function exportThread(id: string, r: ThreadRegistry, cwd: string): string | null {
@@ -468,6 +469,227 @@ export default function (pi: ExtensionAPI) {
468
469
  },
469
470
  });
470
471
 
472
+ // ── /wthread — worktree isolation (port from Grok CLI) ────
473
+
474
+ pi.registerCommand("wthread", {
475
+ description: 'W-Thread: run task in isolated git worktree. Usage: /wthread "task" or /wthread list or /wthread cleanup',
476
+ handler: async (args, ctx) => {
477
+ if (!args?.trim()) {
478
+ ctx.ui.notify('Usage: /wthread "task" | /wthread list | /wthread cleanup', "error");
479
+ return;
480
+ }
481
+
482
+ // Subcommands
483
+ const parts = args.trim().split(/\s+/);
484
+ const sub = parts[0].toLowerCase();
485
+
486
+ if (sub === "list") {
487
+ if (!isGitRepo(ctx.cwd)) { ctx.ui.notify("Not in a git repo", "error"); return; }
488
+ const wts = listWorktrees(ctx.cwd);
489
+ if (wts.length === 0) { ctx.ui.notify("No active worktrees", "info"); return; }
490
+ const lines = wts.map((w) =>
491
+ ` ${w.threadId} → ${w.branch} @ ${w.path} ${w.dirty ? "⚠ dirty" : ""} (↑${w.ahead} ↓${w.behind})`
492
+ );
493
+ ctx.ui.notify(`Worktrees (${wts.length}):\n${lines.join("\n")}`, "info");
494
+ return;
495
+ }
496
+
497
+ if (sub === "cleanup") {
498
+ if (!isGitRepo(ctx.cwd)) { ctx.ui.notify("Not in a git repo", "error"); return; }
499
+ const result = cleanupAll(ctx.cwd);
500
+ ctx.ui.notify(`Cleaned: ${result.removed} worktrees (${result.failed} failed)`, result.failed > 0 ? "warning" : "info");
501
+ return;
502
+ }
503
+
504
+ if (sub === "push" && parts.length >= 2) {
505
+ const threadId = parts[1];
506
+ const msg = parts.slice(2).join(" ") || undefined;
507
+ const ok = pushWorktreeChanges(ctx.cwd, threadId, msg);
508
+ ctx.ui.notify(ok ? `Pushed ${threadId} changes` : "Push failed", ok ? "info" : "error");
509
+ return;
510
+ }
511
+
512
+ // Default: create worktree and run task
513
+ if (!isGitRepo(ctx.cwd)) { ctx.ui.notify("Not in a git repo — can't create worktree", "error"); return; }
514
+
515
+ const task = args.trim();
516
+ const thread = registry.create("worktree", `W: ${task.slice(0, 40)}`, [task], {
517
+ cwd: ctx.cwd,
518
+ backend: "native",
519
+ });
520
+
521
+ ctx.ui.notify(`🌳 W-Thread ${thread.id}: Creating worktree for "${task.slice(0, 50)}"...`, "info");
522
+
523
+ executor.dispatch(thread).then(() => {
524
+ if (thread.state === "completed") {
525
+ ctx.ui.notify(`✅ W-Thread ${thread.id} done in worktree! Use /wthread push ${thread.id} to merge.`, "info");
526
+ } else {
527
+ ctx.ui.notify(`❌ W-Thread ${thread.id} ${thread.state}`, "error");
528
+ }
529
+ });
530
+ },
531
+ });
532
+
533
+ // ── /plan — Plan mode (port from Grok CLI) ────────────────
534
+
535
+ let planFile: string | null = null;
536
+ let planApproved = false;
537
+
538
+ pi.registerCommand("plan", {
539
+ description: 'Plan mode: write, review, and approve a plan before execution. Usage: /plan "goal" | /plan approve | /plan reject | /plan status',
540
+ handler: async (args, ctx) => {
541
+ if (!args?.trim()) {
542
+ ctx.ui.notify("Usage: /plan <goal> | /plan approve | /plan reject | /plan status", "error");
543
+ return;
544
+ }
545
+
546
+ const sub = args.trim().toLowerCase();
547
+
548
+ if (sub === "approve") {
549
+ if (!planFile) { ctx.ui.notify("No active plan. Use /plan \"goal\" first.", "error"); return; }
550
+ planApproved = true;
551
+ ctx.ui.notify("✅ Plan approved! You can now execute the planned work.", "info");
552
+ return;
553
+ }
554
+
555
+ if (sub === "reject") {
556
+ planFile = null;
557
+ planApproved = false;
558
+ ctx.ui.notify("✗ Plan rejected. Revise and re-submit with /plan \"goal\".", "warning");
559
+ return;
560
+ }
561
+
562
+ if (sub === "status") {
563
+ if (planFile && existsSync(planFile)) {
564
+ const content = readFileSync(planFile, "utf8");
565
+ const status = planApproved ? "✅ Approved" : "⏳ Awaiting approval";
566
+ ctx.ui.notify(`Plan status: ${status}\n\n${content.slice(0, 1000)}`, "info");
567
+ } else {
568
+ ctx.ui.notify("No active plan.", "info");
569
+ }
570
+ return;
571
+ }
572
+
573
+ // Create a new plan
574
+ const goal = args.trim();
575
+ planApproved = false;
576
+
577
+ // Create plan directory
578
+ const planDir = join(ctx.cwd, ".pi", "plans");
579
+ if (!existsSync(planDir)) mkdirSync(planDir, { recursive: true });
580
+ const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
581
+ planFile = join(planDir, `plan-${ts}.md`);
582
+
583
+ const planMd = [
584
+ `# Plan: ${goal}`,
585
+ "",
586
+ "## Goal",
587
+ goal,
588
+ "",
589
+ "## Approach",
590
+ "(Generated by agent — review and edit before approving)",
591
+ "",
592
+ "## Steps",
593
+ "1. ",
594
+ "2. ",
595
+ "3. ",
596
+ "",
597
+ "## Verification",
598
+ "- ",
599
+ "",
600
+ "---",
601
+ `Created: ${new Date().toISOString()}`,
602
+ "Status: draft (run `/plan approve` to approve, `/plan reject` to reject)",
603
+ ].join("\n");
604
+
605
+ writeFileSync(planFile, planMd, "utf8");
606
+ ctx.ui.notify(
607
+ `📋 Plan created at ${planFile}\nReview it, then run /plan approve or /plan reject.`,
608
+ "info"
609
+ );
610
+ },
611
+ });
612
+
613
+ // ── /loop — Scheduled recurring tasks (port from Grok CLI) ─
614
+
615
+ const scheduler = new Map<string, { interval: number; prompt: string; timer: ReturnType<typeof setInterval> | null }>();
616
+
617
+ function parseInterval(input: string): number | null {
618
+ const m = input.match(/^(\d+)(s|m|h|d)$/);
619
+ if (!m) return null;
620
+ const n = parseInt(m[1]);
621
+ switch (m[2]) {
622
+ case "s": return n * 1000;
623
+ case "m": return n * 60 * 1000;
624
+ case "h": return n * 3600 * 1000;
625
+ case "d": return n * 86400 * 1000;
626
+ default: return null;
627
+ }
628
+ }
629
+
630
+ pi.registerCommand("loop", {
631
+ description: 'Schedule a recurring task. Usage: /loop 5m "check test status" | /loop list | /loop stop <id>',
632
+ handler: async (args, ctx) => {
633
+ if (!args?.trim()) {
634
+ ctx.ui.notify("Usage: /loop <interval> <prompt> | /loop list | /loop stop <id>", "error");
635
+ return;
636
+ }
637
+
638
+ const parts = args.trim().split(/\s+/);
639
+ const sub = parts[0].toLowerCase();
640
+
641
+ if (sub === "list") {
642
+ if (scheduler.size === 0) { ctx.ui.notify("No scheduled tasks.", "info"); return; }
643
+ const lines: string[] = [];
644
+ for (const [id, task] of scheduler) {
645
+ const intervalStr = `${task.interval / 1000}s`;
646
+ lines.push(` ${id}: every ${intervalStr} — "${task.prompt.slice(0, 50)}"`);
647
+ }
648
+ ctx.ui.notify(`Scheduled tasks (${scheduler.size}):\n${lines.join("\n")}`, "info");
649
+ return;
650
+ }
651
+
652
+ if (sub === "stop" && parts.length >= 2) {
653
+ const id = parts[1];
654
+ const task = scheduler.get(id);
655
+ if (!task) { ctx.ui.notify(`No scheduled task with id ${id}`, "error"); return; }
656
+ if (task.timer) clearInterval(task.timer);
657
+ scheduler.delete(id);
658
+ ctx.ui.notify(`Stopped ${id}`, "info");
659
+ return;
660
+ }
661
+
662
+ // Parse: /loop 5m "prompt"
663
+ const intervalStr = parts[0];
664
+ const interval = parseInterval(intervalStr);
665
+ if (!interval) { ctx.ui.notify(`Invalid interval: ${intervalStr}. Use format: 30s, 5m, 2h, 1d`, "error"); return; }
666
+
667
+ const prompt = parts.slice(1).join(" ");
668
+ if (!prompt) { ctx.ui.notify("No prompt specified", "error"); return; }
669
+
670
+ const id = `loop-${Date.now()}`;
671
+ ctx.ui.notify(`⏰ /loop ${id}: every ${intervalStr} — "${prompt.slice(0, 50)}"`, "info");
672
+
673
+ // Fire immediately
674
+ const thread = registry.create("scheduled", `Loop: ${prompt.slice(0, 40)}`, [prompt], {
675
+ cwd: ctx.cwd,
676
+ backend: "native",
677
+ });
678
+ executor.dispatch(thread);
679
+
680
+ // Schedule recurring
681
+ const timer = setInterval(() => {
682
+ const t = registry.create("scheduled", `Loop: ${prompt.slice(0, 40)}`, [prompt], {
683
+ cwd: ctx.cwd,
684
+ backend: "native",
685
+ });
686
+ executor.dispatch(t);
687
+ }, interval);
688
+
689
+ scheduler.set(id, { interval, prompt, timer });
690
+ },
691
+ });
692
+
471
693
  // ── /story — STORIES (the unique layer) ──────────────────────
472
694
 
473
695
  pi.registerCommand("story", {
@@ -569,9 +791,12 @@ export default function (pi: ExtensionAPI) {
569
791
  "- fusion: same prompt to N agents/models, compare results (native, UNIQUE)",
570
792
  "- zero: autonomous + verification command gate (native, UNIQUE)",
571
793
  "- long: extended autonomous run (native)",
794
+ "- worktree: run in isolated git worktree (native, port from Grok CLI)",
795
+ "- plan: structured plan→approve→execute gate (native, port from Grok CLI)",
796
+ "- scheduled: recurring scheduled task (native, port from Grok CLI)",
572
797
  ].join("\n"),
573
798
  parameters: Type.Object({
574
- type: StringEnum(["parallel", "fusion", "chained", "meta", "long", "zero"] as const),
799
+ type: StringEnum(["parallel", "fusion", "chained", "meta", "long", "zero", "worktree", "plan", "scheduled"] as const),
575
800
  prompts: Type.Array(Type.String(), { description: "Task prompts" }),
576
801
  models: Type.Optional(Type.Array(Type.String(), { description: "Models for fusion (e.g. ['anthropic/claude-sonnet-4', 'google/gemini-2.5-pro'])" })),
577
802
  count: Type.Optional(Type.Number({ description: "Agent count for fusion (default 3)" })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-thread-engine",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Thread-Based Engineering for pi — all 7 thread types + stories + fusion + zero-touch + TUI dashboard. Based on @IndyDevDan framework from agenticengineer.com.",
5
5
  "type": "module",
6
6
  "license": "MIT",