taskplane 0.0.1 → 0.1.0
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/LICENSE +21 -0
- package/README.md +2 -20
- package/bin/taskplane.mjs +706 -0
- package/dashboard/public/app.js +900 -0
- package/dashboard/public/index.html +92 -0
- package/dashboard/public/style.css +924 -0
- package/dashboard/server.cjs +531 -0
- package/extensions/task-orchestrator.ts +28 -0
- package/extensions/task-runner.ts +1923 -0
- package/extensions/taskplane/abort.ts +466 -0
- package/extensions/taskplane/config.ts +102 -0
- package/extensions/taskplane/discovery.ts +988 -0
- package/extensions/taskplane/engine.ts +758 -0
- package/extensions/taskplane/execution.ts +1752 -0
- package/extensions/taskplane/extension.ts +577 -0
- package/extensions/taskplane/formatting.ts +718 -0
- package/extensions/taskplane/git.ts +38 -0
- package/extensions/taskplane/index.ts +22 -0
- package/extensions/taskplane/merge.ts +795 -0
- package/extensions/taskplane/messages.ts +134 -0
- package/extensions/taskplane/persistence.ts +1121 -0
- package/extensions/taskplane/resume.ts +1092 -0
- package/extensions/taskplane/sessions.ts +92 -0
- package/extensions/taskplane/types.ts +1514 -0
- package/extensions/taskplane/waves.ts +900 -0
- package/extensions/taskplane/worktree.ts +1624 -0
- package/package.json +48 -3
- package/skills/create-taskplane-task/SKILL.md +326 -0
- package/skills/create-taskplane-task/references/context-template.md +78 -0
- package/skills/create-taskplane-task/references/prompt-template.md +246 -0
- package/templates/agents/task-merger.md +256 -0
- package/templates/agents/task-reviewer.md +81 -0
- package/templates/agents/task-worker.md +140 -0
- package/templates/config/task-orchestrator.yaml +89 -0
- package/templates/config/task-runner.yaml +99 -0
- package/templates/tasks/CONTEXT.md +31 -0
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +90 -0
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Orchestrator Web Dashboard — Local HTTP server with SSE live updates.
|
|
4
|
+
*
|
|
5
|
+
* Reads .pi/batch-state.json + STATUS.md files and streams state to the
|
|
6
|
+
* browser via Server-Sent Events. Zero external dependencies.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node dashboard/server.cjs [--port 8099] [--root /path/to/project]
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const http = require("http");
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const { execFileSync, exec } = require("child_process");
|
|
16
|
+
// url module not needed — we parse with new URL() below
|
|
17
|
+
|
|
18
|
+
// ─── Configuration ──────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const PUBLIC_DIR = path.join(__dirname, "public");
|
|
21
|
+
const DEFAULT_PORT = 8099;
|
|
22
|
+
const POLL_INTERVAL = 2000; // ms between state checks
|
|
23
|
+
|
|
24
|
+
// REPO_ROOT is resolved after parseArgs() — see initialization below.
|
|
25
|
+
let REPO_ROOT;
|
|
26
|
+
let BATCH_STATE_PATH;
|
|
27
|
+
|
|
28
|
+
// ─── CLI Args ───────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function parseArgs() {
|
|
31
|
+
const args = process.argv.slice(2);
|
|
32
|
+
const opts = { port: DEFAULT_PORT, open: true, root: "" };
|
|
33
|
+
for (let i = 0; i < args.length; i++) {
|
|
34
|
+
if (args[i] === "--port" && args[i + 1]) {
|
|
35
|
+
opts.port = parseInt(args[i + 1]) || DEFAULT_PORT;
|
|
36
|
+
i++;
|
|
37
|
+
} else if (args[i] === "--root" && args[i + 1]) {
|
|
38
|
+
opts.root = args[i + 1];
|
|
39
|
+
i++;
|
|
40
|
+
} else if (args[i] === "--no-open") {
|
|
41
|
+
opts.open = false;
|
|
42
|
+
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
43
|
+
console.log(`
|
|
44
|
+
Orchestrator Web Dashboard
|
|
45
|
+
|
|
46
|
+
Usage:
|
|
47
|
+
node dashboard/server.cjs [options]
|
|
48
|
+
|
|
49
|
+
Options:
|
|
50
|
+
--port <number> Port to listen on (default: ${DEFAULT_PORT})
|
|
51
|
+
--root <path> Project root directory (default: current directory)
|
|
52
|
+
--no-open Don't auto-open browser
|
|
53
|
+
-h, --help Show this help
|
|
54
|
+
`);
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return opts;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Data Loading (ported from orch-dashboard.cjs) ──────────────────────────
|
|
62
|
+
|
|
63
|
+
function loadBatchState() {
|
|
64
|
+
try {
|
|
65
|
+
const raw = fs.readFileSync(BATCH_STATE_PATH, "utf-8");
|
|
66
|
+
return JSON.parse(raw);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolveTaskFolder(task, state) {
|
|
73
|
+
if (!task || !task.taskFolder) return null;
|
|
74
|
+
const laneNum = task.laneNumber;
|
|
75
|
+
const lane = (state?.lanes || []).find((l) => l.laneNumber === laneNum);
|
|
76
|
+
if (!lane || !lane.worktreePath) return task.taskFolder;
|
|
77
|
+
const taskFolderAbs = path.resolve(task.taskFolder);
|
|
78
|
+
const repoRootAbs = path.resolve(REPO_ROOT);
|
|
79
|
+
const rel = path.relative(repoRootAbs, taskFolderAbs);
|
|
80
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return task.taskFolder;
|
|
81
|
+
return path.join(lane.worktreePath, rel);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseStatusMd(taskFolder) {
|
|
85
|
+
const candidates = [taskFolder];
|
|
86
|
+
const taskId = path.basename(taskFolder);
|
|
87
|
+
const archiveBase = taskFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
|
|
88
|
+
if (archiveBase !== taskFolder) candidates.push(archiveBase);
|
|
89
|
+
|
|
90
|
+
for (const folder of candidates) {
|
|
91
|
+
const statusPath = path.join(folder, "STATUS.md");
|
|
92
|
+
try {
|
|
93
|
+
const content = fs.readFileSync(statusPath, "utf-8");
|
|
94
|
+
const stepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
|
|
95
|
+
const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/);
|
|
96
|
+
const iterMatch = content.match(/\*\*Iteration:\*\*\s*(\d+)/);
|
|
97
|
+
const reviewMatch = content.match(/\*\*Review Counter:\*\*\s*(\d+)/);
|
|
98
|
+
const checked = (content.match(/- \[x\]/gi) || []).length;
|
|
99
|
+
const unchecked = (content.match(/- \[ \]/g) || []).length;
|
|
100
|
+
const total = checked + unchecked;
|
|
101
|
+
return {
|
|
102
|
+
currentStep: stepMatch ? stepMatch[1].trim() : "Unknown",
|
|
103
|
+
status: statusMatch ? statusMatch[1].trim() : "Unknown",
|
|
104
|
+
iteration: iterMatch ? parseInt(iterMatch[1]) : 0,
|
|
105
|
+
reviews: reviewMatch ? parseInt(reviewMatch[1]) : 0,
|
|
106
|
+
checked,
|
|
107
|
+
total,
|
|
108
|
+
progress: total > 0 ? Math.round((checked / total) * 100) : 0,
|
|
109
|
+
};
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getTmuxSessions() {
|
|
118
|
+
try {
|
|
119
|
+
const output = execFileSync('tmux list-sessions -F "#{session_name}"', {
|
|
120
|
+
encoding: "utf-8",
|
|
121
|
+
timeout: 5000,
|
|
122
|
+
shell: true,
|
|
123
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
124
|
+
}).trim();
|
|
125
|
+
return output ? output.split("\n").map((s) => s.trim()).filter(Boolean) : [];
|
|
126
|
+
} catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function checkDoneFile(taskFolder) {
|
|
132
|
+
const candidates = [taskFolder];
|
|
133
|
+
const taskId = path.basename(taskFolder);
|
|
134
|
+
const archiveBase = taskFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
|
|
135
|
+
if (archiveBase !== taskFolder) candidates.push(archiveBase);
|
|
136
|
+
for (const folder of candidates) {
|
|
137
|
+
if (fs.existsSync(path.join(folder, ".DONE"))) return true;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Read lane state sidecar JSON files written by the task-runner. */
|
|
143
|
+
function loadLaneStates() {
|
|
144
|
+
const piDir = path.join(REPO_ROOT, ".pi");
|
|
145
|
+
const states = {};
|
|
146
|
+
try {
|
|
147
|
+
const files = fs.readdirSync(piDir).filter(f => f.startsWith("lane-state-") && f.endsWith(".json"));
|
|
148
|
+
for (const file of files) {
|
|
149
|
+
try {
|
|
150
|
+
const raw = fs.readFileSync(path.join(piDir, file), "utf-8").trim();
|
|
151
|
+
if (!raw) continue;
|
|
152
|
+
const data = JSON.parse(raw);
|
|
153
|
+
if (data.prefix) states[data.prefix] = data;
|
|
154
|
+
} catch { continue; }
|
|
155
|
+
}
|
|
156
|
+
} catch { /* .pi dir may not exist */ }
|
|
157
|
+
return states;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Build full dashboard state object for the frontend. */
|
|
161
|
+
function buildDashboardState() {
|
|
162
|
+
const state = loadBatchState();
|
|
163
|
+
const tmuxSessions = getTmuxSessions();
|
|
164
|
+
const laneStates = loadLaneStates();
|
|
165
|
+
|
|
166
|
+
if (!state) {
|
|
167
|
+
return { batch: null, tmuxSessions, laneStates: {}, timestamp: Date.now() };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const tasks = (state.tasks || []).map((task) => {
|
|
171
|
+
const effectiveFolder = resolveTaskFolder(task, state);
|
|
172
|
+
let statusData = null;
|
|
173
|
+
if (effectiveFolder) {
|
|
174
|
+
statusData = parseStatusMd(effectiveFolder);
|
|
175
|
+
}
|
|
176
|
+
if (!task.doneFileFound && effectiveFolder) {
|
|
177
|
+
task.doneFileFound = checkDoneFile(effectiveFolder);
|
|
178
|
+
}
|
|
179
|
+
return { ...task, statusData };
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
laneStates,
|
|
184
|
+
batch: {
|
|
185
|
+
batchId: state.batchId,
|
|
186
|
+
phase: state.phase,
|
|
187
|
+
startedAt: state.startedAt,
|
|
188
|
+
updatedAt: state.updatedAt,
|
|
189
|
+
currentWaveIndex: state.currentWaveIndex || 0,
|
|
190
|
+
totalWaves: state.totalWaves || (state.wavePlan ? state.wavePlan.length : 0),
|
|
191
|
+
wavePlan: state.wavePlan || [],
|
|
192
|
+
lanes: state.lanes || [],
|
|
193
|
+
tasks,
|
|
194
|
+
mergeResults: state.mergeResults || [],
|
|
195
|
+
errors: state.errors || [],
|
|
196
|
+
lastError: state.lastError || null,
|
|
197
|
+
},
|
|
198
|
+
tmuxSessions,
|
|
199
|
+
timestamp: Date.now(),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── Static File Serving ────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
const MIME_TYPES = {
|
|
206
|
+
".html": "text/html; charset=utf-8",
|
|
207
|
+
".css": "text/css; charset=utf-8",
|
|
208
|
+
".js": "application/javascript; charset=utf-8",
|
|
209
|
+
".json": "application/json; charset=utf-8",
|
|
210
|
+
".svg": "image/svg+xml",
|
|
211
|
+
".png": "image/png",
|
|
212
|
+
".ico": "image/x-icon",
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
function serveStatic(req, res) {
|
|
216
|
+
let filePath = new URL(req.url, "http://localhost").pathname;
|
|
217
|
+
if (filePath === "/") filePath = "/index.html";
|
|
218
|
+
|
|
219
|
+
const fullPath = path.join(PUBLIC_DIR, filePath);
|
|
220
|
+
// Prevent directory traversal
|
|
221
|
+
if (!fullPath.startsWith(PUBLIC_DIR)) {
|
|
222
|
+
res.writeHead(403);
|
|
223
|
+
res.end("Forbidden");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const ext = path.extname(fullPath);
|
|
228
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const content = fs.readFileSync(fullPath);
|
|
232
|
+
res.writeHead(200, {
|
|
233
|
+
"Content-Type": contentType,
|
|
234
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
235
|
+
});
|
|
236
|
+
res.end(content);
|
|
237
|
+
} catch {
|
|
238
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
239
|
+
res.end("Not Found");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─── SSE Stream ─────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
const sseClients = new Set();
|
|
246
|
+
|
|
247
|
+
// ─── Pane Capture SSE ───────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
const paneClients = new Map(); // sessionName → Set<res>
|
|
250
|
+
|
|
251
|
+
function handlePaneSSE(req, res, sessionName) {
|
|
252
|
+
// Validate session name (alphanumeric, dashes, underscores only)
|
|
253
|
+
if (!/^[\w-]+$/.test(sessionName)) {
|
|
254
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
255
|
+
res.end("Invalid session name");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
res.writeHead(200, {
|
|
260
|
+
"Content-Type": "text/event-stream",
|
|
261
|
+
"Cache-Control": "no-cache",
|
|
262
|
+
Connection: "keep-alive",
|
|
263
|
+
"Access-Control-Allow-Origin": "*",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (!paneClients.has(sessionName)) {
|
|
267
|
+
paneClients.set(sessionName, new Set());
|
|
268
|
+
}
|
|
269
|
+
paneClients.get(sessionName).add(res);
|
|
270
|
+
|
|
271
|
+
// Send initial capture immediately
|
|
272
|
+
const initial = captureTmuxPane(sessionName);
|
|
273
|
+
if (initial !== null) {
|
|
274
|
+
res.write(`data: ${JSON.stringify({ output: initial, session: sessionName })}\n\n`);
|
|
275
|
+
} else {
|
|
276
|
+
res.write(`data: ${JSON.stringify({ error: "Session not found or not accessible", session: sessionName })}\n\n`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
req.on("close", () => {
|
|
280
|
+
const clients = paneClients.get(sessionName);
|
|
281
|
+
if (clients) {
|
|
282
|
+
clients.delete(res);
|
|
283
|
+
if (clients.size === 0) paneClients.delete(sessionName);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function captureTmuxPane(sessionName) {
|
|
289
|
+
try {
|
|
290
|
+
// Capture with ANSI escape sequences (-e), full scrollback visible area (-p)
|
|
291
|
+
const output = execFileSync(
|
|
292
|
+
`tmux capture-pane -t "${sessionName}" -p -e`,
|
|
293
|
+
{ encoding: "utf-8", timeout: 3000, shell: true, stdio: ["ignore", "pipe", "ignore"] }
|
|
294
|
+
);
|
|
295
|
+
return output;
|
|
296
|
+
} catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function broadcastPaneCaptures() {
|
|
302
|
+
for (const [sessionName, clients] of paneClients) {
|
|
303
|
+
if (clients.size === 0) continue;
|
|
304
|
+
const output = captureTmuxPane(sessionName);
|
|
305
|
+
if (output === null) continue;
|
|
306
|
+
const payload = `data: ${JSON.stringify({ output, session: sessionName })}\n\n`;
|
|
307
|
+
for (const client of clients) {
|
|
308
|
+
try {
|
|
309
|
+
client.write(payload);
|
|
310
|
+
} catch {
|
|
311
|
+
clients.delete(client);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ─── Conversation JSONL ─────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
function serveConversation(req, res, prefix) {
|
|
320
|
+
if (!/^[\w-]+$/.test(prefix)) {
|
|
321
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
322
|
+
res.end("Invalid prefix");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const filePath = path.join(REPO_ROOT, ".pi", `worker-conversation-${prefix}.jsonl`);
|
|
327
|
+
try {
|
|
328
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
329
|
+
res.writeHead(200, {
|
|
330
|
+
"Content-Type": "application/x-ndjson",
|
|
331
|
+
"Access-Control-Allow-Origin": "*",
|
|
332
|
+
});
|
|
333
|
+
res.end(content);
|
|
334
|
+
} catch {
|
|
335
|
+
res.writeHead(200, {
|
|
336
|
+
"Content-Type": "application/x-ndjson",
|
|
337
|
+
"Access-Control-Allow-Origin": "*",
|
|
338
|
+
});
|
|
339
|
+
res.end(""); // empty — no conversation yet
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─── Dashboard SSE ──────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
function handleSSE(req, res) {
|
|
346
|
+
res.writeHead(200, {
|
|
347
|
+
"Content-Type": "text/event-stream",
|
|
348
|
+
"Cache-Control": "no-cache",
|
|
349
|
+
Connection: "keep-alive",
|
|
350
|
+
"Access-Control-Allow-Origin": "*",
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Send initial state immediately
|
|
354
|
+
const state = buildDashboardState();
|
|
355
|
+
res.write(`data: ${JSON.stringify(state)}\n\n`);
|
|
356
|
+
|
|
357
|
+
sseClients.add(res);
|
|
358
|
+
req.on("close", () => sseClients.delete(res));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function broadcastState() {
|
|
362
|
+
if (sseClients.size === 0) return;
|
|
363
|
+
const state = buildDashboardState();
|
|
364
|
+
const payload = `data: ${JSON.stringify(state)}\n\n`;
|
|
365
|
+
for (const client of sseClients) {
|
|
366
|
+
try {
|
|
367
|
+
client.write(payload);
|
|
368
|
+
} catch {
|
|
369
|
+
sseClients.delete(client);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ─── Batch History API ──────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
const BATCH_HISTORY_PATH = path.join(REPO_ROOT, ".pi", "batch-history.json");
|
|
377
|
+
|
|
378
|
+
function loadHistory() {
|
|
379
|
+
try {
|
|
380
|
+
if (!fs.existsSync(BATCH_HISTORY_PATH)) return [];
|
|
381
|
+
const raw = fs.readFileSync(BATCH_HISTORY_PATH, "utf-8");
|
|
382
|
+
return JSON.parse(raw);
|
|
383
|
+
} catch {
|
|
384
|
+
return [];
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** GET /api/history — return list of batch summaries (compact: no per-task detail). */
|
|
389
|
+
function serveHistory(req, res) {
|
|
390
|
+
const history = loadHistory();
|
|
391
|
+
// Return compact list for the dropdown (no per-task details)
|
|
392
|
+
const compact = history.map(h => ({
|
|
393
|
+
batchId: h.batchId,
|
|
394
|
+
status: h.status,
|
|
395
|
+
startedAt: h.startedAt,
|
|
396
|
+
endedAt: h.endedAt,
|
|
397
|
+
durationMs: h.durationMs,
|
|
398
|
+
totalWaves: h.totalWaves,
|
|
399
|
+
totalTasks: h.totalTasks,
|
|
400
|
+
succeededTasks: h.succeededTasks,
|
|
401
|
+
failedTasks: h.failedTasks,
|
|
402
|
+
tokens: h.tokens,
|
|
403
|
+
}));
|
|
404
|
+
res.writeHead(200, {
|
|
405
|
+
"Content-Type": "application/json",
|
|
406
|
+
"Access-Control-Allow-Origin": "*",
|
|
407
|
+
});
|
|
408
|
+
res.end(JSON.stringify(compact));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** GET /api/history/:batchId — return full detail for one batch. */
|
|
412
|
+
function serveHistoryEntry(req, res, batchId) {
|
|
413
|
+
const history = loadHistory();
|
|
414
|
+
const entry = history.find(h => h.batchId === batchId);
|
|
415
|
+
if (!entry) {
|
|
416
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
417
|
+
res.end(JSON.stringify({ error: "Batch not found" }));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
res.writeHead(200, {
|
|
421
|
+
"Content-Type": "application/json",
|
|
422
|
+
"Access-Control-Allow-Origin": "*",
|
|
423
|
+
});
|
|
424
|
+
res.end(JSON.stringify(entry));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ─── HTTP Server ────────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
function createServer(port) {
|
|
430
|
+
const server = http.createServer((req, res) => {
|
|
431
|
+
const pathname = new URL(req.url, "http://localhost").pathname;
|
|
432
|
+
|
|
433
|
+
if (pathname === "/api/stream" && req.method === "GET") {
|
|
434
|
+
handleSSE(req, res);
|
|
435
|
+
} else if (pathname.startsWith("/api/pane/") && req.method === "GET") {
|
|
436
|
+
const sessionName = pathname.slice("/api/pane/".length);
|
|
437
|
+
handlePaneSSE(req, res, sessionName);
|
|
438
|
+
} else if (pathname.startsWith("/api/conversation/") && req.method === "GET") {
|
|
439
|
+
const prefix = pathname.slice("/api/conversation/".length);
|
|
440
|
+
serveConversation(req, res, prefix);
|
|
441
|
+
} else if (pathname === "/api/state" && req.method === "GET") {
|
|
442
|
+
const state = buildDashboardState();
|
|
443
|
+
res.writeHead(200, {
|
|
444
|
+
"Content-Type": "application/json",
|
|
445
|
+
"Access-Control-Allow-Origin": "*",
|
|
446
|
+
});
|
|
447
|
+
res.end(JSON.stringify(state));
|
|
448
|
+
} else if (pathname === "/api/history" && req.method === "GET") {
|
|
449
|
+
serveHistory(req, res);
|
|
450
|
+
} else if (pathname.startsWith("/api/history/") && req.method === "GET") {
|
|
451
|
+
const batchId = decodeURIComponent(pathname.slice("/api/history/".length));
|
|
452
|
+
serveHistoryEntry(req, res, batchId);
|
|
453
|
+
} else {
|
|
454
|
+
serveStatic(req, res);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
server.listen(port, () => {
|
|
459
|
+
console.log(`\n Orchestrator Dashboard → http://localhost:${port}\n`);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
return server;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ─── Browser Auto-Open ─────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
function openBrowser(url) {
|
|
468
|
+
const cmd = process.platform === "win32" ? "start"
|
|
469
|
+
: process.platform === "darwin" ? "open" : "xdg-open";
|
|
470
|
+
exec(`${cmd} ${url}`, () => {}); // fire-and-forget
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
function main() {
|
|
476
|
+
const opts = parseArgs();
|
|
477
|
+
|
|
478
|
+
// Resolve project root: --root flag > cwd
|
|
479
|
+
REPO_ROOT = path.resolve(opts.root || process.cwd());
|
|
480
|
+
BATCH_STATE_PATH = path.join(REPO_ROOT, ".pi", "batch-state.json");
|
|
481
|
+
|
|
482
|
+
const server = createServer(opts.port);
|
|
483
|
+
|
|
484
|
+
// Broadcast state to all SSE clients on interval
|
|
485
|
+
const pollTimer = setInterval(broadcastState, POLL_INTERVAL);
|
|
486
|
+
|
|
487
|
+
// Broadcast pane captures more frequently (1s) for smooth terminal viewing
|
|
488
|
+
const paneTimer = setInterval(broadcastPaneCaptures, 1000);
|
|
489
|
+
|
|
490
|
+
// Also watch batch-state.json for immediate push on change
|
|
491
|
+
try {
|
|
492
|
+
const batchDir = path.dirname(BATCH_STATE_PATH);
|
|
493
|
+
if (fs.existsSync(batchDir)) {
|
|
494
|
+
let debounce = null;
|
|
495
|
+
fs.watch(batchDir, (eventType, filename) => {
|
|
496
|
+
if (filename === "batch-state.json") {
|
|
497
|
+
clearTimeout(debounce);
|
|
498
|
+
debounce = setTimeout(broadcastState, 200);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
} catch {
|
|
503
|
+
// fs.watch not supported — polling is sufficient
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Auto-open browser
|
|
507
|
+
if (opts.open) {
|
|
508
|
+
setTimeout(() => openBrowser(`http://localhost:${opts.port}`), 500);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Graceful shutdown
|
|
512
|
+
function cleanup() {
|
|
513
|
+
clearInterval(pollTimer);
|
|
514
|
+
clearInterval(paneTimer);
|
|
515
|
+
for (const [, clients] of paneClients) {
|
|
516
|
+
for (const client of clients) {
|
|
517
|
+
try { client.end(); } catch {}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
for (const client of sseClients) {
|
|
521
|
+
try { client.end(); } catch {}
|
|
522
|
+
}
|
|
523
|
+
server.close();
|
|
524
|
+
process.exit(0);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
process.on("SIGINT", cleanup);
|
|
528
|
+
process.on("SIGTERM", cleanup);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
main();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Orchestrator — Parallel task execution with git worktrees
|
|
3
|
+
*
|
|
4
|
+
* This is a thin facade that re-exports everything from the taskplane/ modules.
|
|
5
|
+
* The actual implementation lives in extensions/taskplane/*.ts.
|
|
6
|
+
*
|
|
7
|
+
* Commands:
|
|
8
|
+
* /orch <areas|paths|all> — Start batch execution
|
|
9
|
+
* /orch-plan <areas|paths|all> — Preview execution plan (no execution)
|
|
10
|
+
* /orch-status — Show current batch progress
|
|
11
|
+
* /orch-pause — Pause after current tasks finish
|
|
12
|
+
* /orch-resume — Resume a paused batch
|
|
13
|
+
* /orch-abort [--hard] — Abort batch (graceful or immediate)
|
|
14
|
+
* /orch-deps <areas|paths|all> — Show dependency graph
|
|
15
|
+
* /orch-sessions — List active TMUX sessions
|
|
16
|
+
*
|
|
17
|
+
* Configuration:
|
|
18
|
+
* .pi/task-orchestrator.yaml — orchestrator-specific settings
|
|
19
|
+
* .pi/task-runner.yaml — task areas, worker/reviewer config (shared)
|
|
20
|
+
*
|
|
21
|
+
* Usage: pi -e extensions/task-orchestrator.ts -e extensions/task-runner.ts
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Re-export all named exports for tests and other consumers
|
|
25
|
+
export * from "./taskplane/index.ts";
|
|
26
|
+
|
|
27
|
+
// Re-export the default activate function for the pi extension system
|
|
28
|
+
export { default } from "./taskplane/extension.ts";
|