@stacknyu/pi-flow 0.0.0-stage → 1.5.1
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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +173 -2
- package/extension.ts +883 -0
- package/package.json +44 -3
- package/runner.mjs +1597 -0
- package/setup.mjs +310 -0
- package/skills/pi-flow/SKILL.md +20 -0
- package/skills/pi-flow/references/guide.md +80 -0
- package/skills/pi-flow/references/items.md +68 -0
- package/skills/pi-flow/references/manager.md +59 -0
- package/skills/pi-flow/references/troubleshooting.md +39 -0
- package/supervisor.mjs +185 -0
package/extension.ts
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-flow — plan a large task, answer its questions, and let a queue of fresh sessions build it.
|
|
3
|
+
*
|
|
4
|
+
* /task setup once per repo: plugins from your profile, verified checks, AGENTS.md block
|
|
5
|
+
* /task <request> plan a task in this session; it asks its questions, then starts the queue itself
|
|
6
|
+
* /task status | list where things stand
|
|
7
|
+
* /task resume <slug> continue planning a task in this session
|
|
8
|
+
* /task cleanup [--all] delete the task's finished Pi sessions (--all: also worktrees, failed branches)
|
|
9
|
+
* /task save-profile remember this repo's project plugins for /task setup in other repos
|
|
10
|
+
* /queue start|stop|status|watch|retry|problem control the queue directly
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import {
|
|
20
|
+
DEFAULTS,
|
|
21
|
+
DIR,
|
|
22
|
+
LOGIN_SHELL,
|
|
23
|
+
allItems,
|
|
24
|
+
acquireProcessLock,
|
|
25
|
+
enqueueControl,
|
|
26
|
+
pidAlive,
|
|
27
|
+
areasOverlap,
|
|
28
|
+
dependencyGraph,
|
|
29
|
+
checkBroker,
|
|
30
|
+
checkLine,
|
|
31
|
+
cleanEnv,
|
|
32
|
+
deleteSessions,
|
|
33
|
+
sessionFiles,
|
|
34
|
+
ensureDir,
|
|
35
|
+
findPiWeb,
|
|
36
|
+
git,
|
|
37
|
+
loadJson,
|
|
38
|
+
itemConfig,
|
|
39
|
+
parseQueue,
|
|
40
|
+
readConfig,
|
|
41
|
+
run,
|
|
42
|
+
tail,
|
|
43
|
+
webClient,
|
|
44
|
+
} from "./runner.mjs";
|
|
45
|
+
import { FLOW_DIR, TASKS_DIR, exclude, saveProfile, setupRepo } from "./setup.mjs";
|
|
46
|
+
|
|
47
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
const RUNNER = join(HERE, "supervisor.mjs");
|
|
49
|
+
|
|
50
|
+
// ---------- small helpers ----------
|
|
51
|
+
|
|
52
|
+
function readText(path: string): string {
|
|
53
|
+
try {
|
|
54
|
+
return readFileSync(path, "utf8");
|
|
55
|
+
} catch {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function runnerPid(cwd: string, owner = "runner"): number | undefined {
|
|
61
|
+
try {
|
|
62
|
+
const pid = Number(readText(join(cwd, DIR, `${owner}.pid`)).trim());
|
|
63
|
+
if (!pid) return undefined;
|
|
64
|
+
return pidAlive(pid) ? pid : undefined;
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readState(cwd: string): any {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(readText(join(cwd, DIR, "state.json")) || "{}");
|
|
73
|
+
} catch {
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function ago(ms: number): string {
|
|
79
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
80
|
+
return s < 90 ? `${s}s` : `${Math.round(s / 60)}m`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function problemText(cwd: string, lines = 40): string {
|
|
84
|
+
const t = readText(join(cwd, DIR, "problem.txt")).trim();
|
|
85
|
+
if (!t) return "";
|
|
86
|
+
const all = t.split("\n");
|
|
87
|
+
return all.length <= lines ? t : `${all.slice(0, lines - 14).join("\n")}\n…\n${all.slice(-12).join("\n")}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function taskFile(cwd: string, slug: string) {
|
|
91
|
+
return join(cwd, TASKS_DIR, slug, "task.json");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function listTasks(cwd: string): any[] {
|
|
95
|
+
try {
|
|
96
|
+
return readdirSync(join(cwd, TASKS_DIR))
|
|
97
|
+
.map((slug) => loadJson(taskFile(cwd, slug), null))
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.sort((a: any, b: any) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
100
|
+
} catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Runtime status per task lives in the ignored .pi-flow/status.json, so committed plan files never change after handoff. */
|
|
106
|
+
function flowStatus(cwd: string): Record<string, any> {
|
|
107
|
+
return loadJson(join(cwd, FLOW_DIR, "status.json"), {});
|
|
108
|
+
}
|
|
109
|
+
function setFlowStatus(cwd: string, slug: string, patch: any) {
|
|
110
|
+
const all = flowStatus(cwd);
|
|
111
|
+
all[slug] = { ...(all[slug] ?? {}), ...patch };
|
|
112
|
+
mkdirSync(join(cwd, FLOW_DIR), { recursive: true });
|
|
113
|
+
writeFileSync(join(cwd, FLOW_DIR, "status.json"), `${JSON.stringify(all, null, 2)}\n`);
|
|
114
|
+
}
|
|
115
|
+
/** planning · queued · running · done (all items passed) · finished (some failed) · cleaned */
|
|
116
|
+
function taskStatus(cwd: string, t: any): string {
|
|
117
|
+
const fs = flowStatus(cwd)[t.slug] ?? {};
|
|
118
|
+
if (fs.cleanedAt) return "cleaned";
|
|
119
|
+
if (t.status === "planning") return "planning";
|
|
120
|
+
let cfg: any = {};
|
|
121
|
+
try {
|
|
122
|
+
cfg = readConfig(cwd);
|
|
123
|
+
} catch {}
|
|
124
|
+
if (cfg.task !== t.slug) return fs.status ?? t.status;
|
|
125
|
+
if (runnerPid(cwd) || runnerPid(cwd, "supervisor")) return "running";
|
|
126
|
+
const state = readState(cwd);
|
|
127
|
+
const open = allItems(readText(join(cwd, cfg.queue)), state).filter((i: any) => !state.done?.[i.key] && !state.failed?.[i.key]).length;
|
|
128
|
+
if (open > 0) return "queued (stopped)";
|
|
129
|
+
return Object.keys(state.failed ?? {}).length ? "finished with failures" : "done";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function makeSlug(cwd: string, request: string): string {
|
|
133
|
+
const base =
|
|
134
|
+
request
|
|
135
|
+
.toLowerCase()
|
|
136
|
+
.replace(/[^a-z0-9\s-]/g, " ")
|
|
137
|
+
.split(/\s+/)
|
|
138
|
+
.filter((w) => w && !["the", "a", "an", "and", "to", "of", "for", "in", "on", "with", "please", "i", "we", "want"].includes(w))
|
|
139
|
+
.slice(0, 5)
|
|
140
|
+
.join("-")
|
|
141
|
+
.slice(0, 40)
|
|
142
|
+
.replace(/-+$/, "") || "task";
|
|
143
|
+
let slug = base;
|
|
144
|
+
for (let n = 2; existsSync(join(cwd, TASKS_DIR, slug)); n++) slug = `${base}-${n}`;
|
|
145
|
+
return slug;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A Pi Web client when Pi Web is running, else null. Pi Web is optional everywhere in pi-flow. */
|
|
149
|
+
async function piWeb(cwd: string): Promise<any> {
|
|
150
|
+
try {
|
|
151
|
+
const cfg: any = readConfig(cwd);
|
|
152
|
+
const found: any = await findPiWeb(cfg);
|
|
153
|
+
return found.url ? webClient({ ...cfg, piWebUrl: found.url }) : null;
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------- live panel ----------
|
|
160
|
+
|
|
161
|
+
function liveLines(cwd: string): string[] {
|
|
162
|
+
const pid = runnerPid(cwd);
|
|
163
|
+
const state = readState(cwd);
|
|
164
|
+
const done = state.done ?? {};
|
|
165
|
+
const failed = state.failed ?? {};
|
|
166
|
+
let open = 0;
|
|
167
|
+
let cfg: any = {};
|
|
168
|
+
try {
|
|
169
|
+
cfg = readConfig(cwd);
|
|
170
|
+
open = allItems(readText(join(cwd, cfg.queue)), state).filter((i: any) => !done[i.key] && !failed[i.key]).length;
|
|
171
|
+
} catch {}
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
const width = pid && state.width ? ` · width ${state.width}/${cfg.parallel ?? "?"}` : "";
|
|
174
|
+
const phase = pid && state.phase && state.phase !== "running items" ? ` · ${state.phase}` : "";
|
|
175
|
+
const lines = [
|
|
176
|
+
`${pid ? "● queue RUNNING" : "○ queue not running"}${cfg.task ? ` · task ${cfg.task}` : ""} · passed ${Object.keys(done).length} · failed ${Object.keys(failed).length} · open ${open}${width}${phase}`,
|
|
177
|
+
];
|
|
178
|
+
if (pid) {
|
|
179
|
+
for (const r of Object.values(state.running ?? {}) as any[]) {
|
|
180
|
+
lines.push(` ${r.id} · ${ago(now - Date.parse(r.startedAt))} · ${r.doing ?? "starting"} (${ago(now - (r.since ?? now))} ago) · ${r.tools ?? 0} tool calls`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const log = readText(join(cwd, DIR, "log.md"))
|
|
184
|
+
.trim()
|
|
185
|
+
.split("\n")
|
|
186
|
+
.filter((l) => /\b(PASS|FAIL|CONFLICT|REDO|SPLIT|WIDTH|LIMITS RAISED|STOPP|END|NOT STARTED|ERROR|CRASH|CLEANUP)\b/.test(l))
|
|
187
|
+
.slice(-5)
|
|
188
|
+
.map((l) => ` ${l.replace(/^- \d{4}-\d\d-\d\d /, "")}`);
|
|
189
|
+
const manager = loadJson(join(cwd, DIR, "manager.json"), {});
|
|
190
|
+
if (manager.id) lines.push(` automatic manager ${runnerPid(cwd, "supervisor") ? "watching" : "idle"} · ${manager.id}`);
|
|
191
|
+
return [...lines, ...log];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Start the background runner. Returns an error text, or "" when it started (or already ran to the end). */
|
|
195
|
+
async function startRunner(cwd: string, resume = false): Promise<string> {
|
|
196
|
+
await ensureDir(cwd);
|
|
197
|
+
if (runnerPid(cwd) || runnerPid(cwd, "supervisor")) return "Queue or supervisor is still active; wait for it to finish before starting again.";
|
|
198
|
+
const release = acquireProcessLock(join(cwd, DIR, "start.pid"));
|
|
199
|
+
try {
|
|
200
|
+
if (resume) for (const marker of ["STOP", "PAUSE"]) rmSync(join(cwd, DIR, marker), { force: true });
|
|
201
|
+
const logFile = join(cwd, DIR, "log.md");
|
|
202
|
+
const before = readText(logFile).length;
|
|
203
|
+
const out = openSync(join(cwd, DIR, "runner.out"), "a");
|
|
204
|
+
const child = spawn(LOGIN_SHELL, ["-lc", 'exec node "$0" "$1"', RUNNER, cwd], { cwd, detached: true, stdio: ["ignore", out, out] });
|
|
205
|
+
child.on("error", () => {});
|
|
206
|
+
child.unref();
|
|
207
|
+
closeSync(out);
|
|
208
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
209
|
+
if (runnerPid(cwd)) return "";
|
|
210
|
+
const fresh = readText(logFile).slice(before);
|
|
211
|
+
if (/\bSTART\b/.test(fresh) && !/NOT STARTED|CRASH/.test(fresh)) return "";
|
|
212
|
+
const last = fresh.trim().split("\n").slice(-1).join("\n");
|
|
213
|
+
return `${last || "(no log line)"}\n\n${problemText(cwd) || "(see .pi-queue/runner.out)"}`;
|
|
214
|
+
} finally { release(); }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------- planning ----------
|
|
218
|
+
|
|
219
|
+
function planPrompt(cwd: string, slug: string, request: string, models: any[] = []): string {
|
|
220
|
+
const info: any = loadJson(join(cwd, FLOW_DIR, "repo.json"), null);
|
|
221
|
+
const ok = (info?.checks ?? []).filter((c: any) => c.ok === true).map((c: any) => ` - ${c.cmd}`);
|
|
222
|
+
const bad = (info?.checks ?? []).filter((c: any) => c.ok === false).map((c: any) => ` - ${c.cmd} (FAILS today)`);
|
|
223
|
+
const tests = (info?.checks ?? []).filter((c: any) => c.ok === null).map((c: any) => ` - ${c.cmd} (not run by setup)`);
|
|
224
|
+
const facts = info
|
|
225
|
+
? `Repo facts (from /task setup, ${info.updatedAt}):
|
|
226
|
+
- package manager: ${info.pm || "none"}; packages: ${(info.packages ?? []).map((p: any) => p.dir).join(", ") || "none"}${info.go?.length ? `; Go: ${info.go.join(", ")}` : ""}
|
|
227
|
+
- check commands that pass on the current code:
|
|
228
|
+
${ok.join("\n") || " (none)"}
|
|
229
|
+
${bad.length ? `- check commands that fail today (do not use unless the task fixes them first):\n${bad.join("\n")}\n` : ""}${tests.length ? `- tests available:\n${tests.join("\n")}\n` : ""}`
|
|
230
|
+
: "Repo facts: /task setup has not run here, so no check commands are verified yet. Find the right typecheck/lint/build commands yourself and make sure they pass on the current code.";
|
|
231
|
+
const W = `${TASKS_DIR}/${slug}`;
|
|
232
|
+
return `You are PLANNING a large task so that a queue of fresh, unattended agents can implement it item by item. In this phase do not change any code or config; write only the planning files.
|
|
233
|
+
|
|
234
|
+
TASK: ${request}
|
|
235
|
+
|
|
236
|
+
Workspace: ${W}/ (task.json already exists there)
|
|
237
|
+
${facts}
|
|
238
|
+
|
|
239
|
+
Available authenticated models (exact provider/id, supported thinking levels, advertised USD per million tokens; prices are not speed benchmarks):
|
|
240
|
+
${models.map((m) => `- ${m.provider}/${m.id}: thinking=${getSupportedThinkingLevels(m).join(",")}; input=${m.cost?.input ?? "unknown"}, output=${m.cost?.output ?? "unknown"}`).join("\n") || "No catalog supplied: retain configured defaults; do not invent a model id."}
|
|
241
|
+
Queue defaults: model=${readConfig(cwd).model || "Pi default"}, thinking=${readConfig(cwd).thinking || "Pi default"}.
|
|
242
|
+
|
|
243
|
+
The pi-flow skill's references/items.md explains the item format, tags and how the queue treats them; read it before writing items.md.
|
|
244
|
+
|
|
245
|
+
## 1. Research
|
|
246
|
+
- Read AGENTS.md and the AGENTS.md of every app or folder the task touches.
|
|
247
|
+
- Find every place the task touches: files, functions, endpoints, types, tests, docs. Check what the code does now; do not trust old docs or memory.
|
|
248
|
+
- Use subagents for parallel read-only searches when that is faster. Only you write the planning files.
|
|
249
|
+
|
|
250
|
+
## 2. Write the plan
|
|
251
|
+
- ${W}/brief.md — the goal in a few lines; what "done" means for the whole task; what is out of scope; main risks.
|
|
252
|
+
- ${W}/inventory.md — the facts you found, with paths: what exists, how it behaves now, what each part of the task touches. This is the evidence the items rely on.
|
|
253
|
+
- ${W}/map/<area>.md — one per area when the task spans several: exactly what changes there and why, and what must stay unchanged.
|
|
254
|
+
- ${W}/rules.md — short, task-specific rules every item must follow (contracts that must not change, patterns to reuse, things not to do).
|
|
255
|
+
- ${W}/questions.md — decisions only the user can make, one per line: "- [ ] Q1: <question> — Options: <a> / <b> — Recommended: <a>, because <reason>". Leave it empty if there are none.
|
|
256
|
+
- ${W}/items.md — the work list, format below.
|
|
257
|
+
- ${W}/task.json — keep the existing fields and add:
|
|
258
|
+
"allowedPaths": [the folders/files items may change, as narrow as the task allows; include docs folders if AGENTS.md requires doc updates],
|
|
259
|
+
"checks": [commands that must pass after every item: prefer the verified ones above, covering every package the task touches; each must pass on the current code${checkBroker({}) ? `. pi-check is installed: the single entry "pi-check" runs the tests, type checks and lint each item affects in every Go/TypeScript/Python project and ignores failures that already exist on the base branch; prefer ["pi-check"] plus any check it cannot cover (custom scripts)` : ""}],
|
|
260
|
+
"parallel": <how many agent items may run at once, judged from independence and the user's preference; expensive checks have a separate pi-check budget>,
|
|
261
|
+
"model": <optional default exact provider/id from the catalog>, "thinking": <optional supported default level>
|
|
262
|
+
- Choose [model: provider/id] and [thinking: level] for each item during planning. Use the least costly model/effort you judge sufficient, not automatically the cheapest: routine localized edits may need less; cross-cutting design, authority/security, migrations and final verification need stronger reasoning. Honor the user's model preferences. Record a short rationale per class of items in rules.md. Do not claim an objectively perfect model or infer speed from price. With no catalog, inherit defaults rather than guess.
|
|
263
|
+
- Decide size-escalation/splitting policy upfront in rules.md; automatic increases never override explicit repository/task prohibitions. Resolve approval questions now, not during unattended work.
|
|
264
|
+
|
|
265
|
+
## 3. Ask
|
|
266
|
+
If questions.md has open questions, ask them all here in one message, numbered, each with your recommended answer, and wait. Then record each answer in questions.md ("- [x] Q1: … — Answer: …") and update the brief, maps and items to match. Do not hand off while a question is open.
|
|
267
|
+
|
|
268
|
+
## 4. Hand off
|
|
269
|
+
When every file is written (in an earlier step, not in the same batch of tool calls), call the task_handoff tool on its own with slug "${slug}". It validates the plan, runs the checks once, commits ${W}/ and starts the queue. If it reports problems, fix them and call it again. Then tell the user in two lines what was queued.
|
|
270
|
+
|
|
271
|
+
## items.md format — one line per item
|
|
272
|
+
- [ ] A01 — <what to do, concrete, naming the files/functions>. Done when: <observable result a reviewer can verify>. [areas: <comma-separated folders/files it changes>] [after: <ids it needs finished first>]
|
|
273
|
+
|
|
274
|
+
Rules for items:
|
|
275
|
+
- Each item is done by a fresh agent that has never seen this conversation. It reads AGENTS.md, brief.md, rules.md, the answers in questions.md, and the map/inventory parts the item names. Everything it needs must be there or in the item line.
|
|
276
|
+
- Ids: a capital-letter prefix plus a number (A01, A02, B01…), unique. File order breaks ties; actual prerequisites belong in [after].
|
|
277
|
+
- Size: one coherent change, normally at most 5 files gaining lines and 150 net added lines. A bigger item is fine when splitting it would be artificial; the queue raises limits on request and splits items that stall.
|
|
278
|
+
- [areas: …] is required: the narrowest folders/files the item changes. Items with overlapping areas never run at the same time; disjoint ones run in parallel. Accurate areas mean speed and no conflicts.
|
|
279
|
+
- [after: …] only for real dependencies (a helper built in A01 used by A05). Do not chain independent items or add blanket stage barriers. The scheduler prioritizes prerequisites on the longest remaining dependency chain and fills free slots with disjoint ready items. Use [after: *] only for genuinely whole-task verification; otherwise give checks their exact prerequisites.
|
|
280
|
+
- Every item must leave the checks passing. No item may depend on an unanswered question.
|
|
281
|
+
- Verification goes into [check] items: "- [ ] V01 — Check <what>. Done when: <what must hold>. [check] [areas: <what it verifies>] [after: <the items it verifies>]". A check passes without changes when everything holds; it may fix tests/docs; when it finds production gaps it reports them and the queue adds fix items and checks again. A passed check runs again automatically when an item it covers changes later.
|
|
282
|
+
- No "investigate" or "review" items that produce nothing: every item either changes code/docs or is a [check].`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------- handoff ----------
|
|
286
|
+
|
|
287
|
+
async function handoff(cwd: string, slug: string, ctx: any): Promise<{ ok: boolean; text: string }> {
|
|
288
|
+
const W = `${TASKS_DIR}/${slug}`;
|
|
289
|
+
const task: any = loadJson(taskFile(cwd, slug), null);
|
|
290
|
+
if (!task) return { ok: false, text: `${W}/task.json not found. Is the slug right?` };
|
|
291
|
+
if (runnerPid(cwd) || runnerPid(cwd, "supervisor") || Object.keys(readState(cwd).running ?? {}).length) return { ok: false, text: "A queue/supervisor or interrupted workers still own this repo. Wait for completion or reconcile interrupted work before handing off another task." };
|
|
292
|
+
|
|
293
|
+
const problems: string[] = [];
|
|
294
|
+
const warnings: string[] = [];
|
|
295
|
+
const itemsText = readText(join(cwd, W, "items.md"));
|
|
296
|
+
let items;
|
|
297
|
+
try { items = parseQueue(itemsText); }
|
|
298
|
+
catch (e: any) { return { ok: false, text: `Not handed off: ${e.message}` }; }
|
|
299
|
+
if (items.length === 0) problems.push(`${W}/items.md has no "- [ ] ID — …" lines.`);
|
|
300
|
+
const ids = new Map<string, number>();
|
|
301
|
+
for (const i of items as any[]) ids.set(i.id, (ids.get(i.id) ?? 0) + 1);
|
|
302
|
+
const dupes = [...ids].filter(([, n]) => n > 1).map(([id]) => id);
|
|
303
|
+
if (dupes.length) problems.push(`duplicate ids: ${dupes.join(", ")}`);
|
|
304
|
+
const noId = (items as any[]).filter((i) => /^item\d+$/.test(i.id));
|
|
305
|
+
if (noId.length) problems.push(`${noId.length} line(s) do not start with an id like A01, e.g. "${noId[0].line.slice(0, 60)}"`);
|
|
306
|
+
const noDone = (items as any[]).filter((i) => !/Done when:/i.test(i.line));
|
|
307
|
+
if (noDone.length) problems.push(`${noDone.length} item(s) have no "Done when:": ${noDone.slice(0, 6).map((i) => i.id).join(", ")}`);
|
|
308
|
+
const noAreas = (items as any[]).filter((i) => !i.areas?.length);
|
|
309
|
+
if (noAreas.length) problems.push(`${noAreas.length} item(s) have no [areas: …]: ${noAreas.slice(0, 6).map((i) => i.id).join(", ")}`);
|
|
310
|
+
const defaults = { ...readConfig(cwd), ...(task.model !== undefined ? { model: task.model } : {}), ...(task.thinking !== undefined ? { thinking: task.thinking } : {}) };
|
|
311
|
+
const available = ctx?.modelRegistry?.getAvailable?.() ?? [];
|
|
312
|
+
for (const item of items) {
|
|
313
|
+
try {
|
|
314
|
+
const selected = itemConfig(defaults, item);
|
|
315
|
+
const model = selected.model ? available.find((m: any) => `${m.provider}/${m.id}` === selected.model) : ctx?.model;
|
|
316
|
+
if (selected.model && !model) problems.push(`${item.id}: model ${selected.model} is not available/authenticated.`);
|
|
317
|
+
if (selected.thinking && model && !getSupportedThinkingLevels(model).includes(selected.thinking)) problems.push(`${item.id}: ${selected.thinking} is not supported by ${model.provider}/${model.id}.`);
|
|
318
|
+
} catch (e: any) { problems.push(e.message); }
|
|
319
|
+
}
|
|
320
|
+
try { dependencyGraph(items); } catch (e: any) { problems.push(e.message); }
|
|
321
|
+
const openQs = readText(join(cwd, W, "questions.md"))
|
|
322
|
+
.split("\n")
|
|
323
|
+
.filter((l) => /^\s*-\s\[\s\]/.test(l));
|
|
324
|
+
if (openQs.length) problems.push(`${openQs.length} question(s) in questions.md are still open. Ask the user, record the answers ("- [x] … — Answer: …"), then hand off.`);
|
|
325
|
+
for (const f of ["brief.md", "rules.md", "questions.md"]) if (!existsSync(join(cwd, W, f))) problems.push(`${W}/${f} is missing.`);
|
|
326
|
+
const allowed: string[] = Array.isArray(task.allowedPaths) ? task.allowedPaths.filter((p: any) => typeof p === "string" && p.trim()) : [];
|
|
327
|
+
if (allowed.length === 0) problems.push(`task.json has no "allowedPaths".`);
|
|
328
|
+
const outside = (items as any[]).filter((i) => i.areas?.length && !i.areas.every((a: string) => allowed.some((p) => areasOverlap([a], [p]) && a.startsWith(p.split("*")[0].replace(/\/+$/, "")))));
|
|
329
|
+
if (outside.length) warnings.push(`${outside.length} item(s) list areas outside allowedPaths (${outside.slice(0, 4).map((i) => i.id).join(", ")}); their changes there will be rejected.`);
|
|
330
|
+
if (problems.length) return { ok: false, text: `Not handed off yet. Fix these and call task_handoff again:\n- ${problems.join("\n- ")}` };
|
|
331
|
+
|
|
332
|
+
// checks: the plan's, else verified ones from setup that cover the allowed paths
|
|
333
|
+
const info: any = loadJson(join(cwd, FLOW_DIR, "repo.json"), null);
|
|
334
|
+
let checks: string[] = Array.isArray(task.checks) ? task.checks.filter((x: any) => typeof x === "string" && x.trim()) : [];
|
|
335
|
+
if (checks.length === 0)
|
|
336
|
+
checks = (info?.checks ?? []).filter((c: any) => c.ok === true && areasOverlap([c.dir === "." ? "" : c.dir], allowed)).map((c: any) => c.cmd);
|
|
337
|
+
if (checks.length === 0) return { ok: false, text: `No checks: add "checks" to task.json (commands that pass on the current code, e.g. a typecheck for each package the task touches), then call task_handoff again.` };
|
|
338
|
+
const failing: string[] = [];
|
|
339
|
+
for (const cmd of checks) {
|
|
340
|
+
ctx?.ui?.setStatus?.("task", `verifying check: ${cmd}`);
|
|
341
|
+
const l = checkLine({}, cmd, cwd);
|
|
342
|
+
if (l.error) {
|
|
343
|
+
failing.push(l.error);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const r = await run(cwd, LOGIN_SHELL, ["-lc", l.line], { timeoutMs: 15 * 60_000 * l.factor, env: cleanEnv() });
|
|
347
|
+
if (r.code !== 0) failing.push(`${cmd} (exit ${r.code})\n${tail(r.out, 20)}`);
|
|
348
|
+
}
|
|
349
|
+
ctx?.ui?.setStatus?.("task", undefined);
|
|
350
|
+
if (failing.length)
|
|
351
|
+
return { ok: false, text: `These checks fail on the current code, so every item would fail. Pick commands that pass today (or fix the command), update task.json, and call task_handoff again:\n\n${failing.join("\n\n")}` };
|
|
352
|
+
|
|
353
|
+
// the tree must be clean apart from this task's folder (our own runtime folders are kept out of git first)
|
|
354
|
+
await ensureDir(cwd);
|
|
355
|
+
await exclude(cwd, `${FLOW_DIR}/`);
|
|
356
|
+
const dirty = (await git(cwd, "status", "--porcelain", "--untracked-files=all")).out
|
|
357
|
+
.split("\n")
|
|
358
|
+
.filter(Boolean)
|
|
359
|
+
.map((l) => l.slice(3).replace(/^"|"$/g, ""))
|
|
360
|
+
.filter((p) => !p.startsWith(`${W}/`));
|
|
361
|
+
if (dirty.length)
|
|
362
|
+
return { ok: false, text: `Uncommitted changes outside ${W}/ (the queue needs a clean tree). Tell the user to commit or stash them, then call task_handoff again:\n- ${dirty.slice(0, 15).join("\n- ")}` };
|
|
363
|
+
|
|
364
|
+
// record and commit the plan (worktrees only see committed files)
|
|
365
|
+
task.allowedPaths = allowed;
|
|
366
|
+
task.checks = checks;
|
|
367
|
+
task.status = "queued";
|
|
368
|
+
task.queuedAt = new Date().toISOString();
|
|
369
|
+
task.items = items.length;
|
|
370
|
+
writeFileSync(taskFile(cwd, slug), `${JSON.stringify(task, null, 2)}\n`);
|
|
371
|
+
await git(cwd, "add", "--", W);
|
|
372
|
+
const commit = await git(cwd, "commit", "-m", `task ${slug}: plan (${items.length} items)`, "--", W);
|
|
373
|
+
if (commit.code !== 0 && !/nothing to commit|no changes added/i.test(commit.out)) return { ok: false, text: `Could not commit ${W}/: ${tail(commit.out, 5)}` };
|
|
374
|
+
|
|
375
|
+
// archive the previous run's records, keep worktrees (installs are reused)
|
|
376
|
+
await ensureDir(cwd);
|
|
377
|
+
const prev: any = loadJson(join(cwd, DIR, "state.json"), null);
|
|
378
|
+
if (prev) {
|
|
379
|
+
const dest = join(cwd, DIR, "archive", `${readConfig(cwd).task || "previous"}-${new Date().toISOString().replace(/\D/g, "").slice(0, 12)}`);
|
|
380
|
+
mkdirSync(dest, { recursive: true });
|
|
381
|
+
for (const f of ["state.json", "log.md", "notes", "live", "problem.txt", "manager.json", "control"]) {
|
|
382
|
+
if (existsSync(join(cwd, DIR, f))) renameSync(join(cwd, DIR, f), join(dest, f));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const existing: any = loadJson(join(cwd, DIR, "config.json"), {});
|
|
387
|
+
const parallel = Math.max(1, Math.floor(Number(task.parallel)) || existing.parallel || DEFAULTS.parallel);
|
|
388
|
+
const config = {
|
|
389
|
+
...DEFAULTS,
|
|
390
|
+
...existing,
|
|
391
|
+
queue: `${W}/items.md`,
|
|
392
|
+
task: slug,
|
|
393
|
+
allowedPaths: allowed,
|
|
394
|
+
checks,
|
|
395
|
+
model: defaults.model,
|
|
396
|
+
thinking: defaults.thinking,
|
|
397
|
+
parallel,
|
|
398
|
+
worktrees: parallel > 1 ? true : existing.worktrees ?? DEFAULTS.worktrees,
|
|
399
|
+
setup: info?.install ?? existing.setup ?? "",
|
|
400
|
+
readFirst: `AGENTS.md, ${W}/brief.md, ${W}/rules.md, the answers in ${W}/questions.md, and the parts of ${W}/map/*.md and ${W}/inventory.md this item refers to`,
|
|
401
|
+
};
|
|
402
|
+
writeFileSync(join(cwd, DIR, "config.json"), `${JSON.stringify(config, null, 2)}\n`);
|
|
403
|
+
|
|
404
|
+
const err = await startRunner(cwd, true);
|
|
405
|
+
if (err) return { ok: false, text: `The plan is committed, but the queue did not start:\n${err}` };
|
|
406
|
+
// In Pi Web, pin this planning chat while the queue runs: it is the task's control room
|
|
407
|
+
const sid = ctx?.sessionManager?.getSessionId?.();
|
|
408
|
+
if (sid) {
|
|
409
|
+
const web = await piWeb(cwd);
|
|
410
|
+
if (web) await web.ui({ action: "set", ids: [sid], pinned: true }).catch(() => {});
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
ok: true,
|
|
414
|
+
text: `Queued ${items.length} items for task ${slug} (up to ${parallel} in parallel, adaptive). Checks: ${checks.join(" · ")}.${warnings.length ? `\nWarnings: ${warnings.join(" ")}` : ""}\nThe live panel below shows progress; /task status for a summary.`,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---------- cleanup ----------
|
|
419
|
+
|
|
420
|
+
async function cleanup(cwd: string, all: boolean, currentSession: string): Promise<string> {
|
|
421
|
+
if (runnerPid(cwd) || runnerPid(cwd, "supervisor") || Object.keys(readState(cwd).running ?? {}).length) return "The queue/supervisor or interrupted workers still own the worktrees. Stop and reconcile them before cleanup.";
|
|
422
|
+
const notes: string[] = [];
|
|
423
|
+
const state = readState(cwd);
|
|
424
|
+
const web = await piWeb(cwd);
|
|
425
|
+
const managerFile = join(cwd, DIR, "manager.json"), manager = loadJson(managerFile, {});
|
|
426
|
+
if (pidAlive(manager.pid)) return "The automatic manager turn is still active; wait before cleanup.";
|
|
427
|
+
if (manager.web && manager.id && web && (await web.running()).runningSessionIds?.includes(manager.id)) return "The automatic manager is still active in Pi Web; wait before cleanup.";
|
|
428
|
+
if (manager.id && manager.id !== currentSession) state.sessions = { ...state.sessions, [manager.id]: { ...state.sessions?.[manager.id], web: !!manager.web, status: "manager" } };
|
|
429
|
+
const rctx = { repo: cwd, state, web, save: () => writeFileSync(join(cwd, DIR, "state.json"), `${JSON.stringify(state, null, 2)}\n`), log: (m: string) => notes.push(m) };
|
|
430
|
+
|
|
431
|
+
// item sessions: Pi Web ones through its API, terminal ones by file
|
|
432
|
+
const items = Object.entries(state.sessions ?? {}).filter(([, x]: any) => !x.deleted).map(([id]) => id);
|
|
433
|
+
const webLeft = items.filter((id) => state.sessions[id].web);
|
|
434
|
+
if (webLeft.length && !web) notes.push(`${webLeft.length} item session(s) are in Pi Web, which is not running now; run /task cleanup again with Pi Web open to remove them`);
|
|
435
|
+
const n = items.length ? await deleteSessions(rctx, items) : 0;
|
|
436
|
+
notes.push(n ? `deleted ${n} item/manager session(s)` : "no item sessions left to delete");
|
|
437
|
+
if (state.sessions?.[manager.id]?.deleted) {
|
|
438
|
+
delete manager.id; manager.initialized = false;
|
|
439
|
+
writeFileSync(managerFile, `${JSON.stringify(manager, null, 2)}\n`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// planning chats of finished tasks (never the one you are in)
|
|
443
|
+
const fs = flowStatus(cwd);
|
|
444
|
+
const tasks = listTasks(cwd).filter((t: any) => ["done", "finished with failures", "queued (stopped)"].includes(taskStatus(cwd, t)));
|
|
445
|
+
let planned = 0;
|
|
446
|
+
let keptCurrent = false;
|
|
447
|
+
for (const t of tasks) {
|
|
448
|
+
const ids = [...new Set([...(t.planningSessions ?? []), ...(fs[t.slug]?.planningSessions ?? [])])];
|
|
449
|
+
const files: Record<string, string> = fs[t.slug]?.planningFiles ?? {};
|
|
450
|
+
let pending = false;
|
|
451
|
+
for (const id of ids) {
|
|
452
|
+
if (!id) continue;
|
|
453
|
+
if (id === currentSession) {
|
|
454
|
+
keptCurrent = true;
|
|
455
|
+
if (web) await web.ui({ action: "set", ids: [id], pinned: false }).catch(() => {});
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
let gone = false;
|
|
459
|
+
if (web) gone = await web.del([id]).then((r: any) => (r.deleted ?? []).includes(id) || (r.missing ?? []).includes(id), () => false);
|
|
460
|
+
if (!web && files[id]) {
|
|
461
|
+
rmSync(files[id], { force: true });
|
|
462
|
+
gone = true;
|
|
463
|
+
}
|
|
464
|
+
if (gone) planned++;
|
|
465
|
+
else { pending = true; notes.push(`kept planning chat ${id}: deletion was not confirmed`); }
|
|
466
|
+
}
|
|
467
|
+
if (!pending) setFlowStatus(cwd, t.slug, { status: taskStatus(cwd, t), cleanedAt: new Date().toISOString() });
|
|
468
|
+
}
|
|
469
|
+
if (planned) notes.push(`deleted ${planned} planning chat(s)`);
|
|
470
|
+
if (keptCurrent) notes.push("kept this chat (you are in it); delete it from the sidebar or /resume list when you leave it");
|
|
471
|
+
|
|
472
|
+
if (all) {
|
|
473
|
+
const left = Object.values(state.sessions ?? {}).filter((x: any) => !x.deleted).length;
|
|
474
|
+
const root = join(cwd, DIR, "worktrees");
|
|
475
|
+
const wts = existsSync(root) ? readdirSync(root).filter((d) => /^w\d+$/.test(d)) : [];
|
|
476
|
+
const branches = (await git(cwd, "branch", "--list", "queue-failed/*", "--format=%(refname:short)")).out.split("\n").filter(Boolean);
|
|
477
|
+
for (const b of branches) await git(cwd, "branch", "-D", b);
|
|
478
|
+
if (branches.length) notes.push(`deleted ${branches.length} queue-failed/* branches`);
|
|
479
|
+
if (wts.length && left) {
|
|
480
|
+
// Pi Web would show the sessions of a removed worktree as a stray project
|
|
481
|
+
notes.push(`kept the ${wts.length} worktrees: ${left} item session(s) still exist; remove those first`);
|
|
482
|
+
} else if (wts.length) {
|
|
483
|
+
const script = `${wts.map((w) => `git worktree remove --force "${join(root, w)}"; rm -f "${join(root, `${w}.ready`)}"`).join("; ")}; git worktree prune`;
|
|
484
|
+
spawn(LOGIN_SHELL, ["-lc", script], { cwd, detached: true, stdio: "ignore" }).unref();
|
|
485
|
+
notes.push(`removing ${wts.length} worktrees in the background (the next task reinstalls them)`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return notes.join("\n");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ---------- manager ----------
|
|
492
|
+
|
|
493
|
+
/** Queue tools are for you and the manager chat, never for the queue's own item sessions. */
|
|
494
|
+
function insideQueueItem(ctx: any): boolean {
|
|
495
|
+
const cwd = String(ctx?.cwd ?? "");
|
|
496
|
+
let name = "";
|
|
497
|
+
try {
|
|
498
|
+
name = ctx?.sessionManager?.getSessionName?.() ?? "";
|
|
499
|
+
} catch {}
|
|
500
|
+
return cwd.includes(`/${DIR}/worktrees/`) || /^queue [A-Za-z0-9-]+:/.test(name);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function overviewText(cwd: string): string {
|
|
504
|
+
const cfg: any = readConfig(cwd);
|
|
505
|
+
const state = readState(cwd);
|
|
506
|
+
const items = allItems(readText(join(cwd, cfg.queue || "")), state);
|
|
507
|
+
const by = (pred: (i: any) => boolean) => items.filter(pred);
|
|
508
|
+
const fmt = (i: any, extra = "") => ` ${i.id}${i.check ? " [check]" : ""}${extra} — ${i.title.slice(0, 90)}`;
|
|
509
|
+
const running = Object.values(state.running ?? {}) as any[];
|
|
510
|
+
const failed = by((i) => state.failed[i.key]);
|
|
511
|
+
const parked = by((i) => !state.done[i.key] && !state.failed[i.key] && (state.retried?.[i.key] || state.deferred?.[i.key]));
|
|
512
|
+
const open = by((i) => !state.done[i.key] && !state.failed[i.key] && !state.retried?.[i.key] && !state.deferred?.[i.key]);
|
|
513
|
+
const logTail = readText(join(cwd, DIR, "log.md")).trim().split("\n").slice(-15).join("\n");
|
|
514
|
+
return [
|
|
515
|
+
`task ${cfg.task || "(none)"} · queue ${runnerPid(cwd) ? "RUNNING" : "not running"} · ${items.length} items: passed ${by((i) => state.done[i.key]).length}, failed ${failed.length}, parked ${parked.length}, open ${open.length}`,
|
|
516
|
+
running.length ? `running now:\n${running.map((r) => ` ${r.id} · ${r.doing ?? ""}`).join("\n")}` : "",
|
|
517
|
+
failed.length ? `failed (reason):\n${failed.map((i) => fmt(i, `: ${String(state.failed[i.key]).slice(0, 200)}`)).join("\n")}` : "",
|
|
518
|
+
parked.length ? `parked (conflict or deferred BLOCKED; they run when everything else is done):\n${parked.map((i) => fmt(i, state.deferred?.[i.key] ? " (deferred)" : " (conflict)")).join("\n")}` : "",
|
|
519
|
+
open.length ? `open:\n${open.slice(0, 40).map((i) => fmt(i, i.after?.length ? ` [after: ${i.after.join(", ")}]` : "")).join("\n")}${open.length > 40 ? `\n … ${open.length - 40} more` : ""}` : "",
|
|
520
|
+
`recent log:\n${logTail}`,
|
|
521
|
+
]
|
|
522
|
+
.filter(Boolean)
|
|
523
|
+
.join("\n\n");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function itemText(cwd: string, id: string): Promise<string> {
|
|
527
|
+
const cfg: any = readConfig(cwd);
|
|
528
|
+
const state = readState(cwd);
|
|
529
|
+
const item: any = allItems(readText(join(cwd, cfg.queue || "")), state).find((i: any) => i.id === id);
|
|
530
|
+
if (!item) return `No item ${id} in the queue.`;
|
|
531
|
+
const status = state.done[item.key] ? `passed (${state.done[item.key]})` : state.failed[item.key] ? `failed: ${state.failed[item.key]}` : state.deferred?.[item.key] ? "deferred" : state.retried?.[item.key] ? "parked after a conflict" : "open";
|
|
532
|
+
const note = readText(join(cwd, DIR, "notes", `${id}.md`)).trim().slice(0, 3000);
|
|
533
|
+
const live = readText(join(cwd, DIR, "live", `${id}.log`)).trim().split("\n").slice(-20).join("\n");
|
|
534
|
+
const sha = state.done[item.key];
|
|
535
|
+
const diff = sha && sha !== "checked" ? (await git(cwd, "show", "--stat", "--format=%h %s", sha)).out.trim().slice(0, 2000) : "";
|
|
536
|
+
return [`${item.line}`, `status: ${status}`, diff ? `commit:\n${diff}` : "", note ? `last result:\n${note}` : "", live ? `activity:\n${live}` : ""].filter(Boolean).join("\n\n");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Persist controls in one inbox; only the runner mutates queue state. */
|
|
540
|
+
async function control(cwd: string, ctl: any): Promise<string> {
|
|
541
|
+
if (ctl.action === "pause") {
|
|
542
|
+
mkdirSync(join(cwd, DIR), { recursive: true });
|
|
543
|
+
writeFileSync(join(cwd, DIR, "PAUSE"), "");
|
|
544
|
+
return runnerPid(cwd) ? "Pausing dispatch: running items finish normally; no new items start." : "The queue is not running.";
|
|
545
|
+
}
|
|
546
|
+
if (ctl.action === "stop") {
|
|
547
|
+
mkdirSync(join(cwd, DIR), { recursive: true });
|
|
548
|
+
writeFileSync(join(cwd, DIR, "STOP"), "");
|
|
549
|
+
return runnerPid(cwd) ? "Stopping: running items stop within seconds; their work is kept on queue-failed/ branches." : "The queue is not running.";
|
|
550
|
+
}
|
|
551
|
+
if (ctl.action === "start") {
|
|
552
|
+
if (runnerPid(cwd)) return "The queue is already running.";
|
|
553
|
+
const err = await startRunner(cwd, true);
|
|
554
|
+
return err ? `The queue did not start:\n${err}` : "Queue started.";
|
|
555
|
+
}
|
|
556
|
+
const cfg: any = readConfig(cwd);
|
|
557
|
+
enqueueControl(cwd, { ...ctl, task: cfg.task, by: "user" });
|
|
558
|
+
return `${ctl.action} queued; ${runnerPid(cwd) ? "the runner applies it within seconds" : "it applies on the next start"}.`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function managerPrompt(cwd: string): string {
|
|
562
|
+
const cfg: any = readConfig(cwd);
|
|
563
|
+
return `You are now the MANAGER of this repo's work queue${cfg.task ? ` (task ${cfg.task}, plan in .tasks/${cfg.task}/)` : ""}. You act only on what I ask in each message; you never start work on your own.
|
|
564
|
+
|
|
565
|
+
The pi-flow skill (references/manager.md, items.md, troubleshooting.md) explains every action and the usual causes of failures; read it when unsure.
|
|
566
|
+
|
|
567
|
+
Your tools: queue_overview (status, failures with reasons, parked/open items, recent log), queue_item (one item: its line, status, commit, last result, activity), queue_control (stop, start, retry, recheck, rerun, unpark, add, parallel).
|
|
568
|
+
|
|
569
|
+
How to help me:
|
|
570
|
+
- When I ask what is wrong: read the overview and the failing items, find the real cause (often several failures share one cause, or a check ran before the work it checks), and explain it in a few lines with what you would do.
|
|
571
|
+
- When I ask you to act: say what you will do, do it with queue_control, then show the result. retry = run failed/parked items again; recheck = run passed [check] items again; rerun = run passed items again; unpark = let parked items run normally and in parallel; next = run these items next; add = new items in the work-list format (only when I agreed to them); parallel = how many run at once, also while running (my number becomes the ceiling).
|
|
572
|
+
- Never edit code, revert commits or delete branches. If something needs that, tell me.
|
|
573
|
+
|
|
574
|
+
Start with a short overview of where things stand and anything that needs my attention.`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// exported for tests
|
|
578
|
+
export { cleanup, handoff, planPrompt, control, startRunner };
|
|
579
|
+
|
|
580
|
+
// ---------- the extension ----------
|
|
581
|
+
|
|
582
|
+
export default function (pi: ExtensionAPI) {
|
|
583
|
+
let watch: ReturnType<typeof setInterval> | undefined;
|
|
584
|
+
function stopWatch(ctx?: any) {
|
|
585
|
+
clearInterval(watch);
|
|
586
|
+
watch = undefined;
|
|
587
|
+
try {
|
|
588
|
+
ctx?.ui.setWidget("queue", undefined);
|
|
589
|
+
} catch {}
|
|
590
|
+
}
|
|
591
|
+
function startWatch(ctx: any) {
|
|
592
|
+
stopWatch();
|
|
593
|
+
if (!ctx.hasUI) return;
|
|
594
|
+
const cwd = ctx.cwd;
|
|
595
|
+
const tick = () => {
|
|
596
|
+
try {
|
|
597
|
+
ctx.ui.setWidget("queue", liveLines(cwd));
|
|
598
|
+
} catch {
|
|
599
|
+
stopWatch();
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
tick();
|
|
603
|
+
watch = setInterval(tick, 4000);
|
|
604
|
+
watch.unref?.();
|
|
605
|
+
}
|
|
606
|
+
pi.on("session_shutdown", async (_event, ctx) => stopWatch(ctx));
|
|
607
|
+
|
|
608
|
+
pi.registerTool({
|
|
609
|
+
name: "task_handoff",
|
|
610
|
+
label: "Hand task to queue",
|
|
611
|
+
description:
|
|
612
|
+
"Hand a planned task to the queue: validates .tasks/<slug>/ (items, questions answered, allowedPaths, checks), runs the checks once, commits the plan and starts the queue. Call it only when planning is complete and every question is answered.",
|
|
613
|
+
promptSnippet: "task_handoff: validate a planned task and start its queue",
|
|
614
|
+
parameters: Type.Object({ slug: Type.String({ description: "The task slug (folder name under .tasks/)" }) }),
|
|
615
|
+
async execute(_id: string, params: any, _signal: any, _onUpdate: any, ctx: any) {
|
|
616
|
+
const blocked = guard(ctx);
|
|
617
|
+
if (blocked) return blocked;
|
|
618
|
+
const r = await handoff(ctx.cwd, String(params.slug).trim(), ctx);
|
|
619
|
+
if (r.ok) startWatch(ctx);
|
|
620
|
+
return { content: [{ type: "text", text: r.text }], details: { ok: r.ok } };
|
|
621
|
+
},
|
|
622
|
+
} as any);
|
|
623
|
+
|
|
624
|
+
const guard = (ctx: any) => (insideQueueItem(ctx) ? { content: [{ type: "text", text: "Queue tools are not available inside a queue item. Finish the item or reply BLOCKED." }], details: {} } : null);
|
|
625
|
+
|
|
626
|
+
pi.registerTool({
|
|
627
|
+
name: "queue_overview",
|
|
628
|
+
label: "Queue overview",
|
|
629
|
+
description: "The work queue's state: counts, running items, failed items with reasons, parked and open items, recent log. Use it when the user asks about the queue or the task's progress.",
|
|
630
|
+
promptSnippet: "queue_overview: where the task queue stands",
|
|
631
|
+
parameters: Type.Object({}),
|
|
632
|
+
async execute(_id: string, _p: any, _s: any, _u: any, ctx: any) {
|
|
633
|
+
return guard(ctx) ?? { content: [{ type: "text", text: overviewText(ctx.cwd) }], details: {} };
|
|
634
|
+
},
|
|
635
|
+
} as any);
|
|
636
|
+
|
|
637
|
+
pi.registerTool({
|
|
638
|
+
name: "queue_item",
|
|
639
|
+
label: "Queue item",
|
|
640
|
+
description: "One queue item in detail: its line, status, commit, last result and recent activity.",
|
|
641
|
+
promptSnippet: "queue_item: details of one queue item",
|
|
642
|
+
parameters: Type.Object({ id: Type.String({ description: "Item id, e.g. P03 or V04f" }) }),
|
|
643
|
+
async execute(_id: string, p: any, _s: any, _u: any, ctx: any) {
|
|
644
|
+
return guard(ctx) ?? { content: [{ type: "text", text: await itemText(ctx.cwd, String(p.id).trim()) }], details: {} };
|
|
645
|
+
},
|
|
646
|
+
} as any);
|
|
647
|
+
|
|
648
|
+
pi.registerTool({
|
|
649
|
+
name: "queue_control",
|
|
650
|
+
label: "Control queue",
|
|
651
|
+
description:
|
|
652
|
+
"Change the work queue, only when the user asked for it: pause (stop dispatch, let running items finish) | stop (abort running items) | start | retry (failed/parked items run again) | recheck (passed [check] items run again) | rerun (passed items run again) | unpark (parked/deferred items run normally, in parallel) | next (run these items next: un-parks them and puts them first) | add (new work-list lines, only ones the user agreed to) | parallel (value = how many at once, becomes the ceiling; works while running). ids are item ids or prefixes (e.g. V04 matches V04a…V04h); empty ids = all.",
|
|
653
|
+
promptSnippet: "queue_control: pause/stop/start/retry/recheck/rerun/unpark/next/add/parallel (only when the user asks)",
|
|
654
|
+
parameters: Type.Object({
|
|
655
|
+
action: Type.Union(["pause", "stop", "start", "retry", "recheck", "rerun", "unpark", "next", "add", "parallel"].map((a) => Type.Literal(a))),
|
|
656
|
+
ids: Type.Optional(Type.Array(Type.String())),
|
|
657
|
+
lines: Type.Optional(Type.Array(Type.String({ description: "Work-list lines: A01 — what. Done when: … [areas: …] [after: …]" }))),
|
|
658
|
+
value: Type.Optional(Type.Number()),
|
|
659
|
+
}),
|
|
660
|
+
async execute(_id: string, p: any, _s: any, _u: any, ctx: any) {
|
|
661
|
+
return guard(ctx) ?? { content: [{ type: "text", text: await control(ctx.cwd, p) }], details: {} };
|
|
662
|
+
},
|
|
663
|
+
} as any);
|
|
664
|
+
|
|
665
|
+
pi.registerCommand("task", {
|
|
666
|
+
description: "Plan and run a large task: /task <request> | manage | setup | status | resume <slug> | cleanup [--all] | save-profile",
|
|
667
|
+
handler: async (args, ctx) => {
|
|
668
|
+
const cwd = ctx.cwd;
|
|
669
|
+
const text = (args ?? "").trim();
|
|
670
|
+
const [cmd = "", ...rest] = text.split(/\s+/);
|
|
671
|
+
|
|
672
|
+
if (!text || cmd === "help") {
|
|
673
|
+
ctx.ui.notify(
|
|
674
|
+
"/task <what you want done> plan it here; it asks its questions, then starts the queue\n/task manage make this chat the queue manager: ask it what is wrong, tell it to retry/recheck/rerun\n/task setup once per repo (plugins, verified checks, AGENTS.md)\n/task status tasks and the queue\n/task resume <slug> continue planning in this session\n/task cleanup [--all] delete finished task sessions (--all: worktrees, failed branches too)\n/task save-profile use this repo's plugins as the profile for /task setup",
|
|
675
|
+
"info",
|
|
676
|
+
);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (cmd === "setup") {
|
|
681
|
+
ctx.ui.notify("task setup: started. Plugins, AGENTS.md, then every check command runs once (a few minutes).", "info");
|
|
682
|
+
const setStatus = (s?: string) => {
|
|
683
|
+
try {
|
|
684
|
+
ctx.ui.setStatus("task", s ? `setup: ${s}` : undefined);
|
|
685
|
+
} catch {}
|
|
686
|
+
};
|
|
687
|
+
setupRepo(cwd, setStatus)
|
|
688
|
+
.then((r: any) => {
|
|
689
|
+
setStatus();
|
|
690
|
+
ctx.ui.notify(
|
|
691
|
+
`task setup: done. ${r.ok} check(s) pass, ${r.bad} fail.\n${r.notes.map((n: string) => `- ${n}`).join("\n")}\nFull report: ${FLOW_DIR}/setup-report.md`,
|
|
692
|
+
r.bad ? "warning" : "info",
|
|
693
|
+
);
|
|
694
|
+
})
|
|
695
|
+
.catch((e: any) => {
|
|
696
|
+
setStatus();
|
|
697
|
+
ctx.ui.notify(`task setup failed: ${e?.message ?? e}`, "error");
|
|
698
|
+
});
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (cmd === "save-profile") {
|
|
703
|
+
try {
|
|
704
|
+
const r = saveProfile(cwd);
|
|
705
|
+
ctx.ui.notify(`task: saved ${r.count} project plugin(s) as your profile (${r.file}). /task setup installs them in other repos.`, "info");
|
|
706
|
+
} catch (e: any) {
|
|
707
|
+
ctx.ui.notify(`task: could not save the profile: ${e?.message ?? e}`, "error");
|
|
708
|
+
}
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (cmd === "status" || cmd === "list") {
|
|
713
|
+
const tasks = listTasks(cwd);
|
|
714
|
+
const rows = tasks.map((t: any) => {
|
|
715
|
+
const items = parseQueue(readText(join(cwd, TASKS_DIR, t.slug, "items.md"))).length;
|
|
716
|
+
const open = readText(join(cwd, TASKS_DIR, t.slug, "questions.md"))
|
|
717
|
+
.split("\n")
|
|
718
|
+
.filter((l) => /^\s*-\s\[\s\]/.test(l)).length;
|
|
719
|
+
return `- ${t.slug} · ${taskStatus(cwd, t)}${items ? ` · ${items} items` : ""}${open ? ` · ${open} open questions` : ""}`;
|
|
720
|
+
});
|
|
721
|
+
ctx.ui.notify(`${rows.join("\n") || "No tasks yet. /task <request> to plan one."}\n\n${liveLines(cwd).join("\n")}`, "info");
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (cmd === "manage" || cmd === "manager") {
|
|
726
|
+
try {
|
|
727
|
+
pi.setSessionName(`manager${readConfig(cwd).task ? ` · ${readConfig(cwd).task}` : ""}`);
|
|
728
|
+
} catch {}
|
|
729
|
+
const web = await piWeb(cwd);
|
|
730
|
+
if (web) await web.ui({ action: "set", ids: [ctx.sessionManager.getSessionId()], pinned: true }).catch(() => {});
|
|
731
|
+
startWatch(ctx);
|
|
732
|
+
await ctx.waitForIdle();
|
|
733
|
+
pi.sendUserMessage(managerPrompt(cwd));
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (cmd === "cleanup") {
|
|
738
|
+
const msg = await cleanup(cwd, rest.includes("--all"), ctx.sessionManager.getSessionId());
|
|
739
|
+
ctx.ui.notify(`task cleanup:\n${msg}`, "info");
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
let slug: string;
|
|
744
|
+
let request: string;
|
|
745
|
+
if (cmd === "resume") {
|
|
746
|
+
slug = rest[0] ?? "";
|
|
747
|
+
const t: any = loadJson(taskFile(cwd, slug), null);
|
|
748
|
+
if (!t) {
|
|
749
|
+
ctx.ui.notify(`task: no task "${slug}". /task status lists them.`, "warning");
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (t.status !== "planning") {
|
|
753
|
+
ctx.ui.notify(`task ${slug} was already handed to the queue (${taskStatus(cwd, t)}). /queue status, or /queue retry + /queue start for failed items.`, "warning");
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
request = t.request;
|
|
757
|
+
t.planningSessions = [...new Set([...(t.planningSessions ?? []), ctx.sessionManager.getSessionId()])];
|
|
758
|
+
writeFileSync(taskFile(cwd, slug), `${JSON.stringify(t, null, 2)}\n`);
|
|
759
|
+
} else {
|
|
760
|
+
if ((await git(cwd, "rev-parse", "--is-inside-work-tree")).out.trim() !== "true") {
|
|
761
|
+
ctx.ui.notify("task: this folder is not a git repository.", "error");
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
request = text;
|
|
765
|
+
await ensureDir(cwd);
|
|
766
|
+
await exclude(cwd, `${FLOW_DIR}/`);
|
|
767
|
+
slug = makeSlug(cwd, request);
|
|
768
|
+
mkdirSync(join(cwd, TASKS_DIR, slug, "map"), { recursive: true });
|
|
769
|
+
writeFileSync(
|
|
770
|
+
taskFile(cwd, slug),
|
|
771
|
+
`${JSON.stringify({ slug, request, createdAt: new Date().toISOString(), status: "planning", planningSessions: [ctx.sessionManager.getSessionId()] }, null, 2)}\n`,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
const sid = ctx.sessionManager.getSessionId();
|
|
775
|
+
const prevFs = flowStatus(cwd)[slug] ?? {};
|
|
776
|
+
const sfile = ctx.sessionManager.getSessionFile?.();
|
|
777
|
+
setFlowStatus(cwd, slug, {
|
|
778
|
+
planningSessions: [...new Set([...(prevFs.planningSessions ?? []), sid])],
|
|
779
|
+
planningFiles: { ...(prevFs.planningFiles ?? {}), ...(sfile ? { [sid]: sfile } : {}) },
|
|
780
|
+
});
|
|
781
|
+
try {
|
|
782
|
+
pi.setSessionName(`task ${slug}: plan`);
|
|
783
|
+
} catch {}
|
|
784
|
+
if (!existsSync(join(cwd, FLOW_DIR, "repo.json")))
|
|
785
|
+
ctx.ui.notify("task: /task setup has not run in this repo, so no checks are verified yet. Planning anyway; the plan will pick its own checks.", "warning");
|
|
786
|
+
ctx.ui.notify(`task ${slug}: planning in this session. It will ask its questions here, then start the queue.`, "info");
|
|
787
|
+
await ctx.waitForIdle();
|
|
788
|
+
const prompt = planPrompt(cwd, slug, request, ctx.modelRegistry.getAvailable());
|
|
789
|
+
pi.sendUserMessage(cmd === "resume" ? `Continue planning task ${slug}: read ${TASKS_DIR}/${slug}/ (what exists so far), then carry on with the steps below.\n\n${prompt}` : prompt);
|
|
790
|
+
},
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
pi.registerCommand("queue", {
|
|
794
|
+
description: "Control the task queue: /queue start | stop | status | watch [off] | log <id> | open <id> | retry [id] | recheck [id] | rerun <id> | unpark | next <ids> | parallel <n> | problem",
|
|
795
|
+
handler: async (args, ctx) => {
|
|
796
|
+
const cwd = ctx.cwd;
|
|
797
|
+
const [cmd = "status", arg] = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
798
|
+
|
|
799
|
+
if (cmd === "watch") {
|
|
800
|
+
if (arg === "off") {
|
|
801
|
+
stopWatch(ctx);
|
|
802
|
+
ctx.ui.notify("queue: live panel off.", "info");
|
|
803
|
+
} else {
|
|
804
|
+
startWatch(ctx);
|
|
805
|
+
ctx.ui.notify("queue: live panel on. /queue watch off hides it.", "info");
|
|
806
|
+
}
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (cmd === "status") {
|
|
810
|
+
const p = runnerPid(cwd) ? "" : problemText(cwd);
|
|
811
|
+
ctx.ui.notify(`${liveLines(cwd).join("\n")}${p ? `\n\nLast problem:\n${p}` : ""}`, "info");
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (cmd === "log" || cmd === "open") {
|
|
815
|
+
const state = readState(cwd);
|
|
816
|
+
const id = (arg ?? "").trim();
|
|
817
|
+
const entries = Object.entries(state.sessions ?? {}).filter(([, x]: any) => x.item === id);
|
|
818
|
+
if (!id) {
|
|
819
|
+
ctx.ui.notify(`Usage: /queue ${cmd} <item id>, e.g. /queue ${cmd} A01`, "warning");
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (cmd === "log") {
|
|
823
|
+
const live = readText(join(cwd, DIR, "live", `${id}.log`)).trim().split("\n").slice(-25).join("\n");
|
|
824
|
+
const note = readText(join(cwd, DIR, "notes", `${id}.md`)).trim();
|
|
825
|
+
ctx.ui.notify(`${id} activity:\n${live || "(nothing yet)"}${note ? `\n\n${note.slice(0, 1500)}` : ""}`, "info");
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const [sid, info]: any = entries.at(-1) ?? [];
|
|
829
|
+
if (!sid) {
|
|
830
|
+
ctx.ui.notify(`queue: no session recorded for ${id} yet.`, "warning");
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (info.deleted) {
|
|
834
|
+
ctx.ui.notify(`queue: ${id}'s session was already cleaned up. Its result is in .pi-queue/notes/${id}.md.`, "info");
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (info.web) {
|
|
838
|
+
ctx.ui.notify(`queue: ${id} runs in Pi Web — open "queue ${id}: …" in the sidebar (it stays live there).`, "info");
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (info.status === "running") {
|
|
842
|
+
ctx.ui.notify(`queue: ${id} is still running in the background; /queue log ${id} shows what it is doing. Open it once it has finished.`, "info");
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const file = sessionFiles(cwd, [sid])[sid];
|
|
846
|
+
if (!file) {
|
|
847
|
+
ctx.ui.notify(`queue: ${id}'s session file was not found.`, "warning");
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
stopWatch(ctx);
|
|
851
|
+
const switched = await ctx.switchSession(file);
|
|
852
|
+
if (switched.cancelled) ctx.ui.notify(`queue: session switch cancelled or unsupported by this host. Open it from the session list, or run pi --session ${JSON.stringify(file)} in a terminal.`, "info");
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (cmd === "problem") {
|
|
856
|
+
ctx.ui.notify(problemText(cwd, 200) || "queue: no recorded problem.", "info");
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
if (["stop", "pause", "recheck", "rerun", "unpark", "parallel", "retry", "next"].includes(cmd)) {
|
|
860
|
+
const ids = (args ?? "").trim().split(/[\s,]+/).filter(Boolean).slice(1);
|
|
861
|
+
ctx.ui.notify(`queue: ${await control(cwd, cmd === "parallel" ? { action: cmd, value: Number(arg) } : { action: cmd, ids })}`, "info");
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
if (cmd !== "start") {
|
|
865
|
+
ctx.ui.notify("Usage: /queue start | stop | status | watch [off] | log <id> | open <id> | retry [id] | recheck [id] | rerun <id> | unpark | next <ids> | parallel <n> | problem", "warning");
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const pid = runnerPid(cwd);
|
|
869
|
+
if (pid) {
|
|
870
|
+
startWatch(ctx);
|
|
871
|
+
ctx.ui.notify(`queue: already running (pid ${pid}). /queue stop to end.`, "info");
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
if (!existsSync(join(cwd, DIR, "config.json")) || !readConfig(cwd).queue) {
|
|
875
|
+
ctx.ui.notify("queue: nothing queued yet. Plan a task with /task <request>; it starts the queue itself.", "warning");
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const err = await startRunner(cwd, true);
|
|
879
|
+
if (!err) startWatch(ctx);
|
|
880
|
+
ctx.ui.notify(err ? `queue: the runner stopped right away.\n${err}` : `queue: started.\n${liveLines(cwd).join("\n")}`, err ? "error" : "info");
|
|
881
|
+
},
|
|
882
|
+
});
|
|
883
|
+
}
|