pi-thread-engine 0.4.6 → 0.4.9
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 +25 -28
- package/README.md +221 -214
- package/_lib/contract.ts +116 -0
- package/docs/HELLO_PYTHON.md +68 -0
- package/extensions/index.ts +332 -74
- package/package.json +15 -6
- package/src/core/executor.ts +284 -220
- package/src/core/registry.test.ts +32 -0
- package/src/core/registry.ts +290 -290
- package/src/core/types.ts +107 -107
- package/src/core/worktree.ts +309 -0
- package/src/dashboard.ts +426 -421
- package/src/hello-world.test.ts +30 -0
- package/src/hello-world.ts +25 -0
- package/src/worktree-lifecycle.test.ts +124 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Python `hello_world()` Utility
|
|
2
|
+
|
|
3
|
+
> A lightweight Python entry point for pi-thread-engine — useful for
|
|
4
|
+
> Pi extension scripts that prefer Python over TypeScript.
|
|
5
|
+
|
|
6
|
+
## Purpose
|
|
7
|
+
|
|
8
|
+
Mirrors the TypeScript `helloWorld()` function (`src/hello.ts`) as a
|
|
9
|
+
simple verification utility. Calling `hello_world()` returns the
|
|
10
|
+
canonical greeting string `"Hello, World!"` with zero side effects.
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
### Import
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from scripts.hello import hello_world
|
|
18
|
+
|
|
19
|
+
greeting = hello_world()
|
|
20
|
+
print(greeting) # Hello, World!
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### CLI invocation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
python scripts/hello.py
|
|
27
|
+
# Hello, World!
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Tests
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
python -m unittest scripts/test_hello.py -v
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Expected output:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
test_returns_hello_world_string (scripts.test_hello.TestHelloWorld.test_returns_hello_world_string)
|
|
40
|
+
hello_world() should return exactly 'Hello, World!'. ... ok
|
|
41
|
+
test_returns_string_type (scripts.test_hello.TestHelloWorld.test_returns_string_type)
|
|
42
|
+
hello_world() should return a str instance. ... ok
|
|
43
|
+
|
|
44
|
+
----------------------------------------------------------------------
|
|
45
|
+
Ran 2 tests in 0.000s
|
|
46
|
+
|
|
47
|
+
OK
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Implementation
|
|
51
|
+
|
|
52
|
+
| Aspect | Detail |
|
|
53
|
+
|--------|--------|
|
|
54
|
+
| **File** | `scripts/hello.py` |
|
|
55
|
+
| **Function** | `hello_world() -> str` |
|
|
56
|
+
| **Return value** | `"Hello, World!"` |
|
|
57
|
+
| **Framework** | Python 3 (stdlib only) |
|
|
58
|
+
| **Test framework** | `unittest` (stdlib) |
|
|
59
|
+
| **Test file** | `scripts/test_hello.py` |
|
|
60
|
+
|
|
61
|
+
## Integration
|
|
62
|
+
|
|
63
|
+
The Python test is integrated into the project's test suite via
|
|
64
|
+
`scripts/test.sh` (step 4). Run the full suite with:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
bash scripts/test.sh
|
|
68
|
+
```
|
package/extensions/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI,
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import { Type, type TUnsafe } from "@sinclair/typebox";
|
|
3
3
|
import { Text } from "@mariozechner/pi-tui";
|
|
4
4
|
|
|
@@ -18,8 +18,10 @@ 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";
|
|
24
|
+
import { helloWorld } from "../src/hello-world.js";
|
|
23
25
|
|
|
24
26
|
// ── Export helper ───────────────────────────────────────────
|
|
25
27
|
function exportThread(id: string, r: ThreadRegistry, cwd: string): string | null {
|
|
@@ -54,7 +56,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
54
56
|
const threads = registry.all().filter((t) => t.state !== "killed");
|
|
55
57
|
const stories = registry.allStories();
|
|
56
58
|
if (threads.length > 0 || stories.length > 0) {
|
|
57
|
-
|
|
59
|
+
try {
|
|
60
|
+
pi.appendEntry("pi-threads-state", { threads, stories, timestamp: Date.now() });
|
|
61
|
+
} catch {
|
|
62
|
+
// Extension contexts can go stale after session replacement; persistence is best-effort.
|
|
63
|
+
}
|
|
58
64
|
}
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -80,8 +86,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
80
86
|
|
|
81
87
|
// ── Status bar ───────────────────────────────────────────────
|
|
82
88
|
|
|
89
|
+
let clearStatusHandler: (() => void) | undefined;
|
|
83
90
|
pi.on("session_start", async (_event, ctx) => {
|
|
84
|
-
|
|
91
|
+
clearStatusHandler?.();
|
|
92
|
+
clearStatusHandler = registry.on(() => {
|
|
85
93
|
const running = registry.byState("running");
|
|
86
94
|
const stories = registry.allStories().filter((s) => s.state === "executing" || s.state === "planning");
|
|
87
95
|
const parts: string[] = [];
|
|
@@ -98,7 +106,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
98
106
|
);
|
|
99
107
|
}
|
|
100
108
|
|
|
101
|
-
|
|
109
|
+
try {
|
|
110
|
+
ctx.ui.setStatus("pi-threads", parts.length > 0 ? parts.join(" ") : undefined);
|
|
111
|
+
} catch {
|
|
112
|
+
// Status updates are best-effort; command/session contexts may go stale.
|
|
113
|
+
}
|
|
102
114
|
});
|
|
103
115
|
});
|
|
104
116
|
|
|
@@ -124,6 +136,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
124
136
|
return tasks;
|
|
125
137
|
}
|
|
126
138
|
|
|
139
|
+
|
|
127
140
|
function stateIcon(state: string): string {
|
|
128
141
|
switch (state) {
|
|
129
142
|
case "running": return "⟳";
|
|
@@ -137,16 +150,54 @@ export default function (pi: ExtensionAPI) {
|
|
|
137
150
|
|
|
138
151
|
function typeIcon(type: string): string {
|
|
139
152
|
switch (type) {
|
|
153
|
+
case "base": return "•";
|
|
140
154
|
case "parallel": return "⫘";
|
|
141
155
|
case "chained": return "⟶";
|
|
142
156
|
case "fusion": return "⊕";
|
|
143
157
|
case "meta": return "◎";
|
|
144
158
|
case "long": return "∞";
|
|
145
159
|
case "zero": return "⊘";
|
|
160
|
+
case "worktree": return "🌳";
|
|
161
|
+
case "plan": return "⌁";
|
|
162
|
+
case "scheduled": return "⏰";
|
|
146
163
|
default: return "·";
|
|
147
164
|
}
|
|
148
165
|
}
|
|
149
166
|
|
|
167
|
+
async function openDashboard(ctx: ExtensionCommandContext) {
|
|
168
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
169
|
+
const dashboard = createDashboard(
|
|
170
|
+
registry,
|
|
171
|
+
theme,
|
|
172
|
+
() => done(),
|
|
173
|
+
(id) => {
|
|
174
|
+
registry.kill(id);
|
|
175
|
+
ctx.ui.notify(`Killed ${id}`, "warning");
|
|
176
|
+
tui.requestRender();
|
|
177
|
+
},
|
|
178
|
+
(id) => {
|
|
179
|
+
const t = registry.get(id);
|
|
180
|
+
if (t) {
|
|
181
|
+
const preview = t.tasks.map((tk) => `${tk.id}: ${tk.result?.slice(0, 100) ?? tk.error ?? "(pending)"}`).join("\n");
|
|
182
|
+
ctx.ui.notify(`Thread ${id} results:\n${preview}`, "info");
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
(id, message) => {
|
|
186
|
+
executor.injectReply(id, message);
|
|
187
|
+
ctx.ui.notify(`Replied to ${id}: ${message.slice(0, 50)}...`, "info");
|
|
188
|
+
tui.requestRender();
|
|
189
|
+
},
|
|
190
|
+
(id) => { const p = exportThread(id, registry, ctx.cwd); if (p) ctx.ui.notify(`Exported to ${p}`, "info"); }
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
render: (w: number) => dashboard.render(w),
|
|
195
|
+
invalidate: () => dashboard.invalidate(),
|
|
196
|
+
handleInput: (data: string) => { dashboard.handleInput(data); tui.requestRender(); },
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
150
201
|
// ── /threads — unified TUI dashboard ─────────────────────────
|
|
151
202
|
|
|
152
203
|
pi.registerCommand("threads", {
|
|
@@ -240,69 +291,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
240
291
|
}
|
|
241
292
|
const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
|
|
242
293
|
const fn = "threads-" + ts + ".md";
|
|
243
|
-
const outPath =
|
|
244
|
-
|
|
294
|
+
const outPath = join(ctx.cwd, fn);
|
|
295
|
+
writeFileSync(outPath, md.join("\n"), "utf8");
|
|
245
296
|
ctx.ui.notify("Exported to " + outPath, "info");
|
|
246
297
|
return;
|
|
247
298
|
}
|
|
248
299
|
|
|
249
|
-
|
|
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
|
-
});
|
|
300
|
+
await openDashboard(ctx);
|
|
303
301
|
},
|
|
304
302
|
});
|
|
305
303
|
|
|
304
|
+
pi.registerCommand("agents", {
|
|
305
|
+
description: "Alias for /threads — Claude-style Agent View dashboard",
|
|
306
|
+
handler: async (_args, ctx) => openDashboard(ctx),
|
|
307
|
+
});
|
|
308
|
+
|
|
306
309
|
// ── /pthread — parallel via subagent ─────────────────────────
|
|
307
310
|
|
|
308
311
|
pi.registerCommand("pthread", {
|
|
@@ -320,19 +323,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
320
323
|
},
|
|
321
324
|
});
|
|
322
325
|
|
|
323
|
-
// ── /cthread — chained
|
|
326
|
+
// ── /cthread — chained with human checkpoints ─────────────────
|
|
324
327
|
|
|
325
328
|
pi.registerCommand("cthread", {
|
|
326
|
-
description: 'C-Thread: sequential phases
|
|
329
|
+
description: 'C-Thread: sequential phases with human checkpoints. Usage: /cthread "phase 1" "phase 2"',
|
|
327
330
|
handler: async (args, ctx) => {
|
|
328
331
|
if (!args?.trim()) { ctx.ui.notify('Usage: /cthread "phase 1" "phase 2"', "error"); return; }
|
|
329
332
|
const phases = parseTaskArgs(args);
|
|
330
333
|
if (phases.length < 2) { ctx.ui.notify("Need at least 2 phases", "error"); return; }
|
|
331
334
|
|
|
332
|
-
const thread = registry.create("chained", `Chain: ${phases.length} phases`, phases, { cwd: ctx.cwd, backend: "
|
|
335
|
+
const thread = registry.create("chained", `Chain: ${phases.length} phases`, phases, { cwd: ctx.cwd, backend: "native" });
|
|
333
336
|
|
|
334
|
-
ctx.ui.notify(`🧵 C-Thread ${thread.id}: ${phases.length} phases
|
|
335
|
-
executor.dispatch(thread
|
|
337
|
+
ctx.ui.notify(`🧵 C-Thread ${thread.id}: ${phases.length} phases with checkpoints...`, "info");
|
|
338
|
+
executor.dispatch(thread, {
|
|
339
|
+
onCheckpoint: async (phase, task) => ctx.ui.confirm(
|
|
340
|
+
"C-Thread checkpoint",
|
|
341
|
+
`Phase ${phase} complete. Continue to phase ${phase + 1}: ${task.label}?`
|
|
342
|
+
),
|
|
343
|
+
});
|
|
336
344
|
},
|
|
337
345
|
});
|
|
338
346
|
|
|
@@ -468,6 +476,236 @@ export default function (pi: ExtensionAPI) {
|
|
|
468
476
|
},
|
|
469
477
|
});
|
|
470
478
|
|
|
479
|
+
// ── /wthread — worktree isolation (port from Grok CLI) ────
|
|
480
|
+
|
|
481
|
+
pi.registerCommand("wthread", {
|
|
482
|
+
description: 'W-Thread: run task in isolated git worktree. Usage: /wthread "task" or /wthread list, push, discard, cleanup',
|
|
483
|
+
handler: async (args, ctx) => {
|
|
484
|
+
if (!args?.trim()) {
|
|
485
|
+
ctx.ui.notify('Usage: /wthread "task" | /wthread list | /wthread push <id> [message] | /wthread discard <id> | /wthread cleanup', "error");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Subcommands
|
|
490
|
+
const parts = args.trim().split(/\s+/);
|
|
491
|
+
const sub = parts[0].toLowerCase();
|
|
492
|
+
|
|
493
|
+
if (sub === "list") {
|
|
494
|
+
if (!isGitRepo(ctx.cwd)) { ctx.ui.notify("Not in a git repo", "error"); return; }
|
|
495
|
+
const wts = listWorktrees(ctx.cwd);
|
|
496
|
+
if (wts.length === 0) { ctx.ui.notify("No active worktrees", "info"); return; }
|
|
497
|
+
const lines = wts.map((w) =>
|
|
498
|
+
` ${w.threadId} → ${w.branch} @ ${w.path} ${w.dirty ? "⚠ dirty" : ""} (↑${w.ahead} ↓${w.behind})`
|
|
499
|
+
);
|
|
500
|
+
ctx.ui.notify(`Worktrees (${wts.length}):\n${lines.join("\n")}`, "info");
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (sub === "cleanup") {
|
|
505
|
+
if (!isGitRepo(ctx.cwd)) { ctx.ui.notify("Not in a git repo", "error"); return; }
|
|
506
|
+
const result = cleanupAll(ctx.cwd);
|
|
507
|
+
ctx.ui.notify(`Cleaned: ${result.removed} worktrees (${result.failed} failed)`, result.failed > 0 ? "warning" : "info");
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (sub === "push") {
|
|
512
|
+
if (parts.length < 2) { ctx.ui.notify('Usage: /wthread push <id> [message]', "error"); return; }
|
|
513
|
+
const threadId = parts[1];
|
|
514
|
+
const msg = parts.slice(2).join(" ") || undefined;
|
|
515
|
+
const ok = pushWorktreeChanges(ctx.cwd, threadId, msg);
|
|
516
|
+
ctx.ui.notify(ok ? `Pushed ${threadId} changes` : "Push failed", ok ? "info" : "error");
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (sub === "discard" || sub === "remove") {
|
|
521
|
+
if (parts.length < 2) { ctx.ui.notify('Usage: /wthread discard <id>', "error"); return; }
|
|
522
|
+
const threadId = parts[1];
|
|
523
|
+
const ok = removeWorktree(ctx.cwd, threadId);
|
|
524
|
+
ctx.ui.notify(ok ? `Discarded ${threadId} worktree` : "Discard failed", ok ? "info" : "error");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Default: create worktree and run task
|
|
529
|
+
if (!isGitRepo(ctx.cwd)) { ctx.ui.notify("Not in a git repo — can't create worktree", "error"); return; }
|
|
530
|
+
|
|
531
|
+
const task = args.trim();
|
|
532
|
+
const thread = registry.create("worktree", `W: ${task.slice(0, 40)}`, [task], {
|
|
533
|
+
cwd: ctx.cwd,
|
|
534
|
+
backend: "native",
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
ctx.ui.notify(`🌳 W-Thread ${thread.id}: Creating worktree for "${task.slice(0, 50)}"...`, "info");
|
|
538
|
+
|
|
539
|
+
executor.dispatch(thread).then(() => {
|
|
540
|
+
if (thread.state === "completed") {
|
|
541
|
+
ctx.ui.notify(`✅ W-Thread ${thread.id} done in worktree! Use /wthread push ${thread.id} to merge.`, "info");
|
|
542
|
+
} else {
|
|
543
|
+
ctx.ui.notify(`❌ W-Thread ${thread.id} ${thread.state}`, "error");
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// ── /plan — Plan mode (port from Grok CLI) ────────────────
|
|
550
|
+
|
|
551
|
+
let planFile: string | null = null;
|
|
552
|
+
let planApproved = false;
|
|
553
|
+
|
|
554
|
+
pi.registerCommand("plan", {
|
|
555
|
+
description: 'Plan mode: write, review, and approve a plan before execution. Usage: /plan "goal" | /plan approve | /plan reject | /plan status',
|
|
556
|
+
handler: async (args, ctx) => {
|
|
557
|
+
if (!args?.trim()) {
|
|
558
|
+
ctx.ui.notify("Usage: /plan <goal> | /plan approve | /plan reject | /plan status", "error");
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const sub = args.trim().toLowerCase();
|
|
563
|
+
|
|
564
|
+
if (sub === "approve") {
|
|
565
|
+
if (!planFile) { ctx.ui.notify("No active plan. Use /plan \"goal\" first.", "error"); return; }
|
|
566
|
+
planApproved = true;
|
|
567
|
+
ctx.ui.notify("✅ Plan approved! You can now execute the planned work.", "info");
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (sub === "reject") {
|
|
572
|
+
planFile = null;
|
|
573
|
+
planApproved = false;
|
|
574
|
+
ctx.ui.notify("✗ Plan rejected. Revise and re-submit with /plan \"goal\".", "warning");
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (sub === "status") {
|
|
579
|
+
if (planFile && existsSync(planFile)) {
|
|
580
|
+
const content = readFileSync(planFile, "utf8");
|
|
581
|
+
const status = planApproved ? "✅ Approved" : "⏳ Awaiting approval";
|
|
582
|
+
ctx.ui.notify(`Plan status: ${status}\n\n${content.slice(0, 1000)}`, "info");
|
|
583
|
+
} else {
|
|
584
|
+
ctx.ui.notify("No active plan.", "info");
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Create a new plan
|
|
590
|
+
const goal = args.trim();
|
|
591
|
+
planApproved = false;
|
|
592
|
+
|
|
593
|
+
// Create plan directory
|
|
594
|
+
const planDir = join(ctx.cwd, ".pi", "plans");
|
|
595
|
+
if (!existsSync(planDir)) mkdirSync(planDir, { recursive: true });
|
|
596
|
+
const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
|
|
597
|
+
planFile = join(planDir, `plan-${ts}.md`);
|
|
598
|
+
|
|
599
|
+
const planMd = [
|
|
600
|
+
`# Plan: ${goal}`,
|
|
601
|
+
"",
|
|
602
|
+
"## Goal",
|
|
603
|
+
goal,
|
|
604
|
+
"",
|
|
605
|
+
"## Approach",
|
|
606
|
+
"(Generated by agent — review and edit before approving)",
|
|
607
|
+
"",
|
|
608
|
+
"## Steps",
|
|
609
|
+
"1. ",
|
|
610
|
+
"2. ",
|
|
611
|
+
"3. ",
|
|
612
|
+
"",
|
|
613
|
+
"## Verification",
|
|
614
|
+
"- ",
|
|
615
|
+
"",
|
|
616
|
+
"---",
|
|
617
|
+
`Created: ${new Date().toISOString()}`,
|
|
618
|
+
"Status: draft (run `/plan approve` to approve, `/plan reject` to reject)",
|
|
619
|
+
].join("\n");
|
|
620
|
+
|
|
621
|
+
writeFileSync(planFile, planMd, "utf8");
|
|
622
|
+
ctx.ui.notify(
|
|
623
|
+
`📋 Plan created at ${planFile}\nReview it, then run /plan approve or /plan reject.`,
|
|
624
|
+
"info"
|
|
625
|
+
);
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// ── /loop — Scheduled recurring tasks (port from Grok CLI) ─
|
|
630
|
+
|
|
631
|
+
const scheduler = new Map<string, { interval: number; prompt: string; timer: ReturnType<typeof setInterval> | null }>();
|
|
632
|
+
|
|
633
|
+
function parseInterval(input: string): number | null {
|
|
634
|
+
const m = input.match(/^(\d+)(s|m|h|d)$/);
|
|
635
|
+
if (!m) return null;
|
|
636
|
+
const n = parseInt(m[1]);
|
|
637
|
+
switch (m[2]) {
|
|
638
|
+
case "s": return n * 1000;
|
|
639
|
+
case "m": return n * 60 * 1000;
|
|
640
|
+
case "h": return n * 3600 * 1000;
|
|
641
|
+
case "d": return n * 86400 * 1000;
|
|
642
|
+
default: return null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
pi.registerCommand("loop", {
|
|
647
|
+
description: 'Schedule a recurring task. Usage: /loop 5m "check test status" | /loop list | /loop stop <id>',
|
|
648
|
+
handler: async (args, ctx) => {
|
|
649
|
+
if (!args?.trim()) {
|
|
650
|
+
ctx.ui.notify("Usage: /loop <interval> <prompt> | /loop list | /loop stop <id>", "error");
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const parts = args.trim().split(/\s+/);
|
|
655
|
+
const sub = parts[0].toLowerCase();
|
|
656
|
+
|
|
657
|
+
if (sub === "list") {
|
|
658
|
+
if (scheduler.size === 0) { ctx.ui.notify("No scheduled tasks.", "info"); return; }
|
|
659
|
+
const lines: string[] = [];
|
|
660
|
+
for (const [id, task] of scheduler) {
|
|
661
|
+
const intervalStr = `${task.interval / 1000}s`;
|
|
662
|
+
lines.push(` ${id}: every ${intervalStr} — "${task.prompt.slice(0, 50)}"`);
|
|
663
|
+
}
|
|
664
|
+
ctx.ui.notify(`Scheduled tasks (${scheduler.size}):\n${lines.join("\n")}`, "info");
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (sub === "stop" && parts.length >= 2) {
|
|
669
|
+
const id = parts[1];
|
|
670
|
+
const task = scheduler.get(id);
|
|
671
|
+
if (!task) { ctx.ui.notify(`No scheduled task with id ${id}`, "error"); return; }
|
|
672
|
+
if (task.timer) clearInterval(task.timer);
|
|
673
|
+
scheduler.delete(id);
|
|
674
|
+
ctx.ui.notify(`Stopped ${id}`, "info");
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Parse: /loop 5m "prompt"
|
|
679
|
+
const intervalStr = parts[0];
|
|
680
|
+
const interval = parseInterval(intervalStr);
|
|
681
|
+
if (!interval) { ctx.ui.notify(`Invalid interval: ${intervalStr}. Use format: 30s, 5m, 2h, 1d`, "error"); return; }
|
|
682
|
+
|
|
683
|
+
const prompt = parts.slice(1).join(" ");
|
|
684
|
+
if (!prompt) { ctx.ui.notify("No prompt specified", "error"); return; }
|
|
685
|
+
|
|
686
|
+
const id = `loop-${Date.now()}`;
|
|
687
|
+
ctx.ui.notify(`⏰ /loop ${id}: every ${intervalStr} — "${prompt.slice(0, 50)}"`, "info");
|
|
688
|
+
|
|
689
|
+
// Fire immediately
|
|
690
|
+
const thread = registry.create("scheduled", `Loop: ${prompt.slice(0, 40)}`, [prompt], {
|
|
691
|
+
cwd: ctx.cwd,
|
|
692
|
+
backend: "native",
|
|
693
|
+
});
|
|
694
|
+
executor.dispatch(thread);
|
|
695
|
+
|
|
696
|
+
// Schedule recurring
|
|
697
|
+
const timer = setInterval(() => {
|
|
698
|
+
const t = registry.create("scheduled", `Loop: ${prompt.slice(0, 40)}`, [prompt], {
|
|
699
|
+
cwd: ctx.cwd,
|
|
700
|
+
backend: "native",
|
|
701
|
+
});
|
|
702
|
+
executor.dispatch(t);
|
|
703
|
+
}, interval);
|
|
704
|
+
|
|
705
|
+
scheduler.set(id, { interval, prompt, timer });
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
|
|
471
709
|
// ── /story — STORIES (the unique layer) ──────────────────────
|
|
472
710
|
|
|
473
711
|
pi.registerCommand("story", {
|
|
@@ -556,6 +794,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
556
794
|
},
|
|
557
795
|
});
|
|
558
796
|
|
|
797
|
+
// ── /hello — Greeting command ───────────────────────────────
|
|
798
|
+
|
|
799
|
+
pi.registerCommand("hello", {
|
|
800
|
+
description: 'Say hello. Usage: /hello [name]',
|
|
801
|
+
handler: async (args, ctx) => {
|
|
802
|
+
const name = args?.trim() || undefined;
|
|
803
|
+
ctx.ui.notify(helloWorld(name), "info");
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
|
|
559
807
|
// ── LLM-callable tools ───────────────────────────────────────
|
|
560
808
|
|
|
561
809
|
pi.registerTool({
|
|
@@ -563,15 +811,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
563
811
|
label: "Thread Spawn",
|
|
564
812
|
description: [
|
|
565
813
|
"Spawn a thread. Types:",
|
|
814
|
+
"- base: one prompt -> tool calls -> review (native)",
|
|
566
815
|
"- parallel: N independent tasks in parallel (via subagent)",
|
|
567
|
-
"- chained: sequential phases with checkpoints (
|
|
568
|
-
"- meta: scout
|
|
816
|
+
"- chained: sequential phases with human checkpoints (native)",
|
|
817
|
+
"- meta: scout -> plan -> build -> review pipeline (via subagent)",
|
|
569
818
|
"- fusion: same prompt to N agents/models, compare results (native, UNIQUE)",
|
|
570
819
|
"- zero: autonomous + verification command gate (native, UNIQUE)",
|
|
571
820
|
"- long: extended autonomous run (native)",
|
|
821
|
+
"- worktree: run in isolated git worktree (native, extension beyond article)",
|
|
822
|
+
"- plan: structured plan -> approve -> execute gate (native, extension beyond article)",
|
|
823
|
+
"- scheduled: recurring scheduled task (native, extension beyond article)",
|
|
572
824
|
].join("\n"),
|
|
573
825
|
parameters: Type.Object({
|
|
574
|
-
type: StringEnum(["parallel", "fusion", "chained", "meta", "long", "zero"] as const),
|
|
826
|
+
type: StringEnum(["base", "parallel", "fusion", "chained", "meta", "long", "zero", "worktree", "plan", "scheduled"] as const),
|
|
575
827
|
prompts: Type.Array(Type.String(), { description: "Task prompts" }),
|
|
576
828
|
models: Type.Optional(Type.Array(Type.String(), { description: "Models for fusion (e.g. ['anthropic/claude-sonnet-4', 'google/gemini-2.5-pro'])" })),
|
|
577
829
|
count: Type.Optional(Type.Number({ description: "Agent count for fusion (default 3)" })),
|
|
@@ -592,8 +844,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
592
844
|
taskPrompts = [prompts[0]]; // Meta delegates to subagent chain internally
|
|
593
845
|
}
|
|
594
846
|
|
|
595
|
-
// Auto-select backend
|
|
596
|
-
const
|
|
847
|
+
// Auto-select backend: P/B use subagents; C/F/L/Z and extension types run natively.
|
|
848
|
+
const nativeTypes = new Set(["base", "chained", "fusion", "zero", "long", "worktree", "plan", "scheduled"]);
|
|
849
|
+
const backend: import("../src/core/types.js").ExecutionBackend = (backendOverride as import("../src/core/types.js").ExecutionBackend) ?? (nativeTypes.has(String(type)) ? "native" as const : "subagent" as const);
|
|
597
850
|
|
|
598
851
|
const label = type === "fusion"
|
|
599
852
|
? `Fusion: ${prompts[0]?.slice(0, 40)}`
|
|
@@ -608,7 +861,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
608
861
|
});
|
|
609
862
|
|
|
610
863
|
// Dispatch (async — runs in background)
|
|
611
|
-
executor.dispatch(thread
|
|
864
|
+
executor.dispatch(thread, type === "chained" ? {
|
|
865
|
+
onCheckpoint: async (phase, task) => ctx.ui.confirm(
|
|
866
|
+
"C-Thread checkpoint",
|
|
867
|
+
`Phase ${phase} complete. Continue to phase ${phase + 1}: ${task.label}?`
|
|
868
|
+
),
|
|
869
|
+
} : undefined);
|
|
612
870
|
|
|
613
871
|
const modelInfo = models ? ` Models: ${models.join(", ")}` : "";
|
|
614
872
|
const verifyInfo = verify ? ` Verify: ${verify}` : "";
|
|
@@ -622,7 +880,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
622
880
|
details: { threadId: thread.id, type, taskCount: taskPrompts.length, backend },
|
|
623
881
|
};
|
|
624
882
|
},
|
|
625
|
-
renderCall(args, theme) {
|
|
883
|
+
renderCall(args: any, theme: any) {
|
|
626
884
|
return new Text(
|
|
627
885
|
theme.fg("toolTitle", theme.bold("thread_spawn ")) +
|
|
628
886
|
theme.fg("accent", args.type) +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-thread-engine",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
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",
|
|
@@ -21,22 +21,31 @@
|
|
|
21
21
|
"pi": {
|
|
22
22
|
"extensions": [
|
|
23
23
|
"./extensions/index.ts"
|
|
24
|
-
]
|
|
24
|
+
],
|
|
25
|
+
"commands": ["threads","agents","pthread","cthread","bthread","fthread","zthread","lthread","wthread","plan","tloop","story","stories","hello"],
|
|
26
|
+
"tools": ["thread_spawn","thread_status","thread_kill"]
|
|
25
27
|
},
|
|
26
28
|
"files": [
|
|
29
|
+
"_lib/",
|
|
27
30
|
"extensions/",
|
|
28
31
|
"src/",
|
|
29
32
|
"README.md",
|
|
30
33
|
"PLAN.md",
|
|
31
34
|
"docs/"
|
|
32
35
|
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "bash scripts/build.sh",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest"
|
|
40
|
+
},
|
|
33
41
|
"devDependencies": {
|
|
34
|
-
"typescript": "^6.0.3"
|
|
42
|
+
"typescript": "^6.0.3",
|
|
43
|
+
"vitest": "^4.1.6"
|
|
35
44
|
},
|
|
36
45
|
"peerDependencies": {
|
|
37
|
-
"@
|
|
38
|
-
"@
|
|
39
|
-
"@
|
|
46
|
+
"@earendil-works/pi-ai": ">=0.73.0",
|
|
47
|
+
"@earendil-works/pi-coding-agent": ">=0.73.0",
|
|
48
|
+
"@earendil-works/pi-tui": ">=0.73.0",
|
|
40
49
|
"@sinclair/typebox": "^0.34.48"
|
|
41
50
|
}
|
|
42
51
|
}
|