taskplane 0.2.2 → 0.2.3

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.
@@ -1,1894 +1,1899 @@
1
- /**
2
- * Task Runner — Autonomous task execution with live dashboard
3
- *
4
- * Replaces the Ralph Wiggum bash loop with a Pi extension. Workers are
5
- * fresh-context subprocesses; STATUS.md is persistent memory. Supports
6
- * cross-model review (reviewer uses a different model than the worker).
7
- *
8
- * Commands:
9
- * /task <path/to/PROMPT.md> — Start executing a task
10
- * /task-status — Re-read and display STATUS.md progress
11
- * /task-pause — Pause after current worker finishes
12
- * /task-resume — Resume a paused task
13
- *
14
- * Configuration: .pi/task-runner.yaml (project-specific settings)
15
- * Agents: .pi/agents/task-worker.md, .pi/agents/task-reviewer.md
16
- *
17
- * Usage: pi -e extensions/task-runner.ts
18
- */
19
-
20
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
21
- import { DynamicBorder } from "@mariozechner/pi-coding-agent";
22
- import { Container, Text, truncateToWidth } from "@mariozechner/pi-tui";
23
- import { spawn, spawnSync } from "child_process";
24
- import {
25
- readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, unlinkSync,
26
- } from "fs";
27
- import { tmpdir } from "os";
28
- import { join, dirname, basename, resolve } from "path";
29
- import { parse as yamlParse } from "yaml";
30
-
31
-
32
- // ── Types ────────────────────────────────────────────────────────────
33
-
34
- interface TaskConfig {
35
- project: { name: string; description: string };
36
- paths: { tasks: string; architecture?: string };
37
- testing: { commands: Record<string, string> };
38
- standards: { docs: string[]; rules: string[] };
39
- standards_overrides: Record<string, { docs?: string[]; rules?: string[] }>;
40
- task_areas: Record<string, { path: string; [key: string]: any }>;
41
- worker: {
42
- model: string;
43
- tools: string;
44
- thinking: string;
45
- spawn_mode?: "subprocess" | "tmux";
46
- };
47
- reviewer: { model: string; tools: string; thinking: string };
48
- context: {
49
- worker_context_window: number;
50
- warn_percent: number;
51
- kill_percent: number;
52
- max_worker_iterations: number;
53
- max_review_cycles: number;
54
- no_progress_limit: number;
55
- max_worker_minutes?: number;
56
- };
57
- }
58
-
59
- interface StepInfo {
60
- number: number;
61
- name: string;
62
- status: "not-started" | "in-progress" | "complete";
63
- checkboxes: { text: string; checked: boolean }[];
64
- totalChecked: number;
65
- totalItems: number;
66
- }
67
-
68
- interface ParsedTask {
69
- taskId: string;
70
- taskName: string;
71
- reviewLevel: number;
72
- size: string;
73
- steps: StepInfo[];
74
- contextDocs: string[];
75
- taskFolder: string;
76
- promptPath: string;
77
- }
78
-
79
- type TaskPhase = "idle" | "running" | "paused" | "complete" | "error";
80
-
81
- interface TaskState {
82
- phase: TaskPhase;
83
- task: ParsedTask | null;
84
- config: TaskConfig | null;
85
- currentStep: number;
86
- workerIteration: number;
87
- workerStatus: "idle" | "running" | "done" | "error" | "killed";
88
- workerElapsed: number;
89
- workerContextPct: number;
90
- workerLastTool: string;
91
- workerToolCount: number;
92
- workerInputTokens: number;
93
- workerOutputTokens: number;
94
- workerCacheReadTokens: number;
95
- workerCacheWriteTokens: number;
96
- workerCostUsd: number;
97
- workerProc: any;
98
- workerTimer: any;
99
- reviewerStatus: "idle" | "running" | "done" | "error";
100
- reviewerType: string;
101
- reviewerElapsed: number;
102
- reviewerLastTool: string;
103
- reviewerProc: any;
104
- reviewerTimer: any;
105
- reviewCounter: number;
106
- totalIterations: number;
107
- stepStatuses: Map<number, StepInfo>;
108
- }
109
-
110
- function freshState(): TaskState {
111
- return {
112
- phase: "idle", task: null, config: null, currentStep: 0,
113
- workerIteration: 0, workerStatus: "idle", workerElapsed: 0,
114
- workerContextPct: 0, workerLastTool: "", workerToolCount: 0,
115
- workerInputTokens: 0, workerOutputTokens: 0, workerCacheReadTokens: 0, workerCacheWriteTokens: 0, workerCostUsd: 0,
116
- workerProc: null, workerTimer: null,
117
- reviewerStatus: "idle", reviewerType: "", reviewerElapsed: 0,
118
- reviewerLastTool: "", reviewerProc: null, reviewerTimer: null,
119
- reviewCounter: 0, totalIterations: 0, stepStatuses: new Map(),
120
- };
121
- }
122
-
123
- // ── Config ───────────────────────────────────────────────────────────
124
-
125
- const DEFAULT_CONFIG: TaskConfig = {
126
- project: { name: "Project", description: "" },
127
- paths: { tasks: "docs/task-management" },
128
- testing: { commands: {} },
129
- standards: { docs: [], rules: [] },
130
- standards_overrides: {},
131
- task_areas: {},
132
- worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "off" },
133
- reviewer: { model: "openai/gpt-5.3-codex", tools: "read,bash,grep,find,ls", thinking: "on" },
134
- context: {
135
- worker_context_window: 200000, warn_percent: 70, kill_percent: 85,
136
- max_worker_iterations: 20, max_review_cycles: 2, no_progress_limit: 3,
137
- },
138
- };
139
-
140
- function loadConfig(cwd: string): TaskConfig {
141
- const configPath = join(cwd, ".pi", "task-runner.yaml");
142
- if (!existsSync(configPath)) return { ...DEFAULT_CONFIG };
143
- try {
144
- const raw = readFileSync(configPath, "utf-8");
145
- const loaded = yamlParse(raw) as any;
146
- // Parse standards_overrides: Record<areaName, { docs?, rules? }>
147
- const rawOverrides = loaded?.standards_overrides || {};
148
- const parsedOverrides: Record<string, { docs?: string[]; rules?: string[] }> = {};
149
- for (const [key, val] of Object.entries(rawOverrides)) {
150
- if (val && typeof val === "object") {
151
- const v = val as any;
152
- parsedOverrides[key] = {
153
- docs: Array.isArray(v.docs) ? v.docs : undefined,
154
- rules: Array.isArray(v.rules) ? v.rules : undefined,
155
- };
156
- }
157
- }
158
-
159
- // Parse task_areas minimally (we only need path for standards resolution)
160
- const rawAreas = loaded?.task_areas || {};
161
- const parsedAreas: Record<string, { path: string }> = {};
162
- for (const [key, val] of Object.entries(rawAreas)) {
163
- if (val && typeof val === "object" && (val as any).path) {
164
- parsedAreas[key] = { path: (val as any).path };
165
- }
166
- }
167
-
168
- return {
169
- project: { ...DEFAULT_CONFIG.project, ...loaded?.project },
170
- paths: { ...DEFAULT_CONFIG.paths, ...loaded?.paths },
171
- testing: { commands: { ...DEFAULT_CONFIG.testing.commands, ...loaded?.testing?.commands } },
172
- standards: {
173
- docs: loaded?.standards?.docs || DEFAULT_CONFIG.standards.docs,
174
- rules: loaded?.standards?.rules || DEFAULT_CONFIG.standards.rules,
175
- },
176
- standards_overrides: parsedOverrides,
177
- task_areas: parsedAreas,
178
- worker: { ...DEFAULT_CONFIG.worker, ...loaded?.worker },
179
- reviewer: { ...DEFAULT_CONFIG.reviewer, ...loaded?.reviewer },
180
- context: { ...DEFAULT_CONFIG.context, ...loaded?.context },
181
- };
182
- } catch {
183
- return { ...DEFAULT_CONFIG };
184
- }
185
- }
186
-
187
- // ── Spawn Mode Resolution ────────────────────────────────────────────
188
-
189
- /**
190
- * Determines whether workers/reviewers spawn as headless subprocesses
191
- * (existing behavior) or as TMUX sessions (parallel orchestrator mode).
192
- *
193
- * Resolution order: env var → config → default "subprocess".
194
- * The orchestrator sets TASK_RUNNER_SPAWN_MODE=tmux per-lane.
195
- */
196
- function getSpawnMode(config: TaskConfig): "subprocess" | "tmux" {
197
- const envMode = process.env.TASK_RUNNER_SPAWN_MODE;
198
- if (envMode === "tmux" || envMode === "subprocess") return envMode;
199
- if (config.worker.spawn_mode === "tmux" || config.worker.spawn_mode === "subprocess") {
200
- return config.worker.spawn_mode;
201
- }
202
- return "subprocess";
203
- }
204
-
205
- /**
206
- * Returns the TMUX session name prefix for worker/reviewer sessions.
207
- * The orchestrator sets TASK_RUNNER_TMUX_PREFIX per-lane (e.g., "orch-lane-1").
208
- * Worker sessions become "{prefix}-worker", reviewer sessions "{prefix}-reviewer".
209
- */
210
- function getTmuxPrefix(): string {
211
- return process.env.TASK_RUNNER_TMUX_PREFIX || "task";
212
- }
213
-
214
- /**
215
- * Detects whether this task runner is executing inside the parallel orchestrator.
216
- *
217
- * TASK_RUNNER_TMUX_PREFIX is only ever set by the orchestrator (via execution.ts
218
- * buildLaneEnv). Its presence — regardless of value — indicates orchestrated mode.
219
- * The prefix can be any user-configured value (e.g., "orch-lane-1", "penster-lane-1").
220
- *
221
- * When true, certain worker behaviors are suppressed — most notably, workers
222
- * must NOT archive task folders because the orchestrator polls for .DONE files
223
- * at the original path.
224
- */
225
- function isOrchestratedMode(): boolean {
226
- return !!process.env.TASK_RUNNER_TMUX_PREFIX;
227
- }
228
-
229
- /**
230
- * Returns the wall-clock timeout for TMUX worker sessions in minutes.
231
- * Used instead of context-% based kill (no JSON stream in TMUX mode).
232
- *
233
- * Resolution order: env var → config → default 30 minutes.
234
- * Reviewers do NOT use this timeout — they run to session completion.
235
- */
236
- function getMaxWorkerMinutes(config: TaskConfig): number {
237
- const envVal = process.env.TASK_RUNNER_MAX_WORKER_MINUTES;
238
- if (envVal) {
239
- const parsed = parseInt(envVal, 10);
240
- if (!isNaN(parsed) && parsed > 0) return parsed;
241
- }
242
- const configVal = config.context.max_worker_minutes;
243
- if (typeof configVal === "number" && configVal > 0) return configVal;
244
- return 30;
245
- }
246
-
247
- // ── Orchestrator Sidecar Files ────────────────────────────────────────
248
-
249
- /**
250
- * Returns the .pi directory path for sidecar files (lane state, conversation logs).
251
- * In orchestrated mode, the orchestrator passes ORCH_SIDECAR_DIR pointing to the
252
- * MAIN repo's .pi/ directory (not the worktree's).
253
- */
254
- function getSidecarDir(): string {
255
- // Orchestrator provides the main repo .pi path
256
- const orchDir = process.env.ORCH_SIDECAR_DIR;
257
- if (orchDir) {
258
- if (!existsSync(orchDir)) mkdirSync(orchDir, { recursive: true });
259
- return orchDir;
260
- }
261
- // Fallback: walk up from cwd
262
- let dir = process.cwd();
263
- for (let i = 0; i < 10; i++) {
264
- const piDir = join(dir, ".pi");
265
- if (existsSync(piDir)) return piDir;
266
- const parent = dirname(dir);
267
- if (parent === dir) break;
268
- dir = parent;
269
- }
270
- const piDir = join(process.cwd(), ".pi");
271
- if (!existsSync(piDir)) mkdirSync(piDir, { recursive: true });
272
- return piDir;
273
- }
274
-
275
- /**
276
- * Write lane state sidecar JSON for the web dashboard.
277
- * Written every second when in orchestrated mode.
278
- */
279
- function writeLaneState(state: TaskState): void {
280
- if (!isOrchestratedMode()) return;
281
- const prefix = getTmuxPrefix(); // e.g., "orch-lane-1"
282
- const filePath = join(getSidecarDir(), `lane-state-${prefix}.json`);
283
- try {
284
- const data = {
285
- prefix,
286
- taskId: state.task?.taskId || null,
287
- phase: state.phase,
288
- currentStep: state.currentStep,
289
- totalIterations: state.totalIterations,
290
- workerIteration: state.workerIteration,
291
- workerStatus: state.workerStatus,
292
- workerElapsed: state.workerElapsed,
293
- workerContextPct: state.workerContextPct,
294
- workerLastTool: state.workerLastTool,
295
- workerToolCount: state.workerToolCount,
296
- workerInputTokens: state.workerInputTokens,
297
- workerOutputTokens: state.workerOutputTokens,
298
- workerCacheReadTokens: state.workerCacheReadTokens,
299
- workerCacheWriteTokens: state.workerCacheWriteTokens,
300
- workerCostUsd: state.workerCostUsd,
301
- reviewerStatus: state.reviewerStatus || "idle",
302
- timestamp: Date.now(),
303
- };
304
- writeFileSync(filePath, JSON.stringify(data) + "\n");
305
- } catch {
306
- // Best effort — don't crash the runner
307
- }
308
- }
309
-
310
- /**
311
- * Append a JSON event to the conversation JSONL log file.
312
- * Used in orchestrated mode to capture the full worker conversation for the web dashboard.
313
- */
314
- function appendConversationEvent(prefix: string, event: Record<string, unknown>): void {
315
- const filePath = join(getSidecarDir(), `worker-conversation-${prefix}.jsonl`);
316
- try {
317
- appendFileSync(filePath, JSON.stringify(event) + "\n");
318
- } catch {
319
- // Best effort
320
- }
321
- }
322
-
323
- /**
324
- * Clear the conversation log at the start of a new worker iteration.
325
- */
326
- function clearConversationLog(prefix: string): void {
327
- const filePath = join(getSidecarDir(), `worker-conversation-${prefix}.jsonl`);
328
- try {
329
- writeFileSync(filePath, "");
330
- } catch {
331
- // Best effort
332
- }
333
- }
334
-
335
- // ── Agent Loader ─────────────────────────────────────────────────────
336
-
337
- function loadAgentDef(cwd: string, name: string): { systemPrompt: string; tools: string; model: string } | null {
338
- const paths = [join(cwd, ".pi", "agents", `${name}.md`), join(cwd, "agents", `${name}.md`)];
339
- for (const p of paths) {
340
- if (!existsSync(p)) continue;
341
- const raw = readFileSync(p, "utf-8").replace(/\r\n/g, "\n");
342
- const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
343
- if (!match) continue;
344
- const fm: Record<string, string> = {};
345
- for (const line of match[1].split("\n")) {
346
- const idx = line.indexOf(":");
347
- if (idx > 0) fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
348
- }
349
- return { systemPrompt: match[2].trim(), tools: fm.tools || "read,grep,find,ls", model: fm.model || "" };
350
- }
351
- return null;
352
- }
353
-
354
- // ── PROMPT.md Parser ─────────────────────────────────────────────────
355
-
356
- function parsePromptMd(content: string, promptPath: string): ParsedTask {
357
- const text = content.replace(/\r\n/g, "\n");
358
- const taskFolder = dirname(resolve(promptPath));
359
-
360
- // Task ID and name
361
- let taskId = "", taskName = "";
362
- const titleMatch = text.match(/^#\s+(?:Task:\s*)?(\S+-\d+)\s*[-–:]\s*(.+)/m);
363
- if (titleMatch) { taskId = titleMatch[1]; taskName = titleMatch[2].trim(); }
364
- else { taskId = basename(taskFolder); taskName = taskId; }
365
-
366
- // Review level
367
- let reviewLevel = 0;
368
- const rlMatch = text.match(/##\s+Review Level[:\s]*(\d)/);
369
- if (rlMatch) reviewLevel = parseInt(rlMatch[1]);
370
-
371
- // Size
372
- let size = "M";
373
- const sizeMatch = text.match(/\*\*Size:\*\*\s*(\w+)/);
374
- if (sizeMatch) size = sizeMatch[1];
375
-
376
- // Steps
377
- const steps: StepInfo[] = [];
378
- const stepRegex = /###\s+Step\s+(\d+):\s*(.+)/g;
379
- const positions: { number: number; name: string; start: number }[] = [];
380
- let m;
381
- while ((m = stepRegex.exec(text)) !== null) {
382
- positions.push({ number: parseInt(m[1]), name: m[2].trim(), start: m.index });
383
- }
384
- for (let i = 0; i < positions.length; i++) {
385
- const section = text.slice(positions[i].start, i + 1 < positions.length ? positions[i + 1].start : text.length);
386
- const checkboxes: { text: string; checked: boolean }[] = [];
387
- const cbRegex = /^\s*-\s*\[([ xX])\]\s*(.*)/gm;
388
- let cb;
389
- while ((cb = cbRegex.exec(section)) !== null) {
390
- checkboxes.push({ text: cb[2].trim(), checked: cb[1].toLowerCase() === "x" });
391
- }
392
- steps.push({
393
- number: positions[i].number, name: positions[i].name,
394
- status: "not-started", checkboxes,
395
- totalChecked: checkboxes.filter(c => c.checked).length,
396
- totalItems: checkboxes.length,
397
- });
398
- }
399
-
400
- // Context docs
401
- const contextDocs: string[] = [];
402
- const ctxMatch = text.match(/##\s+Context to Read First\s*\n+([\s\S]*?)(?=\n##\s|$)/);
403
- if (ctxMatch) {
404
- const pathRegex = /`([^\s`]+\.(?:md|yaml|json|go|ts|js))`/g;
405
- let pm;
406
- while ((pm = pathRegex.exec(ctxMatch[1])) !== null) contextDocs.push(pm[1]);
407
- }
408
-
409
- return { taskId, taskName, reviewLevel, size, steps, contextDocs, taskFolder, promptPath };
410
- }
411
-
412
- // ── STATUS.md Parser ─────────────────────────────────────────────────
413
-
414
- function parseStatusMd(content: string): { steps: StepInfo[]; reviewCounter: number; iteration: number } {
415
- const text = content.replace(/\r\n/g, "\n");
416
- const steps: StepInfo[] = [];
417
- let currentStep: StepInfo | null = null;
418
- let reviewCounter = 0, iteration = 0;
419
-
420
- for (const line of text.split("\n")) {
421
- const rcMatch = line.match(/\*\*Review Counter:\*\*\s*(\d+)/);
422
- if (rcMatch) reviewCounter = parseInt(rcMatch[1]);
423
- const itMatch = line.match(/\*\*Iteration:\*\*\s*(\d+)/);
424
- if (itMatch) iteration = parseInt(itMatch[1]);
425
-
426
- const stepMatch = line.match(/^###\s+Step\s+(\d+):\s*(.+)/);
427
- if (stepMatch) {
428
- if (currentStep) {
429
- currentStep.totalChecked = currentStep.checkboxes.filter(c => c.checked).length;
430
- currentStep.totalItems = currentStep.checkboxes.length;
431
- steps.push(currentStep);
432
- }
433
- currentStep = { number: parseInt(stepMatch[1]), name: stepMatch[2].trim(), status: "not-started", checkboxes: [], totalChecked: 0, totalItems: 0 };
434
- continue;
435
- }
436
- if (currentStep) {
437
- const ss = line.match(/\*\*Status:\*\*\s*(.*)/);
438
- if (ss) {
439
- const s = ss[1];
440
- if (s.includes("✅") || s.toLowerCase().includes("complete")) currentStep.status = "complete";
441
- else if (s.includes("🟨") || s.toLowerCase().includes("progress")) currentStep.status = "in-progress";
442
- }
443
- const cb = line.match(/^\s*-\s*\[([ xX])\]\s*(.*)/);
444
- if (cb) currentStep.checkboxes.push({ text: cb[2].trim(), checked: cb[1].toLowerCase() === "x" });
445
- }
446
- }
447
- if (currentStep) {
448
- currentStep.totalChecked = currentStep.checkboxes.filter(c => c.checked).length;
449
- currentStep.totalItems = currentStep.checkboxes.length;
450
- steps.push(currentStep);
451
- }
452
- return { steps, reviewCounter, iteration };
453
- }
454
-
455
- // ── STATUS.md Generator ──────────────────────────────────────────────
456
-
457
- function generateStatusMd(task: ParsedTask): string {
458
- const now = new Date().toISOString().slice(0, 10);
459
- const lines: string[] = [
460
- `# ${task.taskId}: ${task.taskName} Status`, "",
461
- `**Current Step:** Not Started`,
462
- `**Status:** 🔵 Ready for Execution`,
463
- `**Last Updated:** ${now}`,
464
- `**Review Level:** ${task.reviewLevel}`,
465
- `**Review Counter:** 0`,
466
- `**Iteration:** 0`,
467
- `**Size:** ${task.size}`, "", "---", "",
468
- ];
469
- for (const step of task.steps) {
470
- lines.push(`### Step ${step.number}: ${step.name}`, `**Status:** ⬜ Not Started`, "");
471
- for (const cb of step.checkboxes) lines.push(`- [ ] ${cb.text}`);
472
- lines.push("", "---", "");
473
- }
474
- lines.push(
475
- "## Reviews", "", "| # | Type | Step | Verdict | File |", "|---|------|------|---------|------|", "", "---", "",
476
- "## Discoveries", "", "| Discovery | Disposition | Location |", "|-----------|-------------|----------|", "", "---", "",
477
- "## Execution Log", "", "| Timestamp | Action | Outcome |", "|-----------|--------|---------|",
478
- `| ${now} | Task staged | STATUS.md auto-generated by task-runner |`, "", "---", "",
479
- "## Blockers", "", "*None*", "", "---", "", "## Notes", "", "*Reserved for execution notes*",
480
- );
481
- return lines.join("\n");
482
- }
483
-
484
- // ── STATUS.md Updaters ───────────────────────────────────────────────
485
-
486
- function updateStatusField(statusPath: string, field: string, value: string): void {
487
- let content = readFileSync(statusPath, "utf-8").replace(/\r\n/g, "\n");
488
- const pattern = new RegExp(`(\\*\\*${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\*\\*\\s*)(.+)`);
489
- if (pattern.test(content)) {
490
- content = content.replace(pattern, `$1${value}`);
491
- } else {
492
- // Append after last ** field
493
- content = content.replace(/(\*\*[^*]+:\*\*\s*.+\n)/, `$1**${field}:** ${value}\n`);
494
- }
495
- writeFileSync(statusPath, content);
496
- }
497
-
498
- function updateStepStatus(statusPath: string, stepNum: number, status: "not-started" | "in-progress" | "complete"): void {
499
- let content = readFileSync(statusPath, "utf-8").replace(/\r\n/g, "\n");
500
- const emoji = status === "complete" ? "✅ Complete" : status === "in-progress" ? "🟨 In Progress" : "⬜ Not Started";
501
- const lines = content.split("\n");
502
- let inTarget = false;
503
- for (let i = 0; i < lines.length; i++) {
504
- const sm = lines[i].match(/^###\s+Step\s+(\d+):/);
505
- if (sm) inTarget = parseInt(sm[1]) === stepNum;
506
- if (inTarget && lines[i].match(/^\*\*Status:\*\*/)) {
507
- lines[i] = `**Status:** ${emoji}`;
508
- break;
509
- }
510
- }
511
- writeFileSync(statusPath, lines.join("\n"));
512
- }
513
-
514
- function appendTableRow(statusPath: string, sectionName: string, row: string): void {
515
- let content = readFileSync(statusPath, "utf-8").replace(/\r\n/g, "\n");
516
- const lines = content.split("\n");
517
- let insertIdx = -1, inSection = false, lastTableRow = -1;
518
- for (let i = 0; i < lines.length; i++) {
519
- if (lines[i].match(new RegExp(`^##\\s+${sectionName}`))) {
520
- inSection = true;
521
- continue;
522
- }
523
- if (inSection) {
524
- // End of section — hit another ## heading or ---
525
- if (lines[i].match(/^##\s/) || lines[i].trim() === "---") {
526
- insertIdx = lastTableRow >= 0 ? lastTableRow + 1 : i;
527
- break;
528
- }
529
- // Track last table data row (skip header separator |---|)
530
- if (lines[i].startsWith("|") && !lines[i].match(/^\|[\s-|]+\|$/)) {
531
- lastTableRow = i;
532
- }
533
- }
534
- }
535
- if (insertIdx === -1) {
536
- insertIdx = lastTableRow >= 0 ? lastTableRow + 1 : lines.length;
537
- }
538
- lines.splice(insertIdx, 0, row);
539
- writeFileSync(statusPath, lines.join("\n"));
540
- }
541
-
542
- function logExecution(statusPath: string, action: string, outcome: string): void {
543
- const ts = new Date().toISOString().slice(0, 16).replace("T", " ");
544
- appendTableRow(statusPath, "Execution Log", `| ${ts} | ${action} | ${outcome} |`);
545
- }
546
-
547
- function logReview(statusPath: string, num: string, type: string, stepNum: number, verdict: string, file: string): void {
548
- appendTableRow(statusPath, "Reviews", `| ${num} | ${type} | Step ${stepNum} | ${verdict} | ${file} |`);
549
- }
550
-
551
- // ── Project Context Builder ──────────────────────────────────────────
552
-
553
- function buildProjectContext(config: TaskConfig, taskFolder: string): string {
554
- const resolved = resolveStandards(config, taskFolder);
555
- const lines: string[] = [`## Project: ${config.project.name}`];
556
- if (config.project.description) lines.push(config.project.description);
557
- lines.push("");
558
- if (resolved.rules.length > 0) {
559
- lines.push("## Code Standards");
560
- for (const r of resolved.rules) lines.push(`- ${r}`);
561
- lines.push("");
562
- }
563
- if (resolved.docs.length > 0) {
564
- lines.push("## Reference Documentation");
565
- for (const d of resolved.docs) lines.push(`- ${d}`);
566
- lines.push("");
567
- }
568
- if (Object.keys(config.testing.commands).length > 0) {
569
- lines.push("## Testing Commands");
570
- for (const [name, cmd] of Object.entries(config.testing.commands)) lines.push(`- **${name}:** \`${cmd}\``);
571
- lines.push("");
572
- }
573
- lines.push(`## Task Folder\n${taskFolder}`);
574
- return lines.join("\n");
575
- }
576
-
577
- // ── Git Helpers ──────────────────────────────────────────────────────
578
-
579
- /**
580
- * Returns the current HEAD commit SHA (short form).
581
- * Used to capture baseline before a step starts so code reviews
582
- * can diff against the correct range instead of just uncommitted changes.
583
- */
584
- function getHeadCommitSha(): string {
585
- try {
586
- const result = spawnSync("git", ["rev-parse", "--short", "HEAD"], {
587
- encoding: "utf-8",
588
- timeout: 5000,
589
- });
590
- return result.status === 0 ? (result.stdout || "").trim() : "";
591
- } catch {
592
- return "";
593
- }
594
- }
595
-
596
- // ── Standards Resolution ─────────────────────────────────────────────
597
-
598
- /**
599
- * Resolve which standards apply to a task based on its area.
600
- *
601
- * Matches the task's folder path against `task_areas` paths to find the
602
- * area name, then checks `standards_overrides` for area-specific standards.
603
- * Falls back to global `standards` if no override exists.
604
- *
605
- * This allows TypeScript extension tasks (e.g., task-system area) to use
606
- * different review standards than Go backend service tasks.
607
- */
608
- function resolveStandards(config: TaskConfig, taskFolder: string): { docs: string[]; rules: string[] } {
609
- const normalizedFolder = taskFolder.replace(/\\/g, "/");
610
-
611
- // Find which area this task belongs to
612
- for (const [areaName, areaCfg] of Object.entries(config.task_areas)) {
613
- const areaPath = areaCfg.path.replace(/\\/g, "/");
614
- if (normalizedFolder.includes(areaPath)) {
615
- const override = config.standards_overrides[areaName];
616
- if (override) {
617
- return {
618
- docs: override.docs ?? config.standards.docs,
619
- rules: override.rules ?? config.standards.rules,
620
- };
621
- }
622
- break; // Area found but no override — use global
623
- }
624
- }
625
-
626
- return { docs: config.standards.docs, rules: config.standards.rules };
627
- }
628
-
629
- // ── Review Request Generator ─────────────────────────────────────────
630
-
631
- function generateReviewRequest(
632
- type: "plan" | "code", stepNum: number, stepName: string,
633
- task: ParsedTask, config: TaskConfig, outputPath: string,
634
- stepBaselineCommit?: string,
635
- ): string {
636
- const resolved = resolveStandards(config, task.taskFolder);
637
- const standardsDocs = resolved.docs.map(d => ` - ${d}`).join("\n");
638
- const standardsRules = resolved.rules.map(r => `- ${r}`).join("\n");
639
-
640
- if (type === "plan") {
641
- return [
642
- `# Review Request: Plan Review`, "",
643
- `You are reviewing an implementation plan for a ${config.project.name} task.`,
644
- `You have full tool access — use \`read\` to examine files and \`bash\` to run commands.`, "",
645
- `## Task Context`, "",
646
- `- **Task PROMPT:** ${task.promptPath}`,
647
- `- **Task STATUS:** ${join(task.taskFolder, "STATUS.md")}`,
648
- `- **Step being planned:** Step ${stepNum}: ${stepName}`, "",
649
- `## Instructions`, "",
650
- `1. Read the PROMPT.md for full requirements`,
651
- `2. Read STATUS.md for progress so far`,
652
- `3. Check relevant source files for existing patterns:`,
653
- standardsDocs, "",
654
- `## Project Standards`, "", standardsRules, "",
655
- `## Output`, "",
656
- `Write your review to: \`${outputPath}\``,
657
- ].join("\n");
658
- } else {
659
- // For code reviews, provide the baseline commit so the reviewer can
660
- // diff the full step's changes — not just uncommitted changes.
661
- // Workers commit via checkpoints, so `git diff` alone sees nothing.
662
- const diffCmd = stepBaselineCommit
663
- ? `git diff ${stepBaselineCommit}..HEAD --name-only`
664
- : `git diff --name-only`;
665
- const diffFullCmd = stepBaselineCommit
666
- ? `git diff ${stepBaselineCommit}..HEAD`
667
- : `git diff`;
668
-
669
- return [
670
- `# Review Request: Code Review`, "",
671
- `You are reviewing code changes for a ${config.project.name} task.`,
672
- `You have full tool access — use \`read\` to examine files and \`bash\` to run commands.`, "",
673
- `## Task Context`, "",
674
- `- **Task PROMPT:** ${task.promptPath}`,
675
- `- **Task STATUS:** ${join(task.taskFolder, "STATUS.md")}`,
676
- `- **Step reviewed:** Step ${stepNum}: ${stepName}`,
677
- ...(stepBaselineCommit ? [`- **Step baseline commit:** ${stepBaselineCommit}`] : []),
678
- "",
679
- `## Instructions`, "",
680
- `1. Run \`${diffCmd}\` to see files changed in this step`,
681
- ` Then \`${diffFullCmd}\` for the full diff`,
682
- ` **Important:** The worker commits code via checkpoints, so plain \`git diff\` may show nothing.`,
683
- ` Always use the baseline commit range above to see all step changes.`,
684
- `2. Read changed files in full for context`,
685
- `3. Check neighboring files for pattern consistency`,
686
- `4. Check standards:`,
687
- standardsDocs, "",
688
- `## Project Standards`, "", standardsRules, "",
689
- `## Output`, "",
690
- `Write your review to: \`${outputPath}\``,
691
- ].join("\n");
692
- }
693
- }
694
-
695
- function extractVerdict(reviewContent: string): string {
696
- const match = reviewContent.match(/###?\s*Verdict[:\s]*(APPROVE|REVISE|RETHINK)/i);
697
- return match ? match[1].toUpperCase() : "UNKNOWN";
698
- }
699
-
700
- // ── Subagent Spawner ─────────────────────────────────────────────────
701
-
702
- function spawnAgent(opts: {
703
- model: string; tools: string; thinking: string;
704
- systemPrompt: string; prompt: string;
705
- contextWindow?: number; warnPct?: number; killPct?: number;
706
- wrapUpFile?: string;
707
- onToolCall?: (toolName: string, args: any) => void;
708
- onContextPct?: (pct: number) => void;
709
- onTokenUpdate?: (tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }) => void;
710
- onJsonEvent?: (event: Record<string, unknown>) => void;
711
- }): { promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>; kill: () => void } {
712
- let killFn: () => void = () => {};
713
-
714
- const promise = new Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>((resolve) => {
715
- // Write system prompt and user prompt to temp files to avoid
716
- // shell escaping issues (backticks, quotes, etc. in markdown)
717
- const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
718
- const sysTmpFile = join(tmpdir(), `pi-task-sys-${id}.txt`);
719
- const promptTmpFile = join(tmpdir(), `pi-task-prompt-${id}.txt`);
720
- writeFileSync(sysTmpFile, opts.systemPrompt);
721
- writeFileSync(promptTmpFile, opts.prompt);
722
-
723
- const args = [
724
- "-p", "--mode", "json",
725
- "--no-session", "--no-extensions", "--no-skills",
726
- "--model", opts.model,
727
- "--tools", opts.tools,
728
- "--thinking", opts.thinking,
729
- "--append-system-prompt", sysTmpFile,
730
- `@${promptTmpFile}`,
731
- ];
732
-
733
- const proc = spawn("pi", args, {
734
- stdio: ["ignore", "pipe", "pipe"],
735
- env: { ...process.env },
736
- shell: true,
737
- });
738
-
739
- // Clean up temp files after process finishes
740
- const cleanupTmp = () => {
741
- setTimeout(() => {
742
- try { unlinkSync(sysTmpFile); } catch {}
743
- try { unlinkSync(promptTmpFile); } catch {}
744
- }, 1000);
745
- };
746
-
747
- let killed = false;
748
- const startTime = Date.now();
749
- const textChunks: string[] = [];
750
- let buffer = "";
751
-
752
- killFn = () => { killed = true; proc.kill("SIGTERM"); };
753
-
754
- proc.stdout!.setEncoding("utf-8");
755
- proc.stdout!.on("data", (chunk: string) => {
756
- buffer += chunk;
757
- const lines = buffer.split("\n");
758
- buffer = lines.pop() || "";
759
- for (const line of lines) {
760
- if (!line.trim()) continue;
761
- try {
762
- const event = JSON.parse(line);
763
- // Tee all events to JSONL log if callback provided
764
- opts.onJsonEvent?.(event);
765
- if (event.type === "message_update") {
766
- const delta = event.assistantMessageEvent;
767
- if (delta?.type === "text_delta" && delta.delta) {
768
- textChunks.push(delta.delta);
769
- }
770
- } else if (event.type === "tool_execution_start") {
771
- opts.onToolCall?.(event.toolName, event.args);
772
- } else if (event.type === "message_end") {
773
- const usage = event.message?.usage;
774
- if (usage) {
775
- // Report per-turn token counts to caller (caller accumulates).
776
- // Anthropic `input` = uncached new tokens only; `cacheRead`
777
- // holds bulk of input. `cost.total` = exact dollar cost for turn.
778
- opts.onTokenUpdate?.({
779
- input: (usage as any).input || 0,
780
- output: (usage as any).output || 0,
781
- cacheRead: (usage as any).cacheRead || 0,
782
- cacheWrite: (usage as any).cacheWrite || 0,
783
- cost: (usage as any).cost?.total || 0,
784
- });
785
- if (opts.contextWindow) {
786
- // Use totalTokens (cumulative) works across providers.
787
- // Anthropic reports small `input` per-turn but growing `totalTokens`.
788
- // OpenAI reports growing `input` but also growing `totalTokens`.
789
- const tokens = (usage as any).totalTokens || ((usage as any).input + (usage as any).output) || 0;
790
- if (tokens > 0) {
791
- const pct = (tokens / opts.contextWindow) * 100;
792
- opts.onContextPct?.(pct);
793
- if (opts.warnPct && pct >= opts.warnPct && opts.wrapUpFile && !existsSync(opts.wrapUpFile)) {
794
- writeFileSync(opts.wrapUpFile, `Wrap up at ${new Date().toISOString()}`);
795
- }
796
- if (opts.killPct && pct >= opts.killPct && !killed) {
797
- killed = true;
798
- proc.kill("SIGTERM");
799
- }
800
- }
801
- }
802
- }
803
- }
804
- } catch {}
805
- }
806
- });
807
-
808
- proc.stderr?.setEncoding("utf-8");
809
- proc.stderr?.on("data", () => {});
810
-
811
- proc.on("close", (code) => {
812
- cleanupTmp();
813
- if (buffer.trim()) {
814
- try {
815
- const event = JSON.parse(buffer);
816
- if (event.type === "message_update") {
817
- const delta = event.assistantMessageEvent;
818
- if (delta?.type === "text_delta") textChunks.push(delta.delta || "");
819
- }
820
- } catch {}
821
- }
822
- resolve({ output: textChunks.join(""), exitCode: code ?? 1, elapsed: Date.now() - startTime, killed });
823
- });
824
-
825
- proc.on("error", (err) => {
826
- cleanupTmp();
827
- resolve({ output: `Error: ${err.message}`, exitCode: 1, elapsed: Date.now() - startTime, killed: false });
828
- });
829
- });
830
-
831
- return { promise, kill: () => killFn() };
832
- }
833
-
834
- // ── TMUX Agent Spawner ───────────────────────────────────────────────
835
-
836
- /**
837
- * Spawns a Pi agent in a named TMUX session instead of a headless subprocess.
838
- * Returns the same interface shape as `spawnAgent()` for drop-in compatibility.
839
- *
840
- * Differences from subprocess mode:
841
- * - No JSON event stream → no onToolCall/onContextPct callbacks
842
- * - No captured output → output is always ""
843
- * - Completion detected via `tmux has-session` polling (2s interval)
844
- * - Kill via `tmux kill-session`
845
- * - User can `tmux attach -t {sessionName}` for full visibility
846
- *
847
- * Temp files are cleaned up on all exit paths:
848
- * - Normal completion (session ends, polling detects it)
849
- * - Kill (explicit kill-session call)
850
- * - TMUX not installed (throws with actionable message)
851
- * - Session creation failure (throws after cleanup)
852
- *
853
- * Parity with spawnAgent():
854
- * - Return shape: identical — { promise, kill }
855
- * - Promise result: identical fields { output, exitCode, elapsed, killed }
856
- * - Kill semantics: sets killed=true, terminates session, cleans temp files
857
- * - Elapsed calc: Date.now() - startTime (same pattern)
858
- * - Cleanup: synchronous on all paths (more deterministic than spawnAgent's 1s setTimeout)
859
- * - output: always "" (no JSON stream in TMUX mode)
860
- * - exitCode: 0 on normal completion, 1 on poll error (TMUX doesn't forward exit codes)
861
- *
862
- * @param opts.sessionName — TMUX session name (e.g., "orch-lane-1-worker")
863
- * @param opts.cwd — Working directory for the TMUX session
864
- * @param opts.systemPrompt System prompt content (written to temp file)
865
- * @param opts.prompt User prompt content (written to temp file)
866
- * @param opts.model — Model identifier (e.g., "anthropic/claude-sonnet-4-20250514")
867
- * @param opts.tools Comma-separated tool list
868
- * @param opts.thinking Thinking mode ("off", "on", etc.)
869
- */
870
- function spawnAgentTmux(opts: {
871
- sessionName: string;
872
- cwd: string;
873
- systemPrompt: string;
874
- prompt: string;
875
- model: string;
876
- tools: string;
877
- thinking: string;
878
- }): { promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>; kill: () => void } {
879
-
880
- // ── Preflight: verify tmux is available ──────────────────────────
881
- const tmuxCheck = spawnSync("tmux", ["-V"], { shell: true });
882
- if (tmuxCheck.status !== 0 && tmuxCheck.status !== null) {
883
- throw new Error(
884
- "tmux is not installed or not in PATH. " +
885
- "Install tmux to use TMUX spawn mode, or set TASK_RUNNER_SPAWN_MODE=subprocess. " +
886
- `(tmux -V exited with code ${tmuxCheck.status})`
887
- );
888
- }
889
-
890
- // ── Write prompts to temp files ─────────────────────────────────
891
- // Same pattern as spawnAgent() avoids shell escaping issues with
892
- // backticks, quotes, and special characters in markdown content.
893
- const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
894
- const sysTmpFile = join(tmpdir(), `pi-task-sys-${id}.txt`);
895
- const promptTmpFile = join(tmpdir(), `pi-task-prompt-${id}.txt`);
896
- writeFileSync(sysTmpFile, opts.systemPrompt);
897
- writeFileSync(promptTmpFile, opts.prompt);
898
-
899
- const cleanupTmp = () => {
900
- try { unlinkSync(sysTmpFile); } catch {}
901
- try { unlinkSync(promptTmpFile); } catch {}
902
- };
903
-
904
- // ── Build Pi command ─────────────────────────────────────────────
905
- // Use an array of arguments and quote each one individually to handle
906
- // paths with spaces (Windows paths, temp dir, etc.). The command is
907
- // passed as a single string to tmux new-session, so we shell-quote it.
908
- const quoteArg = (s: string): string => {
909
- // If the arg contains spaces, quotes, or shell metacharacters, wrap in single quotes.
910
- // Inside single quotes, escape existing single quotes as '\'' (end quote, escaped quote, restart quote).
911
- if (/[\s"'`$\\!&|;()<>{}#*?~]/.test(s)) {
912
- return `'${s.replace(/'/g, "'\\''")}'`;
913
- }
914
- return s;
915
- };
916
-
917
- const piArgs = [
918
- "pi",
919
- "-p", // Non-interactive: process prompt and exit (without this, pi waits for more input)
920
- "--no-session", "--no-extensions", "--no-skills",
921
- "--model", quoteArg(opts.model),
922
- "--tools", quoteArg(opts.tools),
923
- "--thinking", quoteArg(opts.thinking),
924
- "--append-system-prompt", quoteArg(sysTmpFile),
925
- `@${quoteArg(promptTmpFile)}`,
926
- ];
927
- const piCommand = piArgs.join(" ");
928
-
929
- // ── Handle stale session ─────────────────────────────────────────
930
- // Session names are fixed per role (e.g., "orch-lane-1-worker").
931
- // If a stale session from a previous iteration exists, kill it first.
932
- const staleCheck = spawnSync("tmux", ["has-session", "-t", opts.sessionName]);
933
- if (staleCheck.status === 0) {
934
- console.error(`[task-runner] tmux: killing stale session '${opts.sessionName}'`);
935
- spawnSync("tmux", ["kill-session", "-t", opts.sessionName]);
936
- }
937
-
938
- // ── Create TMUX session ─────────────────────────────────────────
939
- // Use `cd <path> && TERM=xterm-256color <cmd>` wrapper instead of tmux `-c`
940
- // because `-c` with Windows paths silently fails in MSYS2/Git Bash tmux.
941
- // Pi's ink/react TUI hangs with TERM=tmux-256color (tmux default), so we
942
- // force xterm-256color.
943
- const tmuxCwd = opts.cwd.replace(/^([A-Za-z]):\\/, (_, d: string) => `/${d.toLowerCase()}/`).replace(/\\/g, "/");
944
- const wrappedCommand = `cd ${quoteArg(tmuxCwd)} && TERM=xterm-256color ${piCommand}`;
945
- const createResult = spawnSync("tmux", [
946
- "new-session", "-d",
947
- "-s", opts.sessionName,
948
- wrappedCommand,
949
- ]);
950
-
951
- if (createResult.status !== 0) {
952
- cleanupTmp();
953
- const stderr = createResult.stderr?.toString().trim() || "unknown error";
954
- console.error(`[task-runner] tmux: session '${opts.sessionName}' creation failed: ${stderr}`);
955
- throw new Error(
956
- `Failed to create TMUX session '${opts.sessionName}': ${stderr}. ` +
957
- `Verify tmux is running and the session name is valid.`
958
- );
959
- }
960
-
961
- console.error(`[task-runner] tmux: session '${opts.sessionName}' created (cwd: ${opts.cwd})`);
962
-
963
-
964
- // ── Poll until session ends ─────────────────────────────────────
965
- let killed = false;
966
- const startTime = Date.now();
967
-
968
- const promise = (async (): Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }> => {
969
- try {
970
- while (true) {
971
- await new Promise(r => setTimeout(r, 2000));
972
- const result = spawnSync("tmux", ["has-session", "-t", opts.sessionName]);
973
- if (result.status !== 0) {
974
- // Session no longer exists — Pi exited, TMUX closed
975
- break;
976
- }
977
- }
978
- } catch (pollErr: any) {
979
- // Polling failureclean up and report
980
- console.error(`[task-runner] tmux: polling error for '${opts.sessionName}': ${pollErr?.message || pollErr}`);
981
- cleanupTmp();
982
- console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}' (poll-fail)`);
983
- return {
984
- output: `Polling error: ${pollErr?.message || pollErr}`,
985
- exitCode: 1,
986
- elapsed: Date.now() - startTime,
987
- killed: false,
988
- };
989
- }
990
-
991
- // Normal completion — clean up temp files
992
- const elapsed = Date.now() - startTime;
993
- console.error(`[task-runner] tmux: session '${opts.sessionName}' ended after ${Math.round(elapsed / 1000)}s${killed ? " (killed)" : ""}`);
994
- cleanupTmp();
995
- console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}'`);
996
- return {
997
- output: "", // No captured output in TMUX mode
998
- exitCode: 0, // TMUX session exit is best-effort success
999
- elapsed,
1000
- killed,
1001
- };
1002
- })();
1003
-
1004
- // ── Kill function ───────────────────────────────────────────────
1005
- const kill = () => {
1006
- killed = true;
1007
- console.error(`[task-runner] tmux: killing session '${opts.sessionName}'`);
1008
- const killResult = spawnSync("tmux", ["kill-session", "-t", opts.sessionName]);
1009
- if (killResult.status !== 0) {
1010
- // Session may have already exited — not an error
1011
- console.error(`[task-runner] tmux: session '${opts.sessionName}' already exited (kill was no-op)`);
1012
- }
1013
- cleanupTmp();
1014
- console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}' (killed)`);
1015
- };
1016
-
1017
- return { promise, kill };
1018
- }
1019
-
1020
- // ── Display Helpers ──────────────────────────────────────────────────
1021
-
1022
- function displayName(name: string): string {
1023
- return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
1024
- }
1025
-
1026
- // ── Extension ────────────────────────────────────────────────────────
1027
-
1028
- export default function (pi: ExtensionAPI) {
1029
- let state = freshState();
1030
- let widgetCtx: ExtensionContext | undefined;
1031
-
1032
- // ── Widget Rendering ─────────────────────────────────────────────
1033
-
1034
- function renderStepCard(step: StepInfo, colWidth: number, theme: any): string[] {
1035
- const w = colWidth - 2;
1036
- const trunc = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s;
1037
-
1038
- const isRunning = state.currentStep === step.number && state.phase === "running";
1039
- const statusColor = step.status === "complete" ? "success"
1040
- : step.status === "in-progress" ? "accent" : "dim";
1041
- const statusIcon = step.status === "complete" ? ""
1042
- : step.status === "in-progress" ? "●" : "○";
1043
-
1044
- const nameStr = theme.fg("accent", theme.bold(trunc(`Step ${step.number}`, w)));
1045
- const nameVis = Math.min(`Step ${step.number}`.length, w);
1046
-
1047
- const statusStr = `${statusIcon} ${trunc(step.name, w - 4)}`;
1048
- const statusLine = theme.fg(statusColor, statusStr);
1049
- const statusVis = Math.min(statusStr.length, w);
1050
-
1051
- const progress = `${step.totalChecked}/${step.totalItems} ✓`;
1052
- const progressLine = theme.fg(step.totalChecked === step.totalItems && step.totalItems > 0 ? "success" : "muted", progress);
1053
- const progressVis = progress.length;
1054
-
1055
- let extraStr = "";
1056
- let extraVis = 0;
1057
- if (isRunning && state.workerStatus === "running") {
1058
- extraStr = theme.fg("accent", `iter ${state.workerIteration}`) + theme.fg("dim", ` ctx:${Math.round(state.workerContextPct)}%`);
1059
- extraVis = `iter ${state.workerIteration} ctx:${Math.round(state.workerContextPct)}%`.length;
1060
- } else if (isRunning && state.reviewerStatus === "running") {
1061
- extraStr = theme.fg("warning", `reviewing...`);
1062
- extraVis = "reviewing...".length;
1063
- }
1064
-
1065
- const top = "┌" + "─".repeat(w) + "";
1066
- const bot = "" + "─".repeat(w) + "┘";
1067
- const border = (content: string, vis: number) =>
1068
- theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - vis)) + theme.fg("dim", "│");
1069
-
1070
- return [
1071
- theme.fg("dim", top),
1072
- border(" " + nameStr, 1 + nameVis),
1073
- border(" " + statusLine, 1 + statusVis),
1074
- border(" " + progressLine, 1 + progressVis),
1075
- border(extraStr ? " " + extraStr : "", extraVis ? 1 + extraVis : 0),
1076
- theme.fg("dim", bot),
1077
- ];
1078
- }
1079
-
1080
- function updateWidgets() {
1081
- // Write sidecar state for web dashboard (orchestrated mode)
1082
- writeLaneState(state);
1083
-
1084
- if (!widgetCtx) return;
1085
- const ctx = widgetCtx;
1086
-
1087
- // Refresh step statuses from STATUS.md if task is active
1088
- if (state.task) {
1089
- const statusPath = join(state.task.taskFolder, "STATUS.md");
1090
- if (existsSync(statusPath)) {
1091
- try {
1092
- const parsed = parseStatusMd(readFileSync(statusPath, "utf-8"));
1093
- for (const s of parsed.steps) state.stepStatuses.set(s.number, s);
1094
- } catch {}
1095
- }
1096
- }
1097
-
1098
- ctx.ui.setWidget("task-runner", (_tui: any, theme: any) => {
1099
- return {
1100
- render(width: number): string[] {
1101
- if (!state.task) {
1102
- return [];
1103
- }
1104
-
1105
- const task = state.task;
1106
- const lines: string[] = [""];
1107
-
1108
- // Header
1109
- const phaseIcon = state.phase === "running" ? "●"
1110
- : state.phase === "paused" ? "⏸"
1111
- : state.phase === "complete" ? "✓"
1112
- : state.phase === "error" ? "✗" : "○";
1113
- const phaseColor = state.phase === "running" ? "accent"
1114
- : state.phase === "complete" ? "success"
1115
- : state.phase === "error" ? "error" : "dim";
1116
-
1117
- const header =
1118
- theme.fg(phaseColor, ` ${phaseIcon} `) +
1119
- theme.fg("accent", theme.bold(task.taskId)) +
1120
- theme.fg("dim", ": ") +
1121
- theme.fg("muted", task.taskName) +
1122
- theme.fg("dim", " ") +
1123
- theme.fg("warning", `L${task.reviewLevel}`) +
1124
- theme.fg("dim", " · ") +
1125
- theme.fg("muted", task.size) +
1126
- theme.fg("dim", " · ") +
1127
- theme.fg("success", `iter ${state.totalIterations}`);
1128
- lines.push(truncateToWidth(header, width));
1129
-
1130
- // Progress bar
1131
- const allSteps = task.steps.map(s => state.stepStatuses.get(s.number) || s);
1132
- const totalCb = allSteps.reduce((a, s) => a + s.totalItems, 0);
1133
- const doneCb = allSteps.reduce((a, s) => a + s.totalChecked, 0);
1134
- const pct = totalCb > 0 ? Math.round((doneCb / totalCb) * 100) : 0;
1135
- const barWidth = Math.min(30, width - 20);
1136
- const filled = Math.round((pct / 100) * barWidth);
1137
- const progressBar =
1138
- theme.fg("dim", " ") +
1139
- theme.fg("warning", "[") +
1140
- theme.fg("success", "█".repeat(filled)) +
1141
- theme.fg("dim", "░".repeat(barWidth - filled)) +
1142
- theme.fg("warning", "]") +
1143
- theme.fg("dim", " ") +
1144
- theme.fg("accent", `${doneCb}/${totalCb}`) +
1145
- theme.fg("dim", ` (${pct}%)`);
1146
- lines.push(truncateToWidth(progressBar, width));
1147
- lines.push("");
1148
-
1149
- // Step cards — fit as many as the terminal allows, wrap to rows
1150
- const steps = allSteps;
1151
- const arrowWidth = 3;
1152
- // Calculate how many cards fit in one row
1153
- const minCardWidth = 16;
1154
- const maxCols = Math.max(1, Math.floor((width + arrowWidth) / (minCardWidth + arrowWidth)));
1155
- const cols = Math.min(steps.length, maxCols);
1156
- const colWidth = Math.max(minCardWidth, Math.floor((width - arrowWidth * (cols - 1)) / cols));
1157
-
1158
- // Render in rows of `cols` cards
1159
- for (let rowStart = 0; rowStart < steps.length; rowStart += cols) {
1160
- const rowSteps = steps.slice(rowStart, rowStart + cols);
1161
- const cards = rowSteps.map(s => renderStepCard(s, colWidth, theme));
1162
-
1163
- if (cards.length > 0) {
1164
- const cardHeight = cards[0].length;
1165
- const arrowRow = 2;
1166
- for (let line = 0; line < cardHeight; line++) {
1167
- let row = cards[0][line];
1168
- for (let c = 1; c < cards.length; c++) {
1169
- row += line === arrowRow ? theme.fg("dim", " → ") : " ";
1170
- row += cards[c][line];
1171
- }
1172
- lines.push(truncateToWidth(row, width));
1173
- }
1174
- }
1175
- }
1176
-
1177
- // Worker status line
1178
- if (state.workerStatus === "running") {
1179
- lines.push("");
1180
- lines.push(truncateToWidth(
1181
- theme.fg("accent", " ● Worker: ") +
1182
- theme.fg("dim", `${Math.round(state.workerElapsed / 1000)}s · `) +
1183
- theme.fg("dim", `🔧${state.workerToolCount}`) +
1184
- (state.workerLastTool
1185
- ? theme.fg("dim", " · ") + theme.fg("muted", state.workerLastTool)
1186
- : ""),
1187
- width,
1188
- ));
1189
- } else if (state.reviewerStatus === "running") {
1190
- lines.push("");
1191
- lines.push(truncateToWidth(
1192
- theme.fg("warning", " ◉ Reviewer: ") +
1193
- theme.fg("dim", `${state.reviewerType} · ${Math.round(state.reviewerElapsed / 1000)}s`) +
1194
- (state.reviewerLastTool
1195
- ? theme.fg("dim", " · ") + theme.fg("muted", state.reviewerLastTool)
1196
- : ""),
1197
- width,
1198
- ));
1199
- }
1200
-
1201
- return lines;
1202
- },
1203
- invalidate() {},
1204
- };
1205
- });
1206
- }
1207
-
1208
- // ── Execution Engine ─────────────────────────────────────────────
1209
-
1210
- async function executeTask(ctx: ExtensionContext): Promise<void> {
1211
- if (!state.task || !state.config) return;
1212
-
1213
- const task = state.task;
1214
- const config = state.config;
1215
- const statusPath = join(task.taskFolder, "STATUS.md");
1216
-
1217
- updateStatusField(statusPath, "Status", "🟡 In Progress");
1218
- updateStatusField(statusPath, "Last Updated", new Date().toISOString().slice(0, 10));
1219
- logExecution(statusPath, "Task started", "Extension-driven execution");
1220
-
1221
- // Find first incomplete step
1222
- const status = parseStatusMd(readFileSync(statusPath, "utf-8"));
1223
- let startStep = 0;
1224
- for (const s of status.steps) {
1225
- if (s.status === "complete") startStep = s.number + 1;
1226
- else break;
1227
- }
1228
-
1229
- for (let i = 0; i < task.steps.length; i++) {
1230
- const step = task.steps[i];
1231
- if (step.number < startStep) continue;
1232
- if (state.phase === "paused") {
1233
- logExecution(statusPath, "Paused", `User paused at Step ${step.number}`);
1234
- ctx.ui.notify(`Task paused at Step ${step.number}`, "info");
1235
- return;
1236
- }
1237
-
1238
- state.currentStep = step.number;
1239
- updateWidgets();
1240
-
1241
- await executeStep(step, ctx);
1242
-
1243
- if (state.phase === "error" || state.phase === "paused") return;
1244
- }
1245
-
1246
- // All done
1247
- const donePath = join(task.taskFolder, ".DONE");
1248
- writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${task.taskId}\n`);
1249
- updateStatusField(statusPath, "Status", "✅ Complete");
1250
- logExecution(statusPath, "Task complete", ".DONE created");
1251
-
1252
- // Auto-archive: move task folder to tasks/archive/.
1253
- // In orchestrated runs, do NOT archive here — the orchestrator polls
1254
- // .DONE at the original path and handles post-merge archival itself.
1255
- if (!isOrchestratedMode()) {
1256
- const tasksDir = dirname(task.taskFolder);
1257
- const archiveDir = join(tasksDir, "archive");
1258
- const archiveDest = join(archiveDir, basename(task.taskFolder));
1259
- try {
1260
- if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
1261
- const { renameSync } = require("fs");
1262
- renameSync(task.taskFolder, archiveDest);
1263
- logExecution(join(archiveDest, "STATUS.md"), "Archived", `Moved to ${archiveDest}`);
1264
- ctx.ui.notify(`📦 Archived to ${archiveDest}`, "info");
1265
- } catch (err: any) {
1266
- ctx.ui.notify(`Archive failed (move manually): ${err?.message}`, "warning");
1267
- }
1268
- } else {
1269
- ctx.ui.notify("ℹ️ Orchestrated run: skipping auto-archive (orchestrator handles archival)", "info");
1270
- }
1271
-
1272
- state.phase = "complete";
1273
- updateWidgets();
1274
- ctx.ui.notify(`✅ Task ${task.taskId} complete!`, "success");
1275
- }
1276
-
1277
- async function executeStep(step: StepInfo, ctx: ExtensionContext): Promise<void> {
1278
- if (!state.task || !state.config) return;
1279
-
1280
- const task = state.task;
1281
- const config = state.config;
1282
- const statusPath = join(task.taskFolder, "STATUS.md");
1283
-
1284
- // Capture git HEAD before the step starts so code reviewers can
1285
- // diff the full step's changes (workers commit via checkpoints).
1286
- const stepBaselineCommit = getHeadCommitSha();
1287
-
1288
- updateStepStatus(statusPath, step.number, "in-progress");
1289
- updateStatusField(statusPath, "Current Step", `Step ${step.number}: ${step.name}`);
1290
- logExecution(statusPath, `Step ${step.number} started`, step.name);
1291
- updateWidgets();
1292
-
1293
- // Plan review (level 1)
1294
- if (task.reviewLevel >= 1) {
1295
- const verdict = await doReview("plan", step, ctx, stepBaselineCommit);
1296
- if (verdict === "RETHINK") {
1297
- ctx.ui.notify(`Reviewer: RETHINK on Step ${step.number} plan. Proceeding with caution.`, "warning");
1298
- }
1299
- }
1300
-
1301
- // Worker loop
1302
- let noProgressCount = 0;
1303
- for (let iter = 0; iter < config.context.max_worker_iterations; iter++) {
1304
- if (state.phase === "paused") return;
1305
-
1306
- // Re-read STATUS.md
1307
- const currentStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
1308
- const stepStatus = currentStatus.steps.find(s => s.number === step.number);
1309
- if (stepStatus?.status === "complete" || (stepStatus && stepStatus.totalChecked === stepStatus.totalItems && stepStatus.totalItems > 0)) {
1310
- updateStepStatus(statusPath, step.number, "complete");
1311
- break;
1312
- }
1313
-
1314
- const prevChecked = stepStatus?.totalChecked || 0;
1315
- state.workerIteration = iter + 1;
1316
- state.totalIterations++;
1317
- updateStatusField(statusPath, "Iteration", `${state.totalIterations}`);
1318
- updateWidgets();
1319
-
1320
- await runWorker(step, ctx);
1321
-
1322
- // Check progress
1323
- const afterStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
1324
- const afterStep = afterStatus.steps.find(s => s.number === step.number);
1325
- const afterChecked = afterStep?.totalChecked || 0;
1326
-
1327
- if (afterChecked <= prevChecked) {
1328
- noProgressCount++;
1329
- if (noProgressCount >= config.context.no_progress_limit) {
1330
- logExecution(statusPath, `Step ${step.number} blocked`, `No progress after ${noProgressCount} iterations`);
1331
- ctx.ui.notify(`⚠️ Step ${step.number} blocked — no progress after ${noProgressCount} iterations`, "error");
1332
- state.phase = "error";
1333
- return;
1334
- }
1335
- } else {
1336
- noProgressCount = 0;
1337
- }
1338
-
1339
- if (afterStep?.status === "complete" || (afterStep && afterStep.totalChecked === afterStep.totalItems && afterStep.totalItems > 0)) {
1340
- updateStepStatus(statusPath, step.number, "complete");
1341
- break;
1342
- }
1343
- }
1344
-
1345
- // Code review (level 2)
1346
- if (task.reviewLevel >= 2 && state.phase === "running") {
1347
- const verdict = await doReview("code", step, ctx, stepBaselineCommit);
1348
- if (verdict === "REVISE") {
1349
- ctx.ui.notify(`Reviewer: REVISE on Step ${step.number}. Running worker to fix...`, "warning");
1350
- await runWorker(step, ctx); // One more pass to address issues
1351
- }
1352
- }
1353
-
1354
- if (state.phase === "running") {
1355
- updateStepStatus(statusPath, step.number, "complete");
1356
- logExecution(statusPath, `Step ${step.number} complete`, step.name);
1357
- // Update local cache
1358
- const refreshed = parseStatusMd(readFileSync(statusPath, "utf-8"));
1359
- for (const s of refreshed.steps) state.stepStatuses.set(s.number, s);
1360
- updateWidgets();
1361
- }
1362
- }
1363
-
1364
- // ── Worker ───────────────────────────────────────────────────────
1365
-
1366
- async function runWorker(step: StepInfo, ctx: ExtensionContext): Promise<void> {
1367
- if (!state.task || !state.config) return;
1368
-
1369
- const task = state.task;
1370
- const config = state.config;
1371
- const statusPath = join(task.taskFolder, "STATUS.md");
1372
- const wrapUpFile = join(task.taskFolder, ".task-wrap-up");
1373
- const legacyWrapUpFile = join(task.taskFolder, ".wiggum-wrap-up");
1374
-
1375
- const clearWrapUpSignals = () => {
1376
- if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch {}
1377
- if (existsSync(legacyWrapUpFile)) try { unlinkSync(legacyWrapUpFile); } catch {}
1378
- };
1379
-
1380
- const writeWrapUpSignal = (reason: string) => {
1381
- const msg = `${reason} at ${new Date().toISOString()}`;
1382
- if (!existsSync(wrapUpFile)) writeFileSync(wrapUpFile, msg);
1383
- // Backward compatibility: write legacy signal too until all workers migrate.
1384
- if (!existsSync(legacyWrapUpFile)) writeFileSync(legacyWrapUpFile, msg);
1385
- };
1386
-
1387
- clearWrapUpSignals();
1388
-
1389
- const workerDef = loadAgentDef(ctx.cwd, "task-worker");
1390
- const basePrompt = workerDef?.systemPrompt || "You are a task execution agent. Read STATUS.md first, find unchecked items, work on them, checkpoint after each.";
1391
- const systemPrompt = basePrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
1392
-
1393
- const model = config.worker.model
1394
- || workerDef?.model
1395
- || (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514");
1396
-
1397
- const contextDocsList = task.contextDocs.length > 0
1398
- ? "\n\nContext docs to read if needed:\n" + task.contextDocs.map(d => `- ${d}`).join("\n")
1399
- : "";
1400
-
1401
- // When running under the parallel orchestrator, workers must NOT
1402
- // archive or move the task folder — the orchestrator polls for .DONE
1403
- // at the original path and handles post-merge archival itself.
1404
- const archiveSuppression = isOrchestratedMode()
1405
- ? "\n\n⚠️ ORCHESTRATED RUN: Do NOT archive or move the task folder. " +
1406
- "Do NOT rename, relocate, or reorganize the task folder path. " +
1407
- "The orchestrator handles post-merge archival. " +
1408
- "Just create the .DONE file in the task folder when complete."
1409
- : "";
1410
-
1411
- const prompt = [
1412
- `Execute Step ${step.number}: ${step.name}`,
1413
- ``,
1414
- `Task: ${task.taskId} — ${task.taskName}`,
1415
- `Task folder: ${task.taskFolder}/`,
1416
- `PROMPT: ${task.promptPath}`,
1417
- `STATUS: ${statusPath}`,
1418
- ``,
1419
- `This is iteration ${state.totalIterations}.`,
1420
- `Read STATUS.md FIRST to find where you left off.`,
1421
- `Work ONLY on Step ${step.number}. Do not proceed to other steps.`,
1422
- ``,
1423
- `Wrap-up signal files: ${wrapUpFile} (primary), ${legacyWrapUpFile} (legacy)`,
1424
- `Check for either file after each checkpoint. If one exists, stop.`,
1425
- archiveSuppression,
1426
- contextDocsList,
1427
- ].join("\n");
1428
-
1429
- state.workerStatus = "running";
1430
- state.workerElapsed = 0;
1431
- state.workerContextPct = 0;
1432
- state.workerLastTool = "";
1433
- state.workerToolCount = 0;
1434
- updateWidgets();
1435
-
1436
- const startTime = Date.now();
1437
- state.workerTimer = setInterval(() => {
1438
- state.workerElapsed = Date.now() - startTime;
1439
- updateWidgets();
1440
- }, 1000);
1441
-
1442
- const spawnMode = getSpawnMode(config);
1443
- let promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
1444
- let kill: () => void;
1445
- let wallClockWarnTimer: ReturnType<typeof setTimeout> | null = null;
1446
- let wallClockKillTimer: ReturnType<typeof setTimeout> | null = null;
1447
-
1448
- if (spawnMode === "tmux") {
1449
- // ── TMUX mode ────────────────────────────────────────
1450
- // No JSON stream no onToolCall/onContextPct callbacks.
1451
- // Kill via wall-clock timeout instead of context-%.
1452
- const sessionName = `${getTmuxPrefix()}-worker`;
1453
- const spawned = spawnAgentTmux({
1454
- sessionName,
1455
- cwd: ctx.cwd,
1456
- systemPrompt,
1457
- prompt,
1458
- model,
1459
- tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
1460
- thinking: config.worker.thinking || "off",
1461
- });
1462
- promise = spawned.promise;
1463
- kill = spawned.kill;
1464
-
1465
- // Wall-clock timeout: write wrap-up file at 80% of limit,
1466
- // hard kill at 100%. No context telemetry in TMUX mode.
1467
- const maxMinutes = getMaxWorkerMinutes(config);
1468
- const warnMs = Math.round(maxMinutes * 0.8 * 60_000);
1469
- const killMs = maxMinutes * 60_000;
1470
- const iterationMarker = state.totalIterations;
1471
-
1472
- // Wrap-up warning at 80% of wall-clock limit
1473
- wallClockWarnTimer = setTimeout(() => {
1474
- if (
1475
- state.workerStatus === "running" &&
1476
- state.totalIterations === iterationMarker
1477
- ) {
1478
- writeWrapUpSignal(`Wrap up (wall-clock ${maxMinutes}min limit)`);
1479
- }
1480
- }, warnMs);
1481
-
1482
- // Hard kill at 100% of wall-clock limit
1483
- wallClockKillTimer = setTimeout(() => {
1484
- if (state.workerStatus === "running" && state.totalIterations === iterationMarker) {
1485
- console.error(`[task-runner] tmux worker: wall-clock timeout (${maxMinutes}min) — killing session '${sessionName}'`);
1486
- kill();
1487
- }
1488
- }, killMs);
1489
- } else {
1490
- // ── Subprocess mode (default, unchanged) ─────────────
1491
- // In orchestrated mode, tee conversation events to JSONL for web dashboard
1492
- const conversationPrefix = isOrchestratedMode() ? getTmuxPrefix() : null;
1493
- if (conversationPrefix) clearConversationLog(conversationPrefix);
1494
-
1495
- const spawned = spawnAgent({
1496
- model,
1497
- tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
1498
- thinking: config.worker.thinking || "off",
1499
- systemPrompt,
1500
- prompt,
1501
- contextWindow: config.context.worker_context_window,
1502
- warnPct: config.context.warn_percent,
1503
- killPct: config.context.kill_percent,
1504
- wrapUpFile,
1505
- onToolCall: (toolName, args) => {
1506
- state.workerToolCount++;
1507
- // Build a short summary of what the tool is doing
1508
- const path = args?.path || args?.command || "";
1509
- const shortPath = typeof path === "string" && path.length > 80
1510
- ? "..." + path.slice(-77) : path;
1511
- state.workerLastTool = `${toolName} ${shortPath}`.trim();
1512
- if (conversationPrefix) {
1513
- appendConversationEvent(conversationPrefix, {
1514
- type: "tool_call", toolName, args, timestamp: Date.now(),
1515
- });
1516
- }
1517
- updateWidgets();
1518
- },
1519
- onTokenUpdate: (tokens) => {
1520
- // Accumulate across turns — each message_end reports per-turn values.
1521
- // Anthropic's `input` is only uncached new tokens; cacheRead holds
1522
- // the bulk of input processing. We sum all four independently so the
1523
- // dashboard can show the full picture.
1524
- state.workerInputTokens += tokens.input;
1525
- state.workerOutputTokens += tokens.output;
1526
- state.workerCacheReadTokens += tokens.cacheRead;
1527
- state.workerCacheWriteTokens += tokens.cacheWrite;
1528
- state.workerCostUsd += tokens.cost;
1529
- updateWidgets();
1530
- },
1531
- onContextPct: (pct) => {
1532
- state.workerContextPct = pct;
1533
- if (pct >= config.context.warn_percent) {
1534
- writeWrapUpSignal(`Wrap up (context ${Math.round(pct)}%)`);
1535
- }
1536
- updateWidgets();
1537
- },
1538
- onJsonEvent: conversationPrefix
1539
- ? (event: Record<string, unknown>) => appendConversationEvent(conversationPrefix, event)
1540
- : undefined,
1541
- });
1542
- promise = spawned.promise;
1543
- kill = spawned.kill;
1544
- }
1545
-
1546
- state.workerProc = { kill };
1547
-
1548
- const result = await promise;
1549
-
1550
- // Clean up wall-clock timers if they haven't fired yet
1551
- if (wallClockWarnTimer) clearTimeout(wallClockWarnTimer);
1552
- if (wallClockKillTimer) clearTimeout(wallClockKillTimer);
1553
-
1554
- clearInterval(state.workerTimer);
1555
- state.workerElapsed = Date.now() - startTime;
1556
- state.workerStatus = result.killed ? "killed" : (result.exitCode === 0 ? "done" : "error");
1557
- state.workerProc = null;
1558
-
1559
- clearWrapUpSignals();
1560
-
1561
- // Log with mode-appropriate detail: subprocess has context%, TMUX does not
1562
- const killedMsg = spawnMode === "tmux" ? "killed (wall-clock timeout)" : "killed (context limit)";
1563
- const statusMsg = result.killed ? killedMsg : (result.exitCode === 0 ? "done" : `error (code ${result.exitCode})`);
1564
- const ctxDetail = spawnMode === "tmux" ? "" : `, ctx: ${Math.round(state.workerContextPct)}%`;
1565
- logExecution(statusPath, `Worker iter ${state.totalIterations}`,
1566
- `${statusMsg} in ${Math.round(state.workerElapsed / 1000)}s${ctxDetail}, tools: ${state.workerToolCount}`);
1567
-
1568
- updateWidgets();
1569
- }
1570
-
1571
- // ── Reviewer ─────────────────────────────────────────────────────
1572
-
1573
- async function doReview(type: "plan" | "code", step: StepInfo, ctx: ExtensionContext, stepBaselineCommit?: string): Promise<string> {
1574
- if (!state.task || !state.config) return "UNKNOWN";
1575
-
1576
- const task = state.task;
1577
- const config = state.config;
1578
- const statusPath = join(task.taskFolder, "STATUS.md");
1579
- const reviewsDir = join(task.taskFolder, ".reviews");
1580
- if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
1581
-
1582
- state.reviewCounter++;
1583
- const num = String(state.reviewCounter).padStart(3, "0");
1584
- const requestPath = join(reviewsDir, `request-R${num}.md`);
1585
- const outputPath = join(reviewsDir, `R${num}-${type}-step${step.number}.md`);
1586
-
1587
- const request = generateReviewRequest(type, step.number, step.name, task, config, outputPath, stepBaselineCommit);
1588
- writeFileSync(requestPath, request);
1589
-
1590
- const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
1591
- const reviewerModel = config.reviewer.model || reviewerDef?.model || "openai/gpt-5.3-codex";
1592
- const reviewerPrompt = reviewerDef?.systemPrompt || "You are a code reviewer. Read the request and write your review to the specified output file.";
1593
- const systemPrompt = reviewerPrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
1594
-
1595
- state.reviewerStatus = "running";
1596
- state.reviewerType = `${type} review`;
1597
- state.reviewerElapsed = 0;
1598
- state.reviewerLastTool = "";
1599
- updateWidgets();
1600
-
1601
- const startTime = Date.now();
1602
- state.reviewerTimer = setInterval(() => {
1603
- state.reviewerElapsed = Date.now() - startTime;
1604
- updateWidgets();
1605
- }, 1000);
1606
-
1607
- // Read the request file content as the prompt
1608
- const promptContent = readFileSync(requestPath, "utf-8");
1609
-
1610
- const spawnMode = getSpawnMode(config);
1611
- let reviewPromise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
1612
-
1613
- if (spawnMode === "tmux") {
1614
- // ── TMUX mode ────────────────────────────────────────
1615
- // No JSON stream → no onToolCall callback.
1616
- // No timeout reviewer runs to session completion.
1617
- const sessionName = `${getTmuxPrefix()}-reviewer`;
1618
- const spawned = spawnAgentTmux({
1619
- sessionName,
1620
- cwd: ctx.cwd,
1621
- systemPrompt,
1622
- prompt: promptContent,
1623
- model: reviewerModel,
1624
- tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
1625
- thinking: config.reviewer.thinking || "on",
1626
- });
1627
- reviewPromise = spawned.promise;
1628
- state.reviewerProc = { kill: spawned.kill };
1629
- } else {
1630
- // ── Subprocess mode (default, unchanged) ─────────────
1631
- const spawned = spawnAgent({
1632
- model: reviewerModel,
1633
- tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
1634
- thinking: config.reviewer.thinking || "on",
1635
- systemPrompt,
1636
- prompt: promptContent,
1637
- onToolCall: (toolName, args) => {
1638
- const path = args?.path || args?.command || "";
1639
- const shortPath = typeof path === "string" && path.length > 40
1640
- ? "..." + path.slice(-37) : path;
1641
- state.reviewerLastTool = `${toolName} ${shortPath}`.trim();
1642
- updateWidgets();
1643
- },
1644
- });
1645
- reviewPromise = spawned.promise;
1646
- state.reviewerProc = { kill: spawned.kill };
1647
- }
1648
-
1649
- const result = await reviewPromise;
1650
-
1651
- clearInterval(state.reviewerTimer);
1652
- state.reviewerElapsed = Date.now() - startTime;
1653
- state.reviewerStatus = result.exitCode === 0 ? "done" : "error";
1654
- state.reviewerProc = null;
1655
- updateWidgets();
1656
-
1657
- // Read verdict
1658
- let verdict = "UNKNOWN";
1659
- if (existsSync(outputPath)) {
1660
- const review = readFileSync(outputPath, "utf-8");
1661
- verdict = extractVerdict(review);
1662
- } else {
1663
- verdict = "UNAVAILABLE";
1664
- logExecution(statusPath, `Reviewer R${num}`, `${type} review — reviewer did not produce output`);
1665
- }
1666
-
1667
- logReview(statusPath, `R${num}`, type, step.number, verdict, `.reviews/R${num}-${type}-step${step.number}.md`);
1668
- logExecution(statusPath, `Review R${num}`, `${type} Step ${step.number}: ${verdict}`);
1669
- updateStatusField(statusPath, "Review Counter", `${state.reviewCounter}`);
1670
-
1671
- ctx.ui.notify(`Review R${num} (${type} Step ${step.number}): ${verdict}`, verdict === "APPROVE" ? "success" : "warning");
1672
-
1673
- return verdict;
1674
- }
1675
-
1676
- // ── Commands ─────────────────────────────────────────────────────
1677
-
1678
- // ── Shared Task Initialization ───────────────────────────────────
1679
- //
1680
- // Extracts the core init logic used by both the `/task` command and
1681
- // TASK_AUTOSTART so that they share a single code path. Returns true
1682
- // if the task was started successfully.
1683
-
1684
- function startTaskFromPath(ctx: ExtensionContext, fullPath: string): boolean {
1685
- if (state.phase === "running") {
1686
- ctx.ui.notify("A task is already running. Use /task-pause first.", "warning");
1687
- return false;
1688
- }
1689
-
1690
- // Parse PROMPT.md
1691
- let parsed: ParsedTask;
1692
- try {
1693
- const content = readFileSync(fullPath, "utf-8");
1694
- parsed = parsePromptMd(content, fullPath);
1695
- } catch (err: any) {
1696
- ctx.ui.notify(`Failed to parse PROMPT.md: ${err?.message || err}`, "error");
1697
- return false;
1698
- }
1699
-
1700
- state = freshState();
1701
- state.task = parsed;
1702
- state.config = loadConfig(ctx.cwd);
1703
- state.phase = "running";
1704
- widgetCtx = ctx;
1705
-
1706
- // Generate STATUS.md if missing
1707
- const statusPath = join(state.task.taskFolder, "STATUS.md");
1708
- if (!existsSync(statusPath)) {
1709
- writeFileSync(statusPath, generateStatusMd(state.task));
1710
- ctx.ui.notify("Generated STATUS.md from PROMPT.md", "info");
1711
- } else {
1712
- // Sync review counter and iteration from existing STATUS
1713
- const existing = parseStatusMd(readFileSync(statusPath, "utf-8"));
1714
- state.reviewCounter = existing.reviewCounter;
1715
- state.totalIterations = existing.iteration;
1716
- for (const s of existing.steps) state.stepStatuses.set(s.number, s);
1717
- }
1718
-
1719
- // Create .reviews/ if missing
1720
- const reviewsDir = join(state.task.taskFolder, ".reviews");
1721
- if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
1722
-
1723
- updateWidgets();
1724
- ctx.ui.notify(
1725
- `Starting: ${state.task.taskId} ${state.task.taskName}\n` +
1726
- `Review Level: ${state.task.reviewLevel} · Size: ${state.task.size} · Steps: ${state.task.steps.length}\n` +
1727
- `Worker model: ${state.config.worker.model || "inherit"} · Reviewer: ${state.config.reviewer.model}`,
1728
- "info",
1729
- );
1730
-
1731
- // Fire-and-forget
1732
- executeTask(ctx).catch(err => {
1733
- state.phase = "error";
1734
- ctx.ui.notify(`Task error: ${err?.message || err}`, "error");
1735
- updateWidgets();
1736
- });
1737
-
1738
- return true;
1739
- }
1740
-
1741
- pi.registerCommand("task", {
1742
- description: "Start executing a task: /task <path/to/PROMPT.md>",
1743
- handler: async (args, ctx) => {
1744
- widgetCtx = ctx;
1745
- const promptPath = args?.trim();
1746
- if (!promptPath) {
1747
- ctx.ui.notify("Usage: /task <path/to/PROMPT.md>", "error");
1748
- return;
1749
- }
1750
-
1751
- const fullPath = resolve(ctx.cwd, promptPath);
1752
- if (!existsSync(fullPath)) {
1753
- ctx.ui.notify(`File not found: ${promptPath}`, "error");
1754
- return;
1755
- }
1756
-
1757
- startTaskFromPath(ctx, fullPath);
1758
- },
1759
- });
1760
-
1761
- pi.registerCommand("task-status", {
1762
- description: "Show current task progress",
1763
- handler: async (_args, ctx) => {
1764
- widgetCtx = ctx;
1765
- if (!state.task) {
1766
- ctx.ui.notify("No task loaded. Use /task <path/to/PROMPT.md>", "info");
1767
- return;
1768
- }
1769
-
1770
- const statusPath = join(state.task.taskFolder, "STATUS.md");
1771
- if (!existsSync(statusPath)) {
1772
- ctx.ui.notify("STATUS.md not found", "error");
1773
- return;
1774
- }
1775
-
1776
- const parsed = parseStatusMd(readFileSync(statusPath, "utf-8"));
1777
- const lines = parsed.steps.map(s => {
1778
- const icon = s.status === "complete" ? "✅" : s.status === "in-progress" ? "🟨" : "⬜";
1779
- return `${icon} Step ${s.number}: ${s.name} (${s.totalChecked}/${s.totalItems})`;
1780
- });
1781
-
1782
- ctx.ui.notify(
1783
- `${state.task.taskId}: ${state.task.taskName}\n` +
1784
- `Phase: ${state.phase} · Iteration: ${state.totalIterations} · Reviews: ${state.reviewCounter}\n\n` +
1785
- lines.join("\n"),
1786
- "info",
1787
- );
1788
-
1789
- // Refresh widget
1790
- for (const s of parsed.steps) state.stepStatuses.set(s.number, s);
1791
- updateWidgets();
1792
- },
1793
- });
1794
-
1795
- pi.registerCommand("task-pause", {
1796
- description: "Pause task after current worker finishes",
1797
- handler: async (_args, ctx) => {
1798
- widgetCtx = ctx;
1799
- if (state.phase !== "running") {
1800
- ctx.ui.notify("No task is running", "warning");
1801
- return;
1802
- }
1803
- state.phase = "paused";
1804
- ctx.ui.notify("Task will pause after current worker finishes", "info");
1805
- updateWidgets();
1806
- },
1807
- });
1808
-
1809
- pi.registerCommand("task-resume", {
1810
- description: "Resume a paused task",
1811
- handler: async (_args, ctx) => {
1812
- widgetCtx = ctx;
1813
- if (state.phase !== "paused") {
1814
- ctx.ui.notify("Task is not paused", "warning");
1815
- return;
1816
- }
1817
- if (!state.task) {
1818
- ctx.ui.notify("No task loaded", "error");
1819
- return;
1820
- }
1821
-
1822
- state.phase = "running";
1823
- ctx.ui.notify(`Resuming ${state.task.taskId}...`, "info");
1824
- updateWidgets();
1825
-
1826
- executeTask(ctx).catch(err => {
1827
- state.phase = "error";
1828
- ctx.ui.notify(`Task error: ${err?.message || err}`, "error");
1829
- updateWidgets();
1830
- });
1831
- },
1832
- });
1833
-
1834
- // ── Session Lifecycle ────────────────────────────────────────────
1835
-
1836
- pi.on("session_start", async (_event, ctx) => {
1837
- widgetCtx = ctx;
1838
-
1839
- // Kill any running subprocesses
1840
- if (state.workerProc) try { state.workerProc.kill(); } catch {}
1841
- if (state.reviewerProc) try { state.reviewerProc.kill(); } catch {}
1842
- if (state.workerTimer) clearInterval(state.workerTimer);
1843
- if (state.reviewerTimer) clearInterval(state.reviewerTimer);
1844
-
1845
- // Keep task state if resuming, but reset runtime state
1846
- const hadTask = state.task;
1847
- if (hadTask) {
1848
- state.phase = "paused";
1849
- state.workerStatus = "idle";
1850
- state.reviewerStatus = "idle";
1851
- state.workerProc = null;
1852
- state.reviewerProc = null;
1853
- // Refresh from STATUS.md
1854
- const statusPath = join(hadTask.taskFolder, "STATUS.md");
1855
- if (existsSync(statusPath)) {
1856
- const parsed = parseStatusMd(readFileSync(statusPath, "utf-8"));
1857
- state.reviewCounter = parsed.reviewCounter;
1858
- state.totalIterations = parsed.iteration;
1859
- for (const s of parsed.steps) state.stepStatuses.set(s.number, s);
1860
- }
1861
- }
1862
-
1863
- updateWidgets();
1864
-
1865
- const config = loadConfig(ctx.cwd);
1866
- ctx.ui.setStatus("task-runner", `📋 ${config.project.name}`);
1867
-
1868
- if (hadTask) {
1869
- ctx.ui.notify(`Task ${hadTask.taskId} loaded (paused). Use /task-resume to continue.`, "info");
1870
- } else if (process.env.TASK_AUTOSTART) {
1871
- // ── TASK_AUTOSTART ────────────────────────────────────────
1872
- // When set, automatically start a task as if the user typed
1873
- // `/task <path>`. Used by the parallel orchestrator to launch
1874
- // workers in TMUX sessions without send-keys timing issues.
1875
- const autoPath = process.env.TASK_AUTOSTART;
1876
- const fullPath = resolve(ctx.cwd, autoPath);
1877
- if (!existsSync(fullPath)) {
1878
- ctx.ui.notify(`TASK_AUTOSTART: file not found ${fullPath}`, "error");
1879
- } else {
1880
- ctx.ui.notify(`TASK_AUTOSTART: ${fullPath}`, "info");
1881
- startTaskFromPath(ctx, fullPath);
1882
- }
1883
- } else {
1884
- ctx.ui.notify(
1885
- `Task Runner ready — ${config.project.name}\n\n` +
1886
- `/task <path/to/PROMPT.md> Start a task\n` +
1887
- `/task-status Show progress\n` +
1888
- `/task-pause Pause execution\n` +
1889
- `/task-resume Resume execution`,
1890
- "info",
1891
- );
1892
- }
1893
- });
1894
- }
1
+ /**
2
+ * Task Runner — Autonomous task execution with live dashboard
3
+ *
4
+ * Replaces the Ralph Wiggum bash loop with a Pi extension. Workers are
5
+ * fresh-context subprocesses; STATUS.md is persistent memory. Supports
6
+ * cross-model review (reviewer uses a different model than the worker).
7
+ *
8
+ * Commands:
9
+ * /task <path/to/PROMPT.md> — Start executing a task
10
+ * /task-status — Re-read and display STATUS.md progress
11
+ * /task-pause — Pause after current worker finishes
12
+ * /task-resume — Resume a paused task
13
+ *
14
+ * Configuration: .pi/task-runner.yaml (project-specific settings)
15
+ * Agents: .pi/agents/task-worker.md, .pi/agents/task-reviewer.md
16
+ *
17
+ * Usage: pi -e extensions/task-runner.ts
18
+ */
19
+
20
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
21
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
22
+ import { Container, Text, truncateToWidth } from "@mariozechner/pi-tui";
23
+ import { spawn, spawnSync } from "child_process";
24
+ import {
25
+ readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, unlinkSync,
26
+ } from "fs";
27
+ import { tmpdir } from "os";
28
+ import { join, dirname, basename, resolve } from "path";
29
+ import { parse as yamlParse } from "yaml";
30
+
31
+
32
+ // ── Types ────────────────────────────────────────────────────────────
33
+
34
+ interface TaskConfig {
35
+ project: { name: string; description: string };
36
+ paths: { tasks: string; architecture?: string };
37
+ testing: { commands: Record<string, string> };
38
+ standards: { docs: string[]; rules: string[] };
39
+ standards_overrides: Record<string, { docs?: string[]; rules?: string[] }>;
40
+ task_areas: Record<string, { path: string; [key: string]: any }>;
41
+ worker: {
42
+ model: string;
43
+ tools: string;
44
+ thinking: string;
45
+ spawn_mode?: "subprocess" | "tmux";
46
+ };
47
+ reviewer: { model: string; tools: string; thinking: string };
48
+ context: {
49
+ worker_context_window: number;
50
+ warn_percent: number;
51
+ kill_percent: number;
52
+ max_worker_iterations: number;
53
+ max_review_cycles: number;
54
+ no_progress_limit: number;
55
+ max_worker_minutes?: number;
56
+ };
57
+ }
58
+
59
+ interface StepInfo {
60
+ number: number;
61
+ name: string;
62
+ status: "not-started" | "in-progress" | "complete";
63
+ checkboxes: { text: string; checked: boolean }[];
64
+ totalChecked: number;
65
+ totalItems: number;
66
+ }
67
+
68
+ interface ParsedTask {
69
+ taskId: string;
70
+ taskName: string;
71
+ reviewLevel: number;
72
+ size: string;
73
+ steps: StepInfo[];
74
+ contextDocs: string[];
75
+ taskFolder: string;
76
+ promptPath: string;
77
+ }
78
+
79
+ type TaskPhase = "idle" | "running" | "paused" | "complete" | "error";
80
+
81
+ interface TaskState {
82
+ phase: TaskPhase;
83
+ task: ParsedTask | null;
84
+ config: TaskConfig | null;
85
+ currentStep: number;
86
+ workerIteration: number;
87
+ workerStatus: "idle" | "running" | "done" | "error" | "killed";
88
+ workerElapsed: number;
89
+ workerContextPct: number;
90
+ workerLastTool: string;
91
+ workerToolCount: number;
92
+ workerInputTokens: number;
93
+ workerOutputTokens: number;
94
+ workerCacheReadTokens: number;
95
+ workerCacheWriteTokens: number;
96
+ workerCostUsd: number;
97
+ workerProc: any;
98
+ workerTimer: any;
99
+ reviewerStatus: "idle" | "running" | "done" | "error";
100
+ reviewerType: string;
101
+ reviewerElapsed: number;
102
+ reviewerLastTool: string;
103
+ reviewerProc: any;
104
+ reviewerTimer: any;
105
+ reviewCounter: number;
106
+ totalIterations: number;
107
+ stepStatuses: Map<number, StepInfo>;
108
+ }
109
+
110
+ function freshState(): TaskState {
111
+ return {
112
+ phase: "idle", task: null, config: null, currentStep: 0,
113
+ workerIteration: 0, workerStatus: "idle", workerElapsed: 0,
114
+ workerContextPct: 0, workerLastTool: "", workerToolCount: 0,
115
+ workerInputTokens: 0, workerOutputTokens: 0, workerCacheReadTokens: 0, workerCacheWriteTokens: 0, workerCostUsd: 0,
116
+ workerProc: null, workerTimer: null,
117
+ reviewerStatus: "idle", reviewerType: "", reviewerElapsed: 0,
118
+ reviewerLastTool: "", reviewerProc: null, reviewerTimer: null,
119
+ reviewCounter: 0, totalIterations: 0, stepStatuses: new Map(),
120
+ };
121
+ }
122
+
123
+ // ── Config ───────────────────────────────────────────────────────────
124
+
125
+ const DEFAULT_CONFIG: TaskConfig = {
126
+ project: { name: "Project", description: "" },
127
+ paths: { tasks: "docs/task-management" },
128
+ testing: { commands: {} },
129
+ standards: { docs: [], rules: [] },
130
+ standards_overrides: {},
131
+ task_areas: {},
132
+ worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "off" },
133
+ reviewer: { model: "openai/gpt-5.3-codex", tools: "read,bash,grep,find,ls", thinking: "on" },
134
+ context: {
135
+ worker_context_window: 200000, warn_percent: 70, kill_percent: 85,
136
+ max_worker_iterations: 20, max_review_cycles: 2, no_progress_limit: 3,
137
+ },
138
+ };
139
+
140
+ function loadConfig(cwd: string): TaskConfig {
141
+ let configPath = join(cwd, ".pi", "task-runner.yaml");
142
+ // In workspace mode, the worker runs in a repo worktree — not the workspace root.
143
+ // TASKPLANE_WORKSPACE_ROOT tells us where .pi/task-runner.yaml actually lives.
144
+ if (!existsSync(configPath) && process.env.TASKPLANE_WORKSPACE_ROOT) {
145
+ configPath = join(process.env.TASKPLANE_WORKSPACE_ROOT, ".pi", "task-runner.yaml");
146
+ }
147
+ if (!existsSync(configPath)) return { ...DEFAULT_CONFIG };
148
+ try {
149
+ const raw = readFileSync(configPath, "utf-8");
150
+ const loaded = yamlParse(raw) as any;
151
+ // Parse standards_overrides: Record<areaName, { docs?, rules? }>
152
+ const rawOverrides = loaded?.standards_overrides || {};
153
+ const parsedOverrides: Record<string, { docs?: string[]; rules?: string[] }> = {};
154
+ for (const [key, val] of Object.entries(rawOverrides)) {
155
+ if (val && typeof val === "object") {
156
+ const v = val as any;
157
+ parsedOverrides[key] = {
158
+ docs: Array.isArray(v.docs) ? v.docs : undefined,
159
+ rules: Array.isArray(v.rules) ? v.rules : undefined,
160
+ };
161
+ }
162
+ }
163
+
164
+ // Parse task_areas minimally (we only need path for standards resolution)
165
+ const rawAreas = loaded?.task_areas || {};
166
+ const parsedAreas: Record<string, { path: string }> = {};
167
+ for (const [key, val] of Object.entries(rawAreas)) {
168
+ if (val && typeof val === "object" && (val as any).path) {
169
+ parsedAreas[key] = { path: (val as any).path };
170
+ }
171
+ }
172
+
173
+ return {
174
+ project: { ...DEFAULT_CONFIG.project, ...loaded?.project },
175
+ paths: { ...DEFAULT_CONFIG.paths, ...loaded?.paths },
176
+ testing: { commands: { ...DEFAULT_CONFIG.testing.commands, ...loaded?.testing?.commands } },
177
+ standards: {
178
+ docs: loaded?.standards?.docs || DEFAULT_CONFIG.standards.docs,
179
+ rules: loaded?.standards?.rules || DEFAULT_CONFIG.standards.rules,
180
+ },
181
+ standards_overrides: parsedOverrides,
182
+ task_areas: parsedAreas,
183
+ worker: { ...DEFAULT_CONFIG.worker, ...loaded?.worker },
184
+ reviewer: { ...DEFAULT_CONFIG.reviewer, ...loaded?.reviewer },
185
+ context: { ...DEFAULT_CONFIG.context, ...loaded?.context },
186
+ };
187
+ } catch {
188
+ return { ...DEFAULT_CONFIG };
189
+ }
190
+ }
191
+
192
+ // ── Spawn Mode Resolution ────────────────────────────────────────────
193
+
194
+ /**
195
+ * Determines whether workers/reviewers spawn as headless subprocesses
196
+ * (existing behavior) or as TMUX sessions (parallel orchestrator mode).
197
+ *
198
+ * Resolution order: env var config → default "subprocess".
199
+ * The orchestrator sets TASK_RUNNER_SPAWN_MODE=tmux per-lane.
200
+ */
201
+ function getSpawnMode(config: TaskConfig): "subprocess" | "tmux" {
202
+ const envMode = process.env.TASK_RUNNER_SPAWN_MODE;
203
+ if (envMode === "tmux" || envMode === "subprocess") return envMode;
204
+ if (config.worker.spawn_mode === "tmux" || config.worker.spawn_mode === "subprocess") {
205
+ return config.worker.spawn_mode;
206
+ }
207
+ return "subprocess";
208
+ }
209
+
210
+ /**
211
+ * Returns the TMUX session name prefix for worker/reviewer sessions.
212
+ * The orchestrator sets TASK_RUNNER_TMUX_PREFIX per-lane (e.g., "orch-lane-1").
213
+ * Worker sessions become "{prefix}-worker", reviewer sessions "{prefix}-reviewer".
214
+ */
215
+ function getTmuxPrefix(): string {
216
+ return process.env.TASK_RUNNER_TMUX_PREFIX || "task";
217
+ }
218
+
219
+ /**
220
+ * Detects whether this task runner is executing inside the parallel orchestrator.
221
+ *
222
+ * TASK_RUNNER_TMUX_PREFIX is only ever set by the orchestrator (via execution.ts
223
+ * buildLaneEnv). Its presence — regardless of value — indicates orchestrated mode.
224
+ * The prefix can be any user-configured value (e.g., "orch-lane-1", "penster-lane-1").
225
+ *
226
+ * When true, certain worker behaviors are suppressed — most notably, workers
227
+ * must NOT archive task folders because the orchestrator polls for .DONE files
228
+ * at the original path.
229
+ */
230
+ function isOrchestratedMode(): boolean {
231
+ return !!process.env.TASK_RUNNER_TMUX_PREFIX;
232
+ }
233
+
234
+ /**
235
+ * Returns the wall-clock timeout for TMUX worker sessions in minutes.
236
+ * Used instead of context-% based kill (no JSON stream in TMUX mode).
237
+ *
238
+ * Resolution order: env var → config → default 30 minutes.
239
+ * Reviewers do NOT use this timeout — they run to session completion.
240
+ */
241
+ function getMaxWorkerMinutes(config: TaskConfig): number {
242
+ const envVal = process.env.TASK_RUNNER_MAX_WORKER_MINUTES;
243
+ if (envVal) {
244
+ const parsed = parseInt(envVal, 10);
245
+ if (!isNaN(parsed) && parsed > 0) return parsed;
246
+ }
247
+ const configVal = config.context.max_worker_minutes;
248
+ if (typeof configVal === "number" && configVal > 0) return configVal;
249
+ return 30;
250
+ }
251
+
252
+ // ── Orchestrator Sidecar Files ────────────────────────────────────────
253
+
254
+ /**
255
+ * Returns the .pi directory path for sidecar files (lane state, conversation logs).
256
+ * In orchestrated mode, the orchestrator passes ORCH_SIDECAR_DIR pointing to the
257
+ * MAIN repo's .pi/ directory (not the worktree's).
258
+ */
259
+ function getSidecarDir(): string {
260
+ // Orchestrator provides the main repo .pi path
261
+ const orchDir = process.env.ORCH_SIDECAR_DIR;
262
+ if (orchDir) {
263
+ if (!existsSync(orchDir)) mkdirSync(orchDir, { recursive: true });
264
+ return orchDir;
265
+ }
266
+ // Fallback: walk up from cwd
267
+ let dir = process.cwd();
268
+ for (let i = 0; i < 10; i++) {
269
+ const piDir = join(dir, ".pi");
270
+ if (existsSync(piDir)) return piDir;
271
+ const parent = dirname(dir);
272
+ if (parent === dir) break;
273
+ dir = parent;
274
+ }
275
+ const piDir = join(process.cwd(), ".pi");
276
+ if (!existsSync(piDir)) mkdirSync(piDir, { recursive: true });
277
+ return piDir;
278
+ }
279
+
280
+ /**
281
+ * Write lane state sidecar JSON for the web dashboard.
282
+ * Written every second when in orchestrated mode.
283
+ */
284
+ function writeLaneState(state: TaskState): void {
285
+ if (!isOrchestratedMode()) return;
286
+ const prefix = getTmuxPrefix(); // e.g., "orch-lane-1"
287
+ const filePath = join(getSidecarDir(), `lane-state-${prefix}.json`);
288
+ try {
289
+ const data = {
290
+ prefix,
291
+ taskId: state.task?.taskId || null,
292
+ phase: state.phase,
293
+ currentStep: state.currentStep,
294
+ totalIterations: state.totalIterations,
295
+ workerIteration: state.workerIteration,
296
+ workerStatus: state.workerStatus,
297
+ workerElapsed: state.workerElapsed,
298
+ workerContextPct: state.workerContextPct,
299
+ workerLastTool: state.workerLastTool,
300
+ workerToolCount: state.workerToolCount,
301
+ workerInputTokens: state.workerInputTokens,
302
+ workerOutputTokens: state.workerOutputTokens,
303
+ workerCacheReadTokens: state.workerCacheReadTokens,
304
+ workerCacheWriteTokens: state.workerCacheWriteTokens,
305
+ workerCostUsd: state.workerCostUsd,
306
+ reviewerStatus: state.reviewerStatus || "idle",
307
+ timestamp: Date.now(),
308
+ };
309
+ writeFileSync(filePath, JSON.stringify(data) + "\n");
310
+ } catch {
311
+ // Best effort don't crash the runner
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Append a JSON event to the conversation JSONL log file.
317
+ * Used in orchestrated mode to capture the full worker conversation for the web dashboard.
318
+ */
319
+ function appendConversationEvent(prefix: string, event: Record<string, unknown>): void {
320
+ const filePath = join(getSidecarDir(), `worker-conversation-${prefix}.jsonl`);
321
+ try {
322
+ appendFileSync(filePath, JSON.stringify(event) + "\n");
323
+ } catch {
324
+ // Best effort
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Clear the conversation log at the start of a new worker iteration.
330
+ */
331
+ function clearConversationLog(prefix: string): void {
332
+ const filePath = join(getSidecarDir(), `worker-conversation-${prefix}.jsonl`);
333
+ try {
334
+ writeFileSync(filePath, "");
335
+ } catch {
336
+ // Best effort
337
+ }
338
+ }
339
+
340
+ // ── Agent Loader ─────────────────────────────────────────────────────
341
+
342
+ function loadAgentDef(cwd: string, name: string): { systemPrompt: string; tools: string; model: string } | null {
343
+ const paths = [join(cwd, ".pi", "agents", `${name}.md`), join(cwd, "agents", `${name}.md`)];
344
+ for (const p of paths) {
345
+ if (!existsSync(p)) continue;
346
+ const raw = readFileSync(p, "utf-8").replace(/\r\n/g, "\n");
347
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
348
+ if (!match) continue;
349
+ const fm: Record<string, string> = {};
350
+ for (const line of match[1].split("\n")) {
351
+ const idx = line.indexOf(":");
352
+ if (idx > 0) fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
353
+ }
354
+ return { systemPrompt: match[2].trim(), tools: fm.tools || "read,grep,find,ls", model: fm.model || "" };
355
+ }
356
+ return null;
357
+ }
358
+
359
+ // ── PROMPT.md Parser ─────────────────────────────────────────────────
360
+
361
+ function parsePromptMd(content: string, promptPath: string): ParsedTask {
362
+ const text = content.replace(/\r\n/g, "\n");
363
+ const taskFolder = dirname(resolve(promptPath));
364
+
365
+ // Task ID and name
366
+ let taskId = "", taskName = "";
367
+ const titleMatch = text.match(/^#\s+(?:Task:\s*)?(\S+-\d+)\s*[-–:]\s*(.+)/m);
368
+ if (titleMatch) { taskId = titleMatch[1]; taskName = titleMatch[2].trim(); }
369
+ else { taskId = basename(taskFolder); taskName = taskId; }
370
+
371
+ // Review level
372
+ let reviewLevel = 0;
373
+ const rlMatch = text.match(/##\s+Review Level[:\s]*(\d)/);
374
+ if (rlMatch) reviewLevel = parseInt(rlMatch[1]);
375
+
376
+ // Size
377
+ let size = "M";
378
+ const sizeMatch = text.match(/\*\*Size:\*\*\s*(\w+)/);
379
+ if (sizeMatch) size = sizeMatch[1];
380
+
381
+ // Steps
382
+ const steps: StepInfo[] = [];
383
+ const stepRegex = /###\s+Step\s+(\d+):\s*(.+)/g;
384
+ const positions: { number: number; name: string; start: number }[] = [];
385
+ let m;
386
+ while ((m = stepRegex.exec(text)) !== null) {
387
+ positions.push({ number: parseInt(m[1]), name: m[2].trim(), start: m.index });
388
+ }
389
+ for (let i = 0; i < positions.length; i++) {
390
+ const section = text.slice(positions[i].start, i + 1 < positions.length ? positions[i + 1].start : text.length);
391
+ const checkboxes: { text: string; checked: boolean }[] = [];
392
+ const cbRegex = /^\s*-\s*\[([ xX])\]\s*(.*)/gm;
393
+ let cb;
394
+ while ((cb = cbRegex.exec(section)) !== null) {
395
+ checkboxes.push({ text: cb[2].trim(), checked: cb[1].toLowerCase() === "x" });
396
+ }
397
+ steps.push({
398
+ number: positions[i].number, name: positions[i].name,
399
+ status: "not-started", checkboxes,
400
+ totalChecked: checkboxes.filter(c => c.checked).length,
401
+ totalItems: checkboxes.length,
402
+ });
403
+ }
404
+
405
+ // Context docs
406
+ const contextDocs: string[] = [];
407
+ const ctxMatch = text.match(/##\s+Context to Read First\s*\n+([\s\S]*?)(?=\n##\s|$)/);
408
+ if (ctxMatch) {
409
+ const pathRegex = /`([^\s`]+\.(?:md|yaml|json|go|ts|js))`/g;
410
+ let pm;
411
+ while ((pm = pathRegex.exec(ctxMatch[1])) !== null) contextDocs.push(pm[1]);
412
+ }
413
+
414
+ return { taskId, taskName, reviewLevel, size, steps, contextDocs, taskFolder, promptPath };
415
+ }
416
+
417
+ // ── STATUS.md Parser ─────────────────────────────────────────────────
418
+
419
+ function parseStatusMd(content: string): { steps: StepInfo[]; reviewCounter: number; iteration: number } {
420
+ const text = content.replace(/\r\n/g, "\n");
421
+ const steps: StepInfo[] = [];
422
+ let currentStep: StepInfo | null = null;
423
+ let reviewCounter = 0, iteration = 0;
424
+
425
+ for (const line of text.split("\n")) {
426
+ const rcMatch = line.match(/\*\*Review Counter:\*\*\s*(\d+)/);
427
+ if (rcMatch) reviewCounter = parseInt(rcMatch[1]);
428
+ const itMatch = line.match(/\*\*Iteration:\*\*\s*(\d+)/);
429
+ if (itMatch) iteration = parseInt(itMatch[1]);
430
+
431
+ const stepMatch = line.match(/^###\s+Step\s+(\d+):\s*(.+)/);
432
+ if (stepMatch) {
433
+ if (currentStep) {
434
+ currentStep.totalChecked = currentStep.checkboxes.filter(c => c.checked).length;
435
+ currentStep.totalItems = currentStep.checkboxes.length;
436
+ steps.push(currentStep);
437
+ }
438
+ currentStep = { number: parseInt(stepMatch[1]), name: stepMatch[2].trim(), status: "not-started", checkboxes: [], totalChecked: 0, totalItems: 0 };
439
+ continue;
440
+ }
441
+ if (currentStep) {
442
+ const ss = line.match(/\*\*Status:\*\*\s*(.*)/);
443
+ if (ss) {
444
+ const s = ss[1];
445
+ if (s.includes("✅") || s.toLowerCase().includes("complete")) currentStep.status = "complete";
446
+ else if (s.includes("🟨") || s.toLowerCase().includes("progress")) currentStep.status = "in-progress";
447
+ }
448
+ const cb = line.match(/^\s*-\s*\[([ xX])\]\s*(.*)/);
449
+ if (cb) currentStep.checkboxes.push({ text: cb[2].trim(), checked: cb[1].toLowerCase() === "x" });
450
+ }
451
+ }
452
+ if (currentStep) {
453
+ currentStep.totalChecked = currentStep.checkboxes.filter(c => c.checked).length;
454
+ currentStep.totalItems = currentStep.checkboxes.length;
455
+ steps.push(currentStep);
456
+ }
457
+ return { steps, reviewCounter, iteration };
458
+ }
459
+
460
+ // ── STATUS.md Generator ──────────────────────────────────────────────
461
+
462
+ function generateStatusMd(task: ParsedTask): string {
463
+ const now = new Date().toISOString().slice(0, 10);
464
+ const lines: string[] = [
465
+ `# ${task.taskId}: ${task.taskName} — Status`, "",
466
+ `**Current Step:** Not Started`,
467
+ `**Status:** 🔵 Ready for Execution`,
468
+ `**Last Updated:** ${now}`,
469
+ `**Review Level:** ${task.reviewLevel}`,
470
+ `**Review Counter:** 0`,
471
+ `**Iteration:** 0`,
472
+ `**Size:** ${task.size}`, "", "---", "",
473
+ ];
474
+ for (const step of task.steps) {
475
+ lines.push(`### Step ${step.number}: ${step.name}`, `**Status:** Not Started`, "");
476
+ for (const cb of step.checkboxes) lines.push(`- [ ] ${cb.text}`);
477
+ lines.push("", "---", "");
478
+ }
479
+ lines.push(
480
+ "## Reviews", "", "| # | Type | Step | Verdict | File |", "|---|------|------|---------|------|", "", "---", "",
481
+ "## Discoveries", "", "| Discovery | Disposition | Location |", "|-----------|-------------|----------|", "", "---", "",
482
+ "## Execution Log", "", "| Timestamp | Action | Outcome |", "|-----------|--------|---------|",
483
+ `| ${now} | Task staged | STATUS.md auto-generated by task-runner |`, "", "---", "",
484
+ "## Blockers", "", "*None*", "", "---", "", "## Notes", "", "*Reserved for execution notes*",
485
+ );
486
+ return lines.join("\n");
487
+ }
488
+
489
+ // ── STATUS.md Updaters ───────────────────────────────────────────────
490
+
491
+ function updateStatusField(statusPath: string, field: string, value: string): void {
492
+ let content = readFileSync(statusPath, "utf-8").replace(/\r\n/g, "\n");
493
+ const pattern = new RegExp(`(\\*\\*${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\*\\*\\s*)(.+)`);
494
+ if (pattern.test(content)) {
495
+ content = content.replace(pattern, `$1${value}`);
496
+ } else {
497
+ // Append after last ** field
498
+ content = content.replace(/(\*\*[^*]+:\*\*\s*.+\n)/, `$1**${field}:** ${value}\n`);
499
+ }
500
+ writeFileSync(statusPath, content);
501
+ }
502
+
503
+ function updateStepStatus(statusPath: string, stepNum: number, status: "not-started" | "in-progress" | "complete"): void {
504
+ let content = readFileSync(statusPath, "utf-8").replace(/\r\n/g, "\n");
505
+ const emoji = status === "complete" ? "✅ Complete" : status === "in-progress" ? "🟨 In Progress" : "⬜ Not Started";
506
+ const lines = content.split("\n");
507
+ let inTarget = false;
508
+ for (let i = 0; i < lines.length; i++) {
509
+ const sm = lines[i].match(/^###\s+Step\s+(\d+):/);
510
+ if (sm) inTarget = parseInt(sm[1]) === stepNum;
511
+ if (inTarget && lines[i].match(/^\*\*Status:\*\*/)) {
512
+ lines[i] = `**Status:** ${emoji}`;
513
+ break;
514
+ }
515
+ }
516
+ writeFileSync(statusPath, lines.join("\n"));
517
+ }
518
+
519
+ function appendTableRow(statusPath: string, sectionName: string, row: string): void {
520
+ let content = readFileSync(statusPath, "utf-8").replace(/\r\n/g, "\n");
521
+ const lines = content.split("\n");
522
+ let insertIdx = -1, inSection = false, lastTableRow = -1;
523
+ for (let i = 0; i < lines.length; i++) {
524
+ if (lines[i].match(new RegExp(`^##\\s+${sectionName}`))) {
525
+ inSection = true;
526
+ continue;
527
+ }
528
+ if (inSection) {
529
+ // End of section hit another ## heading or ---
530
+ if (lines[i].match(/^##\s/) || lines[i].trim() === "---") {
531
+ insertIdx = lastTableRow >= 0 ? lastTableRow + 1 : i;
532
+ break;
533
+ }
534
+ // Track last table data row (skip header separator |---|)
535
+ if (lines[i].startsWith("|") && !lines[i].match(/^\|[\s-|]+\|$/)) {
536
+ lastTableRow = i;
537
+ }
538
+ }
539
+ }
540
+ if (insertIdx === -1) {
541
+ insertIdx = lastTableRow >= 0 ? lastTableRow + 1 : lines.length;
542
+ }
543
+ lines.splice(insertIdx, 0, row);
544
+ writeFileSync(statusPath, lines.join("\n"));
545
+ }
546
+
547
+ function logExecution(statusPath: string, action: string, outcome: string): void {
548
+ const ts = new Date().toISOString().slice(0, 16).replace("T", " ");
549
+ appendTableRow(statusPath, "Execution Log", `| ${ts} | ${action} | ${outcome} |`);
550
+ }
551
+
552
+ function logReview(statusPath: string, num: string, type: string, stepNum: number, verdict: string, file: string): void {
553
+ appendTableRow(statusPath, "Reviews", `| ${num} | ${type} | Step ${stepNum} | ${verdict} | ${file} |`);
554
+ }
555
+
556
+ // ── Project Context Builder ──────────────────────────────────────────
557
+
558
+ function buildProjectContext(config: TaskConfig, taskFolder: string): string {
559
+ const resolved = resolveStandards(config, taskFolder);
560
+ const lines: string[] = [`## Project: ${config.project.name}`];
561
+ if (config.project.description) lines.push(config.project.description);
562
+ lines.push("");
563
+ if (resolved.rules.length > 0) {
564
+ lines.push("## Code Standards");
565
+ for (const r of resolved.rules) lines.push(`- ${r}`);
566
+ lines.push("");
567
+ }
568
+ if (resolved.docs.length > 0) {
569
+ lines.push("## Reference Documentation");
570
+ for (const d of resolved.docs) lines.push(`- ${d}`);
571
+ lines.push("");
572
+ }
573
+ if (Object.keys(config.testing.commands).length > 0) {
574
+ lines.push("## Testing Commands");
575
+ for (const [name, cmd] of Object.entries(config.testing.commands)) lines.push(`- **${name}:** \`${cmd}\``);
576
+ lines.push("");
577
+ }
578
+ lines.push(`## Task Folder\n${taskFolder}`);
579
+ return lines.join("\n");
580
+ }
581
+
582
+ // ── Git Helpers ──────────────────────────────────────────────────────
583
+
584
+ /**
585
+ * Returns the current HEAD commit SHA (short form).
586
+ * Used to capture baseline before a step starts so code reviews
587
+ * can diff against the correct range instead of just uncommitted changes.
588
+ */
589
+ function getHeadCommitSha(): string {
590
+ try {
591
+ const result = spawnSync("git", ["rev-parse", "--short", "HEAD"], {
592
+ encoding: "utf-8",
593
+ timeout: 5000,
594
+ });
595
+ return result.status === 0 ? (result.stdout || "").trim() : "";
596
+ } catch {
597
+ return "";
598
+ }
599
+ }
600
+
601
+ // ── Standards Resolution ─────────────────────────────────────────────
602
+
603
+ /**
604
+ * Resolve which standards apply to a task based on its area.
605
+ *
606
+ * Matches the task's folder path against `task_areas` paths to find the
607
+ * area name, then checks `standards_overrides` for area-specific standards.
608
+ * Falls back to global `standards` if no override exists.
609
+ *
610
+ * This allows TypeScript extension tasks (e.g., task-system area) to use
611
+ * different review standards than Go backend service tasks.
612
+ */
613
+ function resolveStandards(config: TaskConfig, taskFolder: string): { docs: string[]; rules: string[] } {
614
+ const normalizedFolder = taskFolder.replace(/\\/g, "/");
615
+
616
+ // Find which area this task belongs to
617
+ for (const [areaName, areaCfg] of Object.entries(config.task_areas)) {
618
+ const areaPath = areaCfg.path.replace(/\\/g, "/");
619
+ if (normalizedFolder.includes(areaPath)) {
620
+ const override = config.standards_overrides[areaName];
621
+ if (override) {
622
+ return {
623
+ docs: override.docs ?? config.standards.docs,
624
+ rules: override.rules ?? config.standards.rules,
625
+ };
626
+ }
627
+ break; // Area found but no override — use global
628
+ }
629
+ }
630
+
631
+ return { docs: config.standards.docs, rules: config.standards.rules };
632
+ }
633
+
634
+ // ── Review Request Generator ─────────────────────────────────────────
635
+
636
+ function generateReviewRequest(
637
+ type: "plan" | "code", stepNum: number, stepName: string,
638
+ task: ParsedTask, config: TaskConfig, outputPath: string,
639
+ stepBaselineCommit?: string,
640
+ ): string {
641
+ const resolved = resolveStandards(config, task.taskFolder);
642
+ const standardsDocs = resolved.docs.map(d => ` - ${d}`).join("\n");
643
+ const standardsRules = resolved.rules.map(r => `- ${r}`).join("\n");
644
+
645
+ if (type === "plan") {
646
+ return [
647
+ `# Review Request: Plan Review`, "",
648
+ `You are reviewing an implementation plan for a ${config.project.name} task.`,
649
+ `You have full tool access — use \`read\` to examine files and \`bash\` to run commands.`, "",
650
+ `## Task Context`, "",
651
+ `- **Task PROMPT:** ${task.promptPath}`,
652
+ `- **Task STATUS:** ${join(task.taskFolder, "STATUS.md")}`,
653
+ `- **Step being planned:** Step ${stepNum}: ${stepName}`, "",
654
+ `## Instructions`, "",
655
+ `1. Read the PROMPT.md for full requirements`,
656
+ `2. Read STATUS.md for progress so far`,
657
+ `3. Check relevant source files for existing patterns:`,
658
+ standardsDocs, "",
659
+ `## Project Standards`, "", standardsRules, "",
660
+ `## Output`, "",
661
+ `Write your review to: \`${outputPath}\``,
662
+ ].join("\n");
663
+ } else {
664
+ // For code reviews, provide the baseline commit so the reviewer can
665
+ // diff the full step's changes — not just uncommitted changes.
666
+ // Workers commit via checkpoints, so `git diff` alone sees nothing.
667
+ const diffCmd = stepBaselineCommit
668
+ ? `git diff ${stepBaselineCommit}..HEAD --name-only`
669
+ : `git diff --name-only`;
670
+ const diffFullCmd = stepBaselineCommit
671
+ ? `git diff ${stepBaselineCommit}..HEAD`
672
+ : `git diff`;
673
+
674
+ return [
675
+ `# Review Request: Code Review`, "",
676
+ `You are reviewing code changes for a ${config.project.name} task.`,
677
+ `You have full tool access use \`read\` to examine files and \`bash\` to run commands.`, "",
678
+ `## Task Context`, "",
679
+ `- **Task PROMPT:** ${task.promptPath}`,
680
+ `- **Task STATUS:** ${join(task.taskFolder, "STATUS.md")}`,
681
+ `- **Step reviewed:** Step ${stepNum}: ${stepName}`,
682
+ ...(stepBaselineCommit ? [`- **Step baseline commit:** ${stepBaselineCommit}`] : []),
683
+ "",
684
+ `## Instructions`, "",
685
+ `1. Run \`${diffCmd}\` to see files changed in this step`,
686
+ ` Then \`${diffFullCmd}\` for the full diff`,
687
+ ` **Important:** The worker commits code via checkpoints, so plain \`git diff\` may show nothing.`,
688
+ ` Always use the baseline commit range above to see all step changes.`,
689
+ `2. Read changed files in full for context`,
690
+ `3. Check neighboring files for pattern consistency`,
691
+ `4. Check standards:`,
692
+ standardsDocs, "",
693
+ `## Project Standards`, "", standardsRules, "",
694
+ `## Output`, "",
695
+ `Write your review to: \`${outputPath}\``,
696
+ ].join("\n");
697
+ }
698
+ }
699
+
700
+ function extractVerdict(reviewContent: string): string {
701
+ const match = reviewContent.match(/###?\s*Verdict[:\s]*(APPROVE|REVISE|RETHINK)/i);
702
+ return match ? match[1].toUpperCase() : "UNKNOWN";
703
+ }
704
+
705
+ // ── Subagent Spawner ─────────────────────────────────────────────────
706
+
707
+ function spawnAgent(opts: {
708
+ model: string; tools: string; thinking: string;
709
+ systemPrompt: string; prompt: string;
710
+ contextWindow?: number; warnPct?: number; killPct?: number;
711
+ wrapUpFile?: string;
712
+ onToolCall?: (toolName: string, args: any) => void;
713
+ onContextPct?: (pct: number) => void;
714
+ onTokenUpdate?: (tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }) => void;
715
+ onJsonEvent?: (event: Record<string, unknown>) => void;
716
+ }): { promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>; kill: () => void } {
717
+ let killFn: () => void = () => {};
718
+
719
+ const promise = new Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>((resolve) => {
720
+ // Write system prompt and user prompt to temp files to avoid
721
+ // shell escaping issues (backticks, quotes, etc. in markdown)
722
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
723
+ const sysTmpFile = join(tmpdir(), `pi-task-sys-${id}.txt`);
724
+ const promptTmpFile = join(tmpdir(), `pi-task-prompt-${id}.txt`);
725
+ writeFileSync(sysTmpFile, opts.systemPrompt);
726
+ writeFileSync(promptTmpFile, opts.prompt);
727
+
728
+ const args = [
729
+ "-p", "--mode", "json",
730
+ "--no-session", "--no-extensions", "--no-skills",
731
+ "--model", opts.model,
732
+ "--tools", opts.tools,
733
+ "--thinking", opts.thinking,
734
+ "--append-system-prompt", sysTmpFile,
735
+ `@${promptTmpFile}`,
736
+ ];
737
+
738
+ const proc = spawn("pi", args, {
739
+ stdio: ["ignore", "pipe", "pipe"],
740
+ env: { ...process.env },
741
+ shell: true,
742
+ });
743
+
744
+ // Clean up temp files after process finishes
745
+ const cleanupTmp = () => {
746
+ setTimeout(() => {
747
+ try { unlinkSync(sysTmpFile); } catch {}
748
+ try { unlinkSync(promptTmpFile); } catch {}
749
+ }, 1000);
750
+ };
751
+
752
+ let killed = false;
753
+ const startTime = Date.now();
754
+ const textChunks: string[] = [];
755
+ let buffer = "";
756
+
757
+ killFn = () => { killed = true; proc.kill("SIGTERM"); };
758
+
759
+ proc.stdout!.setEncoding("utf-8");
760
+ proc.stdout!.on("data", (chunk: string) => {
761
+ buffer += chunk;
762
+ const lines = buffer.split("\n");
763
+ buffer = lines.pop() || "";
764
+ for (const line of lines) {
765
+ if (!line.trim()) continue;
766
+ try {
767
+ const event = JSON.parse(line);
768
+ // Tee all events to JSONL log if callback provided
769
+ opts.onJsonEvent?.(event);
770
+ if (event.type === "message_update") {
771
+ const delta = event.assistantMessageEvent;
772
+ if (delta?.type === "text_delta" && delta.delta) {
773
+ textChunks.push(delta.delta);
774
+ }
775
+ } else if (event.type === "tool_execution_start") {
776
+ opts.onToolCall?.(event.toolName, event.args);
777
+ } else if (event.type === "message_end") {
778
+ const usage = event.message?.usage;
779
+ if (usage) {
780
+ // Report per-turn token counts to caller (caller accumulates).
781
+ // Anthropic `input` = uncached new tokens only; `cacheRead`
782
+ // holds bulk of input. `cost.total` = exact dollar cost for turn.
783
+ opts.onTokenUpdate?.({
784
+ input: (usage as any).input || 0,
785
+ output: (usage as any).output || 0,
786
+ cacheRead: (usage as any).cacheRead || 0,
787
+ cacheWrite: (usage as any).cacheWrite || 0,
788
+ cost: (usage as any).cost?.total || 0,
789
+ });
790
+ if (opts.contextWindow) {
791
+ // Use totalTokens (cumulative) works across providers.
792
+ // Anthropic reports small `input` per-turn but growing `totalTokens`.
793
+ // OpenAI reports growing `input` but also growing `totalTokens`.
794
+ const tokens = (usage as any).totalTokens || ((usage as any).input + (usage as any).output) || 0;
795
+ if (tokens > 0) {
796
+ const pct = (tokens / opts.contextWindow) * 100;
797
+ opts.onContextPct?.(pct);
798
+ if (opts.warnPct && pct >= opts.warnPct && opts.wrapUpFile && !existsSync(opts.wrapUpFile)) {
799
+ writeFileSync(opts.wrapUpFile, `Wrap up at ${new Date().toISOString()}`);
800
+ }
801
+ if (opts.killPct && pct >= opts.killPct && !killed) {
802
+ killed = true;
803
+ proc.kill("SIGTERM");
804
+ }
805
+ }
806
+ }
807
+ }
808
+ }
809
+ } catch {}
810
+ }
811
+ });
812
+
813
+ proc.stderr?.setEncoding("utf-8");
814
+ proc.stderr?.on("data", () => {});
815
+
816
+ proc.on("close", (code) => {
817
+ cleanupTmp();
818
+ if (buffer.trim()) {
819
+ try {
820
+ const event = JSON.parse(buffer);
821
+ if (event.type === "message_update") {
822
+ const delta = event.assistantMessageEvent;
823
+ if (delta?.type === "text_delta") textChunks.push(delta.delta || "");
824
+ }
825
+ } catch {}
826
+ }
827
+ resolve({ output: textChunks.join(""), exitCode: code ?? 1, elapsed: Date.now() - startTime, killed });
828
+ });
829
+
830
+ proc.on("error", (err) => {
831
+ cleanupTmp();
832
+ resolve({ output: `Error: ${err.message}`, exitCode: 1, elapsed: Date.now() - startTime, killed: false });
833
+ });
834
+ });
835
+
836
+ return { promise, kill: () => killFn() };
837
+ }
838
+
839
+ // ── TMUX Agent Spawner ───────────────────────────────────────────────
840
+
841
+ /**
842
+ * Spawns a Pi agent in a named TMUX session instead of a headless subprocess.
843
+ * Returns the same interface shape as `spawnAgent()` for drop-in compatibility.
844
+ *
845
+ * Differences from subprocess mode:
846
+ * - No JSON event stream → no onToolCall/onContextPct callbacks
847
+ * - No captured output → output is always ""
848
+ * - Completion detected via `tmux has-session` polling (2s interval)
849
+ * - Kill via `tmux kill-session`
850
+ * - User can `tmux attach -t {sessionName}` for full visibility
851
+ *
852
+ * Temp files are cleaned up on all exit paths:
853
+ * - Normal completion (session ends, polling detects it)
854
+ * - Kill (explicit kill-session call)
855
+ * - TMUX not installed (throws with actionable message)
856
+ * - Session creation failure (throws after cleanup)
857
+ *
858
+ * Parity with spawnAgent():
859
+ * - Return shape: identical { promise, kill }
860
+ * - Promise result: identical fields { output, exitCode, elapsed, killed }
861
+ * - Kill semantics: sets killed=true, terminates session, cleans temp files
862
+ * - Elapsed calc: Date.now() - startTime (same pattern)
863
+ * - Cleanup: synchronous on all paths (more deterministic than spawnAgent's 1s setTimeout)
864
+ * - output: always "" (no JSON stream in TMUX mode)
865
+ * - exitCode: 0 on normal completion, 1 on poll error (TMUX doesn't forward exit codes)
866
+ *
867
+ * @param opts.sessionName TMUX session name (e.g., "orch-lane-1-worker")
868
+ * @param opts.cwd Working directory for the TMUX session
869
+ * @param opts.systemPrompt — System prompt content (written to temp file)
870
+ * @param opts.prompt — User prompt content (written to temp file)
871
+ * @param opts.model — Model identifier (e.g., "anthropic/claude-sonnet-4-20250514")
872
+ * @param opts.tools — Comma-separated tool list
873
+ * @param opts.thinking — Thinking mode ("off", "on", etc.)
874
+ */
875
+ function spawnAgentTmux(opts: {
876
+ sessionName: string;
877
+ cwd: string;
878
+ systemPrompt: string;
879
+ prompt: string;
880
+ model: string;
881
+ tools: string;
882
+ thinking: string;
883
+ }): { promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>; kill: () => void } {
884
+
885
+ // ── Preflight: verify tmux is available ──────────────────────────
886
+ const tmuxCheck = spawnSync("tmux", ["-V"], { shell: true });
887
+ if (tmuxCheck.status !== 0 && tmuxCheck.status !== null) {
888
+ throw new Error(
889
+ "tmux is not installed or not in PATH. " +
890
+ "Install tmux to use TMUX spawn mode, or set TASK_RUNNER_SPAWN_MODE=subprocess. " +
891
+ `(tmux -V exited with code ${tmuxCheck.status})`
892
+ );
893
+ }
894
+
895
+ // ── Write prompts to temp files ─────────────────────────────────
896
+ // Same pattern as spawnAgent() — avoids shell escaping issues with
897
+ // backticks, quotes, and special characters in markdown content.
898
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
899
+ const sysTmpFile = join(tmpdir(), `pi-task-sys-${id}.txt`);
900
+ const promptTmpFile = join(tmpdir(), `pi-task-prompt-${id}.txt`);
901
+ writeFileSync(sysTmpFile, opts.systemPrompt);
902
+ writeFileSync(promptTmpFile, opts.prompt);
903
+
904
+ const cleanupTmp = () => {
905
+ try { unlinkSync(sysTmpFile); } catch {}
906
+ try { unlinkSync(promptTmpFile); } catch {}
907
+ };
908
+
909
+ // ── Build Pi command ─────────────────────────────────────────────
910
+ // Use an array of arguments and quote each one individually to handle
911
+ // paths with spaces (Windows paths, temp dir, etc.). The command is
912
+ // passed as a single string to tmux new-session, so we shell-quote it.
913
+ const quoteArg = (s: string): string => {
914
+ // If the arg contains spaces, quotes, or shell metacharacters, wrap in single quotes.
915
+ // Inside single quotes, escape existing single quotes as '\'' (end quote, escaped quote, restart quote).
916
+ if (/[\s"'`$\\!&|;()<>{}#*?~]/.test(s)) {
917
+ return `'${s.replace(/'/g, "'\\''")}'`;
918
+ }
919
+ return s;
920
+ };
921
+
922
+ const piArgs = [
923
+ "pi",
924
+ "-p", // Non-interactive: process prompt and exit (without this, pi waits for more input)
925
+ "--no-session", "--no-extensions", "--no-skills",
926
+ "--model", quoteArg(opts.model),
927
+ "--tools", quoteArg(opts.tools),
928
+ "--thinking", quoteArg(opts.thinking),
929
+ "--append-system-prompt", quoteArg(sysTmpFile),
930
+ `@${quoteArg(promptTmpFile)}`,
931
+ ];
932
+ const piCommand = piArgs.join(" ");
933
+
934
+ // ── Handle stale session ─────────────────────────────────────────
935
+ // Session names are fixed per role (e.g., "orch-lane-1-worker").
936
+ // If a stale session from a previous iteration exists, kill it first.
937
+ const staleCheck = spawnSync("tmux", ["has-session", "-t", opts.sessionName]);
938
+ if (staleCheck.status === 0) {
939
+ console.error(`[task-runner] tmux: killing stale session '${opts.sessionName}'`);
940
+ spawnSync("tmux", ["kill-session", "-t", opts.sessionName]);
941
+ }
942
+
943
+ // ── Create TMUX session ─────────────────────────────────────────
944
+ // Use `cd <path> && TERM=xterm-256color <cmd>` wrapper instead of tmux `-c`
945
+ // because `-c` with Windows paths silently fails in MSYS2/Git Bash tmux.
946
+ // Pi's ink/react TUI hangs with TERM=tmux-256color (tmux default), so we
947
+ // force xterm-256color.
948
+ const tmuxCwd = opts.cwd.replace(/^([A-Za-z]):\\/, (_, d: string) => `/${d.toLowerCase()}/`).replace(/\\/g, "/");
949
+ const wrappedCommand = `cd ${quoteArg(tmuxCwd)} && TERM=xterm-256color ${piCommand}`;
950
+ const createResult = spawnSync("tmux", [
951
+ "new-session", "-d",
952
+ "-s", opts.sessionName,
953
+ wrappedCommand,
954
+ ]);
955
+
956
+ if (createResult.status !== 0) {
957
+ cleanupTmp();
958
+ const stderr = createResult.stderr?.toString().trim() || "unknown error";
959
+ console.error(`[task-runner] tmux: session '${opts.sessionName}' creation failed: ${stderr}`);
960
+ throw new Error(
961
+ `Failed to create TMUX session '${opts.sessionName}': ${stderr}. ` +
962
+ `Verify tmux is running and the session name is valid.`
963
+ );
964
+ }
965
+
966
+ console.error(`[task-runner] tmux: session '${opts.sessionName}' created (cwd: ${opts.cwd})`);
967
+
968
+
969
+ // ── Poll until session ends ─────────────────────────────────────
970
+ let killed = false;
971
+ const startTime = Date.now();
972
+
973
+ const promise = (async (): Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }> => {
974
+ try {
975
+ while (true) {
976
+ await new Promise(r => setTimeout(r, 2000));
977
+ const result = spawnSync("tmux", ["has-session", "-t", opts.sessionName]);
978
+ if (result.status !== 0) {
979
+ // Session no longer exists Pi exited, TMUX closed
980
+ break;
981
+ }
982
+ }
983
+ } catch (pollErr: any) {
984
+ // Polling failure clean up and report
985
+ console.error(`[task-runner] tmux: polling error for '${opts.sessionName}': ${pollErr?.message || pollErr}`);
986
+ cleanupTmp();
987
+ console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}' (poll-fail)`);
988
+ return {
989
+ output: `Polling error: ${pollErr?.message || pollErr}`,
990
+ exitCode: 1,
991
+ elapsed: Date.now() - startTime,
992
+ killed: false,
993
+ };
994
+ }
995
+
996
+ // Normal completion — clean up temp files
997
+ const elapsed = Date.now() - startTime;
998
+ console.error(`[task-runner] tmux: session '${opts.sessionName}' ended after ${Math.round(elapsed / 1000)}s${killed ? " (killed)" : ""}`);
999
+ cleanupTmp();
1000
+ console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}'`);
1001
+ return {
1002
+ output: "", // No captured output in TMUX mode
1003
+ exitCode: 0, // TMUX session exit is best-effort success
1004
+ elapsed,
1005
+ killed,
1006
+ };
1007
+ })();
1008
+
1009
+ // ── Kill function ───────────────────────────────────────────────
1010
+ const kill = () => {
1011
+ killed = true;
1012
+ console.error(`[task-runner] tmux: killing session '${opts.sessionName}'`);
1013
+ const killResult = spawnSync("tmux", ["kill-session", "-t", opts.sessionName]);
1014
+ if (killResult.status !== 0) {
1015
+ // Session may have already exited — not an error
1016
+ console.error(`[task-runner] tmux: session '${opts.sessionName}' already exited (kill was no-op)`);
1017
+ }
1018
+ cleanupTmp();
1019
+ console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}' (killed)`);
1020
+ };
1021
+
1022
+ return { promise, kill };
1023
+ }
1024
+
1025
+ // ── Display Helpers ──────────────────────────────────────────────────
1026
+
1027
+ function displayName(name: string): string {
1028
+ return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
1029
+ }
1030
+
1031
+ // ── Extension ────────────────────────────────────────────────────────
1032
+
1033
+ export default function (pi: ExtensionAPI) {
1034
+ let state = freshState();
1035
+ let widgetCtx: ExtensionContext | undefined;
1036
+
1037
+ // ── Widget Rendering ─────────────────────────────────────────────
1038
+
1039
+ function renderStepCard(step: StepInfo, colWidth: number, theme: any): string[] {
1040
+ const w = colWidth - 2;
1041
+ const trunc = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s;
1042
+
1043
+ const isRunning = state.currentStep === step.number && state.phase === "running";
1044
+ const statusColor = step.status === "complete" ? "success"
1045
+ : step.status === "in-progress" ? "accent" : "dim";
1046
+ const statusIcon = step.status === "complete" ? "✓"
1047
+ : step.status === "in-progress" ? "●" : "○";
1048
+
1049
+ const nameStr = theme.fg("accent", theme.bold(trunc(`Step ${step.number}`, w)));
1050
+ const nameVis = Math.min(`Step ${step.number}`.length, w);
1051
+
1052
+ const statusStr = `${statusIcon} ${trunc(step.name, w - 4)}`;
1053
+ const statusLine = theme.fg(statusColor, statusStr);
1054
+ const statusVis = Math.min(statusStr.length, w);
1055
+
1056
+ const progress = `${step.totalChecked}/${step.totalItems} ✓`;
1057
+ const progressLine = theme.fg(step.totalChecked === step.totalItems && step.totalItems > 0 ? "success" : "muted", progress);
1058
+ const progressVis = progress.length;
1059
+
1060
+ let extraStr = "";
1061
+ let extraVis = 0;
1062
+ if (isRunning && state.workerStatus === "running") {
1063
+ extraStr = theme.fg("accent", `iter ${state.workerIteration}`) + theme.fg("dim", ` ctx:${Math.round(state.workerContextPct)}%`);
1064
+ extraVis = `iter ${state.workerIteration} ctx:${Math.round(state.workerContextPct)}%`.length;
1065
+ } else if (isRunning && state.reviewerStatus === "running") {
1066
+ extraStr = theme.fg("warning", `reviewing...`);
1067
+ extraVis = "reviewing...".length;
1068
+ }
1069
+
1070
+ const top = "┌" + "─".repeat(w) + "┐";
1071
+ const bot = "└" + "─".repeat(w) + "";
1072
+ const border = (content: string, vis: number) =>
1073
+ theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - vis)) + theme.fg("dim", "│");
1074
+
1075
+ return [
1076
+ theme.fg("dim", top),
1077
+ border(" " + nameStr, 1 + nameVis),
1078
+ border(" " + statusLine, 1 + statusVis),
1079
+ border(" " + progressLine, 1 + progressVis),
1080
+ border(extraStr ? " " + extraStr : "", extraVis ? 1 + extraVis : 0),
1081
+ theme.fg("dim", bot),
1082
+ ];
1083
+ }
1084
+
1085
+ function updateWidgets() {
1086
+ // Write sidecar state for web dashboard (orchestrated mode)
1087
+ writeLaneState(state);
1088
+
1089
+ if (!widgetCtx) return;
1090
+ const ctx = widgetCtx;
1091
+
1092
+ // Refresh step statuses from STATUS.md if task is active
1093
+ if (state.task) {
1094
+ const statusPath = join(state.task.taskFolder, "STATUS.md");
1095
+ if (existsSync(statusPath)) {
1096
+ try {
1097
+ const parsed = parseStatusMd(readFileSync(statusPath, "utf-8"));
1098
+ for (const s of parsed.steps) state.stepStatuses.set(s.number, s);
1099
+ } catch {}
1100
+ }
1101
+ }
1102
+
1103
+ ctx.ui.setWidget("task-runner", (_tui: any, theme: any) => {
1104
+ return {
1105
+ render(width: number): string[] {
1106
+ if (!state.task) {
1107
+ return [];
1108
+ }
1109
+
1110
+ const task = state.task;
1111
+ const lines: string[] = [""];
1112
+
1113
+ // Header
1114
+ const phaseIcon = state.phase === "running" ? ""
1115
+ : state.phase === "paused" ? ""
1116
+ : state.phase === "complete" ? "✓"
1117
+ : state.phase === "error" ? "✗" : "○";
1118
+ const phaseColor = state.phase === "running" ? "accent"
1119
+ : state.phase === "complete" ? "success"
1120
+ : state.phase === "error" ? "error" : "dim";
1121
+
1122
+ const header =
1123
+ theme.fg(phaseColor, ` ${phaseIcon} `) +
1124
+ theme.fg("accent", theme.bold(task.taskId)) +
1125
+ theme.fg("dim", ": ") +
1126
+ theme.fg("muted", task.taskName) +
1127
+ theme.fg("dim", " ") +
1128
+ theme.fg("warning", `L${task.reviewLevel}`) +
1129
+ theme.fg("dim", " · ") +
1130
+ theme.fg("muted", task.size) +
1131
+ theme.fg("dim", " · ") +
1132
+ theme.fg("success", `iter ${state.totalIterations}`);
1133
+ lines.push(truncateToWidth(header, width));
1134
+
1135
+ // Progress bar
1136
+ const allSteps = task.steps.map(s => state.stepStatuses.get(s.number) || s);
1137
+ const totalCb = allSteps.reduce((a, s) => a + s.totalItems, 0);
1138
+ const doneCb = allSteps.reduce((a, s) => a + s.totalChecked, 0);
1139
+ const pct = totalCb > 0 ? Math.round((doneCb / totalCb) * 100) : 0;
1140
+ const barWidth = Math.min(30, width - 20);
1141
+ const filled = Math.round((pct / 100) * barWidth);
1142
+ const progressBar =
1143
+ theme.fg("dim", " ") +
1144
+ theme.fg("warning", "[") +
1145
+ theme.fg("success", "█".repeat(filled)) +
1146
+ theme.fg("dim", "░".repeat(barWidth - filled)) +
1147
+ theme.fg("warning", "]") +
1148
+ theme.fg("dim", " ") +
1149
+ theme.fg("accent", `${doneCb}/${totalCb}`) +
1150
+ theme.fg("dim", ` (${pct}%)`);
1151
+ lines.push(truncateToWidth(progressBar, width));
1152
+ lines.push("");
1153
+
1154
+ // Step cards fit as many as the terminal allows, wrap to rows
1155
+ const steps = allSteps;
1156
+ const arrowWidth = 3;
1157
+ // Calculate how many cards fit in one row
1158
+ const minCardWidth = 16;
1159
+ const maxCols = Math.max(1, Math.floor((width + arrowWidth) / (minCardWidth + arrowWidth)));
1160
+ const cols = Math.min(steps.length, maxCols);
1161
+ const colWidth = Math.max(minCardWidth, Math.floor((width - arrowWidth * (cols - 1)) / cols));
1162
+
1163
+ // Render in rows of `cols` cards
1164
+ for (let rowStart = 0; rowStart < steps.length; rowStart += cols) {
1165
+ const rowSteps = steps.slice(rowStart, rowStart + cols);
1166
+ const cards = rowSteps.map(s => renderStepCard(s, colWidth, theme));
1167
+
1168
+ if (cards.length > 0) {
1169
+ const cardHeight = cards[0].length;
1170
+ const arrowRow = 2;
1171
+ for (let line = 0; line < cardHeight; line++) {
1172
+ let row = cards[0][line];
1173
+ for (let c = 1; c < cards.length; c++) {
1174
+ row += line === arrowRow ? theme.fg("dim", " → ") : " ";
1175
+ row += cards[c][line];
1176
+ }
1177
+ lines.push(truncateToWidth(row, width));
1178
+ }
1179
+ }
1180
+ }
1181
+
1182
+ // Worker status line
1183
+ if (state.workerStatus === "running") {
1184
+ lines.push("");
1185
+ lines.push(truncateToWidth(
1186
+ theme.fg("accent", " ● Worker: ") +
1187
+ theme.fg("dim", `${Math.round(state.workerElapsed / 1000)}s · `) +
1188
+ theme.fg("dim", `🔧${state.workerToolCount}`) +
1189
+ (state.workerLastTool
1190
+ ? theme.fg("dim", " · ") + theme.fg("muted", state.workerLastTool)
1191
+ : ""),
1192
+ width,
1193
+ ));
1194
+ } else if (state.reviewerStatus === "running") {
1195
+ lines.push("");
1196
+ lines.push(truncateToWidth(
1197
+ theme.fg("warning", " ◉ Reviewer: ") +
1198
+ theme.fg("dim", `${state.reviewerType} · ${Math.round(state.reviewerElapsed / 1000)}s`) +
1199
+ (state.reviewerLastTool
1200
+ ? theme.fg("dim", " · ") + theme.fg("muted", state.reviewerLastTool)
1201
+ : ""),
1202
+ width,
1203
+ ));
1204
+ }
1205
+
1206
+ return lines;
1207
+ },
1208
+ invalidate() {},
1209
+ };
1210
+ });
1211
+ }
1212
+
1213
+ // ── Execution Engine ─────────────────────────────────────────────
1214
+
1215
+ async function executeTask(ctx: ExtensionContext): Promise<void> {
1216
+ if (!state.task || !state.config) return;
1217
+
1218
+ const task = state.task;
1219
+ const config = state.config;
1220
+ const statusPath = join(task.taskFolder, "STATUS.md");
1221
+
1222
+ updateStatusField(statusPath, "Status", "🟡 In Progress");
1223
+ updateStatusField(statusPath, "Last Updated", new Date().toISOString().slice(0, 10));
1224
+ logExecution(statusPath, "Task started", "Extension-driven execution");
1225
+
1226
+ // Find first incomplete step
1227
+ const status = parseStatusMd(readFileSync(statusPath, "utf-8"));
1228
+ let startStep = 0;
1229
+ for (const s of status.steps) {
1230
+ if (s.status === "complete") startStep = s.number + 1;
1231
+ else break;
1232
+ }
1233
+
1234
+ for (let i = 0; i < task.steps.length; i++) {
1235
+ const step = task.steps[i];
1236
+ if (step.number < startStep) continue;
1237
+ if (state.phase === "paused") {
1238
+ logExecution(statusPath, "Paused", `User paused at Step ${step.number}`);
1239
+ ctx.ui.notify(`Task paused at Step ${step.number}`, "info");
1240
+ return;
1241
+ }
1242
+
1243
+ state.currentStep = step.number;
1244
+ updateWidgets();
1245
+
1246
+ await executeStep(step, ctx);
1247
+
1248
+ if (state.phase === "error" || state.phase === "paused") return;
1249
+ }
1250
+
1251
+ // All done
1252
+ const donePath = join(task.taskFolder, ".DONE");
1253
+ writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${task.taskId}\n`);
1254
+ updateStatusField(statusPath, "Status", "✅ Complete");
1255
+ logExecution(statusPath, "Task complete", ".DONE created");
1256
+
1257
+ // Auto-archive: move task folder to tasks/archive/.
1258
+ // In orchestrated runs, do NOT archive here — the orchestrator polls
1259
+ // .DONE at the original path and handles post-merge archival itself.
1260
+ if (!isOrchestratedMode()) {
1261
+ const tasksDir = dirname(task.taskFolder);
1262
+ const archiveDir = join(tasksDir, "archive");
1263
+ const archiveDest = join(archiveDir, basename(task.taskFolder));
1264
+ try {
1265
+ if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
1266
+ const { renameSync } = require("fs");
1267
+ renameSync(task.taskFolder, archiveDest);
1268
+ logExecution(join(archiveDest, "STATUS.md"), "Archived", `Moved to ${archiveDest}`);
1269
+ ctx.ui.notify(`📦 Archived to ${archiveDest}`, "info");
1270
+ } catch (err: any) {
1271
+ ctx.ui.notify(`Archive failed (move manually): ${err?.message}`, "warning");
1272
+ }
1273
+ } else {
1274
+ ctx.ui.notify("ℹ️ Orchestrated run: skipping auto-archive (orchestrator handles archival)", "info");
1275
+ }
1276
+
1277
+ state.phase = "complete";
1278
+ updateWidgets();
1279
+ ctx.ui.notify(`✅ Task ${task.taskId} complete!`, "success");
1280
+ }
1281
+
1282
+ async function executeStep(step: StepInfo, ctx: ExtensionContext): Promise<void> {
1283
+ if (!state.task || !state.config) return;
1284
+
1285
+ const task = state.task;
1286
+ const config = state.config;
1287
+ const statusPath = join(task.taskFolder, "STATUS.md");
1288
+
1289
+ // Capture git HEAD before the step starts so code reviewers can
1290
+ // diff the full step's changes (workers commit via checkpoints).
1291
+ const stepBaselineCommit = getHeadCommitSha();
1292
+
1293
+ updateStepStatus(statusPath, step.number, "in-progress");
1294
+ updateStatusField(statusPath, "Current Step", `Step ${step.number}: ${step.name}`);
1295
+ logExecution(statusPath, `Step ${step.number} started`, step.name);
1296
+ updateWidgets();
1297
+
1298
+ // Plan review (level ≥ 1)
1299
+ if (task.reviewLevel >= 1) {
1300
+ const verdict = await doReview("plan", step, ctx, stepBaselineCommit);
1301
+ if (verdict === "RETHINK") {
1302
+ ctx.ui.notify(`Reviewer: RETHINK on Step ${step.number} plan. Proceeding with caution.`, "warning");
1303
+ }
1304
+ }
1305
+
1306
+ // Worker loop
1307
+ let noProgressCount = 0;
1308
+ for (let iter = 0; iter < config.context.max_worker_iterations; iter++) {
1309
+ if (state.phase === "paused") return;
1310
+
1311
+ // Re-read STATUS.md
1312
+ const currentStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
1313
+ const stepStatus = currentStatus.steps.find(s => s.number === step.number);
1314
+ if (stepStatus?.status === "complete" || (stepStatus && stepStatus.totalChecked === stepStatus.totalItems && stepStatus.totalItems > 0)) {
1315
+ updateStepStatus(statusPath, step.number, "complete");
1316
+ break;
1317
+ }
1318
+
1319
+ const prevChecked = stepStatus?.totalChecked || 0;
1320
+ state.workerIteration = iter + 1;
1321
+ state.totalIterations++;
1322
+ updateStatusField(statusPath, "Iteration", `${state.totalIterations}`);
1323
+ updateWidgets();
1324
+
1325
+ await runWorker(step, ctx);
1326
+
1327
+ // Check progress
1328
+ const afterStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
1329
+ const afterStep = afterStatus.steps.find(s => s.number === step.number);
1330
+ const afterChecked = afterStep?.totalChecked || 0;
1331
+
1332
+ if (afterChecked <= prevChecked) {
1333
+ noProgressCount++;
1334
+ if (noProgressCount >= config.context.no_progress_limit) {
1335
+ logExecution(statusPath, `Step ${step.number} blocked`, `No progress after ${noProgressCount} iterations`);
1336
+ ctx.ui.notify(`⚠️ Step ${step.number} blocked — no progress after ${noProgressCount} iterations`, "error");
1337
+ state.phase = "error";
1338
+ return;
1339
+ }
1340
+ } else {
1341
+ noProgressCount = 0;
1342
+ }
1343
+
1344
+ if (afterStep?.status === "complete" || (afterStep && afterStep.totalChecked === afterStep.totalItems && afterStep.totalItems > 0)) {
1345
+ updateStepStatus(statusPath, step.number, "complete");
1346
+ break;
1347
+ }
1348
+ }
1349
+
1350
+ // Code review (level 2)
1351
+ if (task.reviewLevel >= 2 && state.phase === "running") {
1352
+ const verdict = await doReview("code", step, ctx, stepBaselineCommit);
1353
+ if (verdict === "REVISE") {
1354
+ ctx.ui.notify(`Reviewer: REVISE on Step ${step.number}. Running worker to fix...`, "warning");
1355
+ await runWorker(step, ctx); // One more pass to address issues
1356
+ }
1357
+ }
1358
+
1359
+ if (state.phase === "running") {
1360
+ updateStepStatus(statusPath, step.number, "complete");
1361
+ logExecution(statusPath, `Step ${step.number} complete`, step.name);
1362
+ // Update local cache
1363
+ const refreshed = parseStatusMd(readFileSync(statusPath, "utf-8"));
1364
+ for (const s of refreshed.steps) state.stepStatuses.set(s.number, s);
1365
+ updateWidgets();
1366
+ }
1367
+ }
1368
+
1369
+ // ── Worker ───────────────────────────────────────────────────────
1370
+
1371
+ async function runWorker(step: StepInfo, ctx: ExtensionContext): Promise<void> {
1372
+ if (!state.task || !state.config) return;
1373
+
1374
+ const task = state.task;
1375
+ const config = state.config;
1376
+ const statusPath = join(task.taskFolder, "STATUS.md");
1377
+ const wrapUpFile = join(task.taskFolder, ".task-wrap-up");
1378
+ const legacyWrapUpFile = join(task.taskFolder, ".wiggum-wrap-up");
1379
+
1380
+ const clearWrapUpSignals = () => {
1381
+ if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch {}
1382
+ if (existsSync(legacyWrapUpFile)) try { unlinkSync(legacyWrapUpFile); } catch {}
1383
+ };
1384
+
1385
+ const writeWrapUpSignal = (reason: string) => {
1386
+ const msg = `${reason} at ${new Date().toISOString()}`;
1387
+ if (!existsSync(wrapUpFile)) writeFileSync(wrapUpFile, msg);
1388
+ // Backward compatibility: write legacy signal too until all workers migrate.
1389
+ if (!existsSync(legacyWrapUpFile)) writeFileSync(legacyWrapUpFile, msg);
1390
+ };
1391
+
1392
+ clearWrapUpSignals();
1393
+
1394
+ const workerDef = loadAgentDef(ctx.cwd, "task-worker");
1395
+ const basePrompt = workerDef?.systemPrompt || "You are a task execution agent. Read STATUS.md first, find unchecked items, work on them, checkpoint after each.";
1396
+ const systemPrompt = basePrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
1397
+
1398
+ const model = config.worker.model
1399
+ || workerDef?.model
1400
+ || (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514");
1401
+
1402
+ const contextDocsList = task.contextDocs.length > 0
1403
+ ? "\n\nContext docs to read if needed:\n" + task.contextDocs.map(d => `- ${d}`).join("\n")
1404
+ : "";
1405
+
1406
+ // When running under the parallel orchestrator, workers must NOT
1407
+ // archive or move the task folder — the orchestrator polls for .DONE
1408
+ // at the original path and handles post-merge archival itself.
1409
+ const archiveSuppression = isOrchestratedMode()
1410
+ ? "\n\n⚠️ ORCHESTRATED RUN: Do NOT archive or move the task folder. " +
1411
+ "Do NOT rename, relocate, or reorganize the task folder path. " +
1412
+ "The orchestrator handles post-merge archival. " +
1413
+ "Just create the .DONE file in the task folder when complete."
1414
+ : "";
1415
+
1416
+ const prompt = [
1417
+ `Execute Step ${step.number}: ${step.name}`,
1418
+ ``,
1419
+ `Task: ${task.taskId} ${task.taskName}`,
1420
+ `Task folder: ${task.taskFolder}/`,
1421
+ `PROMPT: ${task.promptPath}`,
1422
+ `STATUS: ${statusPath}`,
1423
+ ``,
1424
+ `This is iteration ${state.totalIterations}.`,
1425
+ `Read STATUS.md FIRST to find where you left off.`,
1426
+ `Work ONLY on Step ${step.number}. Do not proceed to other steps.`,
1427
+ ``,
1428
+ `Wrap-up signal files: ${wrapUpFile} (primary), ${legacyWrapUpFile} (legacy)`,
1429
+ `Check for either file after each checkpoint. If one exists, stop.`,
1430
+ archiveSuppression,
1431
+ contextDocsList,
1432
+ ].join("\n");
1433
+
1434
+ state.workerStatus = "running";
1435
+ state.workerElapsed = 0;
1436
+ state.workerContextPct = 0;
1437
+ state.workerLastTool = "";
1438
+ state.workerToolCount = 0;
1439
+ updateWidgets();
1440
+
1441
+ const startTime = Date.now();
1442
+ state.workerTimer = setInterval(() => {
1443
+ state.workerElapsed = Date.now() - startTime;
1444
+ updateWidgets();
1445
+ }, 1000);
1446
+
1447
+ const spawnMode = getSpawnMode(config);
1448
+ let promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
1449
+ let kill: () => void;
1450
+ let wallClockWarnTimer: ReturnType<typeof setTimeout> | null = null;
1451
+ let wallClockKillTimer: ReturnType<typeof setTimeout> | null = null;
1452
+
1453
+ if (spawnMode === "tmux") {
1454
+ // ── TMUX mode ────────────────────────────────────────
1455
+ // No JSON stream → no onToolCall/onContextPct callbacks.
1456
+ // Kill via wall-clock timeout instead of context-%.
1457
+ const sessionName = `${getTmuxPrefix()}-worker`;
1458
+ const spawned = spawnAgentTmux({
1459
+ sessionName,
1460
+ cwd: ctx.cwd,
1461
+ systemPrompt,
1462
+ prompt,
1463
+ model,
1464
+ tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
1465
+ thinking: config.worker.thinking || "off",
1466
+ });
1467
+ promise = spawned.promise;
1468
+ kill = spawned.kill;
1469
+
1470
+ // Wall-clock timeout: write wrap-up file at 80% of limit,
1471
+ // hard kill at 100%. No context telemetry in TMUX mode.
1472
+ const maxMinutes = getMaxWorkerMinutes(config);
1473
+ const warnMs = Math.round(maxMinutes * 0.8 * 60_000);
1474
+ const killMs = maxMinutes * 60_000;
1475
+ const iterationMarker = state.totalIterations;
1476
+
1477
+ // Wrap-up warning at 80% of wall-clock limit
1478
+ wallClockWarnTimer = setTimeout(() => {
1479
+ if (
1480
+ state.workerStatus === "running" &&
1481
+ state.totalIterations === iterationMarker
1482
+ ) {
1483
+ writeWrapUpSignal(`Wrap up (wall-clock ${maxMinutes}min limit)`);
1484
+ }
1485
+ }, warnMs);
1486
+
1487
+ // Hard kill at 100% of wall-clock limit
1488
+ wallClockKillTimer = setTimeout(() => {
1489
+ if (state.workerStatus === "running" && state.totalIterations === iterationMarker) {
1490
+ console.error(`[task-runner] tmux worker: wall-clock timeout (${maxMinutes}min) — killing session '${sessionName}'`);
1491
+ kill();
1492
+ }
1493
+ }, killMs);
1494
+ } else {
1495
+ // ── Subprocess mode (default, unchanged) ─────────────
1496
+ // In orchestrated mode, tee conversation events to JSONL for web dashboard
1497
+ const conversationPrefix = isOrchestratedMode() ? getTmuxPrefix() : null;
1498
+ if (conversationPrefix) clearConversationLog(conversationPrefix);
1499
+
1500
+ const spawned = spawnAgent({
1501
+ model,
1502
+ tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
1503
+ thinking: config.worker.thinking || "off",
1504
+ systemPrompt,
1505
+ prompt,
1506
+ contextWindow: config.context.worker_context_window,
1507
+ warnPct: config.context.warn_percent,
1508
+ killPct: config.context.kill_percent,
1509
+ wrapUpFile,
1510
+ onToolCall: (toolName, args) => {
1511
+ state.workerToolCount++;
1512
+ // Build a short summary of what the tool is doing
1513
+ const path = args?.path || args?.command || "";
1514
+ const shortPath = typeof path === "string" && path.length > 80
1515
+ ? "..." + path.slice(-77) : path;
1516
+ state.workerLastTool = `${toolName} ${shortPath}`.trim();
1517
+ if (conversationPrefix) {
1518
+ appendConversationEvent(conversationPrefix, {
1519
+ type: "tool_call", toolName, args, timestamp: Date.now(),
1520
+ });
1521
+ }
1522
+ updateWidgets();
1523
+ },
1524
+ onTokenUpdate: (tokens) => {
1525
+ // Accumulate across turns — each message_end reports per-turn values.
1526
+ // Anthropic's `input` is only uncached new tokens; cacheRead holds
1527
+ // the bulk of input processing. We sum all four independently so the
1528
+ // dashboard can show the full picture.
1529
+ state.workerInputTokens += tokens.input;
1530
+ state.workerOutputTokens += tokens.output;
1531
+ state.workerCacheReadTokens += tokens.cacheRead;
1532
+ state.workerCacheWriteTokens += tokens.cacheWrite;
1533
+ state.workerCostUsd += tokens.cost;
1534
+ updateWidgets();
1535
+ },
1536
+ onContextPct: (pct) => {
1537
+ state.workerContextPct = pct;
1538
+ if (pct >= config.context.warn_percent) {
1539
+ writeWrapUpSignal(`Wrap up (context ${Math.round(pct)}%)`);
1540
+ }
1541
+ updateWidgets();
1542
+ },
1543
+ onJsonEvent: conversationPrefix
1544
+ ? (event: Record<string, unknown>) => appendConversationEvent(conversationPrefix, event)
1545
+ : undefined,
1546
+ });
1547
+ promise = spawned.promise;
1548
+ kill = spawned.kill;
1549
+ }
1550
+
1551
+ state.workerProc = { kill };
1552
+
1553
+ const result = await promise;
1554
+
1555
+ // Clean up wall-clock timers if they haven't fired yet
1556
+ if (wallClockWarnTimer) clearTimeout(wallClockWarnTimer);
1557
+ if (wallClockKillTimer) clearTimeout(wallClockKillTimer);
1558
+
1559
+ clearInterval(state.workerTimer);
1560
+ state.workerElapsed = Date.now() - startTime;
1561
+ state.workerStatus = result.killed ? "killed" : (result.exitCode === 0 ? "done" : "error");
1562
+ state.workerProc = null;
1563
+
1564
+ clearWrapUpSignals();
1565
+
1566
+ // Log with mode-appropriate detail: subprocess has context%, TMUX does not
1567
+ const killedMsg = spawnMode === "tmux" ? "killed (wall-clock timeout)" : "killed (context limit)";
1568
+ const statusMsg = result.killed ? killedMsg : (result.exitCode === 0 ? "done" : `error (code ${result.exitCode})`);
1569
+ const ctxDetail = spawnMode === "tmux" ? "" : `, ctx: ${Math.round(state.workerContextPct)}%`;
1570
+ logExecution(statusPath, `Worker iter ${state.totalIterations}`,
1571
+ `${statusMsg} in ${Math.round(state.workerElapsed / 1000)}s${ctxDetail}, tools: ${state.workerToolCount}`);
1572
+
1573
+ updateWidgets();
1574
+ }
1575
+
1576
+ // ── Reviewer ─────────────────────────────────────────────────────
1577
+
1578
+ async function doReview(type: "plan" | "code", step: StepInfo, ctx: ExtensionContext, stepBaselineCommit?: string): Promise<string> {
1579
+ if (!state.task || !state.config) return "UNKNOWN";
1580
+
1581
+ const task = state.task;
1582
+ const config = state.config;
1583
+ const statusPath = join(task.taskFolder, "STATUS.md");
1584
+ const reviewsDir = join(task.taskFolder, ".reviews");
1585
+ if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
1586
+
1587
+ state.reviewCounter++;
1588
+ const num = String(state.reviewCounter).padStart(3, "0");
1589
+ const requestPath = join(reviewsDir, `request-R${num}.md`);
1590
+ const outputPath = join(reviewsDir, `R${num}-${type}-step${step.number}.md`);
1591
+
1592
+ const request = generateReviewRequest(type, step.number, step.name, task, config, outputPath, stepBaselineCommit);
1593
+ writeFileSync(requestPath, request);
1594
+
1595
+ const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
1596
+ const reviewerModel = config.reviewer.model || reviewerDef?.model || "openai/gpt-5.3-codex";
1597
+ const reviewerPrompt = reviewerDef?.systemPrompt || "You are a code reviewer. Read the request and write your review to the specified output file.";
1598
+ const systemPrompt = reviewerPrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
1599
+
1600
+ state.reviewerStatus = "running";
1601
+ state.reviewerType = `${type} review`;
1602
+ state.reviewerElapsed = 0;
1603
+ state.reviewerLastTool = "";
1604
+ updateWidgets();
1605
+
1606
+ const startTime = Date.now();
1607
+ state.reviewerTimer = setInterval(() => {
1608
+ state.reviewerElapsed = Date.now() - startTime;
1609
+ updateWidgets();
1610
+ }, 1000);
1611
+
1612
+ // Read the request file content as the prompt
1613
+ const promptContent = readFileSync(requestPath, "utf-8");
1614
+
1615
+ const spawnMode = getSpawnMode(config);
1616
+ let reviewPromise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
1617
+
1618
+ if (spawnMode === "tmux") {
1619
+ // ── TMUX mode ────────────────────────────────────────
1620
+ // No JSON stream → no onToolCall callback.
1621
+ // No timeout — reviewer runs to session completion.
1622
+ const sessionName = `${getTmuxPrefix()}-reviewer`;
1623
+ const spawned = spawnAgentTmux({
1624
+ sessionName,
1625
+ cwd: ctx.cwd,
1626
+ systemPrompt,
1627
+ prompt: promptContent,
1628
+ model: reviewerModel,
1629
+ tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
1630
+ thinking: config.reviewer.thinking || "on",
1631
+ });
1632
+ reviewPromise = spawned.promise;
1633
+ state.reviewerProc = { kill: spawned.kill };
1634
+ } else {
1635
+ // ── Subprocess mode (default, unchanged) ─────────────
1636
+ const spawned = spawnAgent({
1637
+ model: reviewerModel,
1638
+ tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
1639
+ thinking: config.reviewer.thinking || "on",
1640
+ systemPrompt,
1641
+ prompt: promptContent,
1642
+ onToolCall: (toolName, args) => {
1643
+ const path = args?.path || args?.command || "";
1644
+ const shortPath = typeof path === "string" && path.length > 40
1645
+ ? "..." + path.slice(-37) : path;
1646
+ state.reviewerLastTool = `${toolName} ${shortPath}`.trim();
1647
+ updateWidgets();
1648
+ },
1649
+ });
1650
+ reviewPromise = spawned.promise;
1651
+ state.reviewerProc = { kill: spawned.kill };
1652
+ }
1653
+
1654
+ const result = await reviewPromise;
1655
+
1656
+ clearInterval(state.reviewerTimer);
1657
+ state.reviewerElapsed = Date.now() - startTime;
1658
+ state.reviewerStatus = result.exitCode === 0 ? "done" : "error";
1659
+ state.reviewerProc = null;
1660
+ updateWidgets();
1661
+
1662
+ // Read verdict
1663
+ let verdict = "UNKNOWN";
1664
+ if (existsSync(outputPath)) {
1665
+ const review = readFileSync(outputPath, "utf-8");
1666
+ verdict = extractVerdict(review);
1667
+ } else {
1668
+ verdict = "UNAVAILABLE";
1669
+ logExecution(statusPath, `Reviewer R${num}`, `${type} review — reviewer did not produce output`);
1670
+ }
1671
+
1672
+ logReview(statusPath, `R${num}`, type, step.number, verdict, `.reviews/R${num}-${type}-step${step.number}.md`);
1673
+ logExecution(statusPath, `Review R${num}`, `${type} Step ${step.number}: ${verdict}`);
1674
+ updateStatusField(statusPath, "Review Counter", `${state.reviewCounter}`);
1675
+
1676
+ ctx.ui.notify(`Review R${num} (${type} Step ${step.number}): ${verdict}`, verdict === "APPROVE" ? "success" : "warning");
1677
+
1678
+ return verdict;
1679
+ }
1680
+
1681
+ // ── Commands ─────────────────────────────────────────────────────
1682
+
1683
+ // ── Shared Task Initialization ───────────────────────────────────
1684
+ //
1685
+ // Extracts the core init logic used by both the `/task` command and
1686
+ // TASK_AUTOSTART so that they share a single code path. Returns true
1687
+ // if the task was started successfully.
1688
+
1689
+ function startTaskFromPath(ctx: ExtensionContext, fullPath: string): boolean {
1690
+ if (state.phase === "running") {
1691
+ ctx.ui.notify("A task is already running. Use /task-pause first.", "warning");
1692
+ return false;
1693
+ }
1694
+
1695
+ // Parse PROMPT.md
1696
+ let parsed: ParsedTask;
1697
+ try {
1698
+ const content = readFileSync(fullPath, "utf-8");
1699
+ parsed = parsePromptMd(content, fullPath);
1700
+ } catch (err: any) {
1701
+ ctx.ui.notify(`Failed to parse PROMPT.md: ${err?.message || err}`, "error");
1702
+ return false;
1703
+ }
1704
+
1705
+ state = freshState();
1706
+ state.task = parsed;
1707
+ state.config = loadConfig(ctx.cwd);
1708
+ state.phase = "running";
1709
+ widgetCtx = ctx;
1710
+
1711
+ // Generate STATUS.md if missing
1712
+ const statusPath = join(state.task.taskFolder, "STATUS.md");
1713
+ if (!existsSync(statusPath)) {
1714
+ writeFileSync(statusPath, generateStatusMd(state.task));
1715
+ ctx.ui.notify("Generated STATUS.md from PROMPT.md", "info");
1716
+ } else {
1717
+ // Sync review counter and iteration from existing STATUS
1718
+ const existing = parseStatusMd(readFileSync(statusPath, "utf-8"));
1719
+ state.reviewCounter = existing.reviewCounter;
1720
+ state.totalIterations = existing.iteration;
1721
+ for (const s of existing.steps) state.stepStatuses.set(s.number, s);
1722
+ }
1723
+
1724
+ // Create .reviews/ if missing
1725
+ const reviewsDir = join(state.task.taskFolder, ".reviews");
1726
+ if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
1727
+
1728
+ updateWidgets();
1729
+ ctx.ui.notify(
1730
+ `Starting: ${state.task.taskId} — ${state.task.taskName}\n` +
1731
+ `Review Level: ${state.task.reviewLevel} · Size: ${state.task.size} · Steps: ${state.task.steps.length}\n` +
1732
+ `Worker model: ${state.config.worker.model || "inherit"} · Reviewer: ${state.config.reviewer.model}`,
1733
+ "info",
1734
+ );
1735
+
1736
+ // Fire-and-forget
1737
+ executeTask(ctx).catch(err => {
1738
+ state.phase = "error";
1739
+ ctx.ui.notify(`Task error: ${err?.message || err}`, "error");
1740
+ updateWidgets();
1741
+ });
1742
+
1743
+ return true;
1744
+ }
1745
+
1746
+ pi.registerCommand("task", {
1747
+ description: "Start executing a task: /task <path/to/PROMPT.md>",
1748
+ handler: async (args, ctx) => {
1749
+ widgetCtx = ctx;
1750
+ const promptPath = args?.trim();
1751
+ if (!promptPath) {
1752
+ ctx.ui.notify("Usage: /task <path/to/PROMPT.md>", "error");
1753
+ return;
1754
+ }
1755
+
1756
+ const fullPath = resolve(ctx.cwd, promptPath);
1757
+ if (!existsSync(fullPath)) {
1758
+ ctx.ui.notify(`File not found: ${promptPath}`, "error");
1759
+ return;
1760
+ }
1761
+
1762
+ startTaskFromPath(ctx, fullPath);
1763
+ },
1764
+ });
1765
+
1766
+ pi.registerCommand("task-status", {
1767
+ description: "Show current task progress",
1768
+ handler: async (_args, ctx) => {
1769
+ widgetCtx = ctx;
1770
+ if (!state.task) {
1771
+ ctx.ui.notify("No task loaded. Use /task <path/to/PROMPT.md>", "info");
1772
+ return;
1773
+ }
1774
+
1775
+ const statusPath = join(state.task.taskFolder, "STATUS.md");
1776
+ if (!existsSync(statusPath)) {
1777
+ ctx.ui.notify("STATUS.md not found", "error");
1778
+ return;
1779
+ }
1780
+
1781
+ const parsed = parseStatusMd(readFileSync(statusPath, "utf-8"));
1782
+ const lines = parsed.steps.map(s => {
1783
+ const icon = s.status === "complete" ? "✅" : s.status === "in-progress" ? "🟨" : "⬜";
1784
+ return `${icon} Step ${s.number}: ${s.name} (${s.totalChecked}/${s.totalItems})`;
1785
+ });
1786
+
1787
+ ctx.ui.notify(
1788
+ `${state.task.taskId}: ${state.task.taskName}\n` +
1789
+ `Phase: ${state.phase} · Iteration: ${state.totalIterations} · Reviews: ${state.reviewCounter}\n\n` +
1790
+ lines.join("\n"),
1791
+ "info",
1792
+ );
1793
+
1794
+ // Refresh widget
1795
+ for (const s of parsed.steps) state.stepStatuses.set(s.number, s);
1796
+ updateWidgets();
1797
+ },
1798
+ });
1799
+
1800
+ pi.registerCommand("task-pause", {
1801
+ description: "Pause task after current worker finishes",
1802
+ handler: async (_args, ctx) => {
1803
+ widgetCtx = ctx;
1804
+ if (state.phase !== "running") {
1805
+ ctx.ui.notify("No task is running", "warning");
1806
+ return;
1807
+ }
1808
+ state.phase = "paused";
1809
+ ctx.ui.notify("Task will pause after current worker finishes", "info");
1810
+ updateWidgets();
1811
+ },
1812
+ });
1813
+
1814
+ pi.registerCommand("task-resume", {
1815
+ description: "Resume a paused task",
1816
+ handler: async (_args, ctx) => {
1817
+ widgetCtx = ctx;
1818
+ if (state.phase !== "paused") {
1819
+ ctx.ui.notify("Task is not paused", "warning");
1820
+ return;
1821
+ }
1822
+ if (!state.task) {
1823
+ ctx.ui.notify("No task loaded", "error");
1824
+ return;
1825
+ }
1826
+
1827
+ state.phase = "running";
1828
+ ctx.ui.notify(`Resuming ${state.task.taskId}...`, "info");
1829
+ updateWidgets();
1830
+
1831
+ executeTask(ctx).catch(err => {
1832
+ state.phase = "error";
1833
+ ctx.ui.notify(`Task error: ${err?.message || err}`, "error");
1834
+ updateWidgets();
1835
+ });
1836
+ },
1837
+ });
1838
+
1839
+ // ── Session Lifecycle ────────────────────────────────────────────
1840
+
1841
+ pi.on("session_start", async (_event, ctx) => {
1842
+ widgetCtx = ctx;
1843
+
1844
+ // Kill any running subprocesses
1845
+ if (state.workerProc) try { state.workerProc.kill(); } catch {}
1846
+ if (state.reviewerProc) try { state.reviewerProc.kill(); } catch {}
1847
+ if (state.workerTimer) clearInterval(state.workerTimer);
1848
+ if (state.reviewerTimer) clearInterval(state.reviewerTimer);
1849
+
1850
+ // Keep task state if resuming, but reset runtime state
1851
+ const hadTask = state.task;
1852
+ if (hadTask) {
1853
+ state.phase = "paused";
1854
+ state.workerStatus = "idle";
1855
+ state.reviewerStatus = "idle";
1856
+ state.workerProc = null;
1857
+ state.reviewerProc = null;
1858
+ // Refresh from STATUS.md
1859
+ const statusPath = join(hadTask.taskFolder, "STATUS.md");
1860
+ if (existsSync(statusPath)) {
1861
+ const parsed = parseStatusMd(readFileSync(statusPath, "utf-8"));
1862
+ state.reviewCounter = parsed.reviewCounter;
1863
+ state.totalIterations = parsed.iteration;
1864
+ for (const s of parsed.steps) state.stepStatuses.set(s.number, s);
1865
+ }
1866
+ }
1867
+
1868
+ updateWidgets();
1869
+
1870
+ const config = loadConfig(ctx.cwd);
1871
+ ctx.ui.setStatus("task-runner", `📋 ${config.project.name}`);
1872
+
1873
+ if (hadTask) {
1874
+ ctx.ui.notify(`Task ${hadTask.taskId} loaded (paused). Use /task-resume to continue.`, "info");
1875
+ } else if (process.env.TASK_AUTOSTART) {
1876
+ // ── TASK_AUTOSTART ────────────────────────────────────────
1877
+ // When set, automatically start a task as if the user typed
1878
+ // `/task <path>`. Used by the parallel orchestrator to launch
1879
+ // workers in TMUX sessions without send-keys timing issues.
1880
+ const autoPath = process.env.TASK_AUTOSTART;
1881
+ const fullPath = resolve(ctx.cwd, autoPath);
1882
+ if (!existsSync(fullPath)) {
1883
+ ctx.ui.notify(`TASK_AUTOSTART: file not found — ${fullPath}`, "error");
1884
+ } else {
1885
+ ctx.ui.notify(`TASK_AUTOSTART: ${fullPath}`, "info");
1886
+ startTaskFromPath(ctx, fullPath);
1887
+ }
1888
+ } else {
1889
+ ctx.ui.notify(
1890
+ `Task Runner ready — ${config.project.name}\n\n` +
1891
+ `/task <path/to/PROMPT.md> Start a task\n` +
1892
+ `/task-status Show progress\n` +
1893
+ `/task-pause Pause execution\n` +
1894
+ `/task-resume Resume execution`,
1895
+ "info",
1896
+ );
1897
+ }
1898
+ });
1899
+ }