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 +53 -33
- package/extensions/index.ts +228 -3
- package/package.json +1 -1
- package/src/core/executor.ts +296 -220
- package/src/core/registry.ts +2 -0
- package/src/core/types.ts +4 -1
- package/src/core/worktree.ts +263 -0
- package/src/dashboard.ts +430 -421
package/PLAN.md
CHANGED
|
@@ -1,33 +1,53 @@
|
|
|
1
|
-
# pi-thread-engine
|
|
2
|
-
|
|
3
|
-
##
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
###
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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)
|
package/extensions/index.ts
CHANGED
|
@@ -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.
|
|
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",
|