taskplane 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Persistent Reviewer Extension — TP-057
3
+ *
4
+ * Provides the `wait_for_review` tool that enables a reviewer agent to stay
5
+ * alive across multiple review requests within a single task. The tool blocks
6
+ * (via filesystem polling) until the task-runner signals a new review request
7
+ * or shutdown.
8
+ *
9
+ * Signal protocol:
10
+ * - `.reviews/.review-signal-{NNN}` — new review request available
11
+ * - `.reviews/.review-shutdown` — reviewer should exit cleanly
12
+ * - `.reviews/request-R{NNN}.md` — review request content
13
+ *
14
+ * Environment:
15
+ * - REVIEWER_SIGNAL_DIR — path to .reviews/ directory (required)
16
+ */
17
+
18
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
19
+ import { Type } from "@mariozechner/pi-ai";
20
+ import { existsSync, readFileSync } from "fs";
21
+ import { join } from "path";
22
+ import {
23
+ REVIEWER_POLL_INTERVAL_MS,
24
+ REVIEWER_WAIT_TIMEOUT_MS,
25
+ REVIEWER_SHUTDOWN_SIGNAL,
26
+ REVIEWER_SIGNAL_PREFIX,
27
+ } from "./taskplane/types.ts";
28
+
29
+ // ── Extension ────────────────────────────────────────────────────────
30
+
31
+ export default function reviewerExtension(pi: ExtensionAPI) {
32
+ const signalDir = process.env.REVIEWER_SIGNAL_DIR;
33
+
34
+ if (!signalDir) {
35
+ // Not running in persistent reviewer mode — skip tool registration.
36
+ // This allows the extension to be loaded in non-persistent contexts
37
+ // without error (fallback fresh-spawn mode).
38
+ return;
39
+ }
40
+
41
+ /** Counter tracking which signal number to watch for next. */
42
+ let nextSignalNum = 1;
43
+
44
+ pi.registerTool({
45
+ name: "wait_for_review",
46
+ label: "Wait for Review",
47
+ description:
48
+ "Block until the next review request is available, then return its content. " +
49
+ "Call this after completing each review to wait for the next one. " +
50
+ "Returns 'SHUTDOWN' when the task is complete and you should exit.",
51
+ promptSnippet: "wait_for_review() — block until the next review request arrives (persistent reviewer mode)",
52
+ promptGuidelines: [
53
+ "Call wait_for_review() to receive each review request.",
54
+ "After writing your review to the specified output file, call wait_for_review() again.",
55
+ "When it returns 'SHUTDOWN', exit cleanly — the task is complete.",
56
+ "Reference your previous reviews when relevant (e.g., 'I flagged X in Step 1 — checking if addressed').",
57
+ ],
58
+ parameters: Type.Object({}),
59
+ async execute() {
60
+ const startTime = Date.now();
61
+ const signalNum = String(nextSignalNum).padStart(3, "0");
62
+ const signalPath = join(signalDir, `${REVIEWER_SIGNAL_PREFIX}${signalNum}`);
63
+ const shutdownPath = join(signalDir, REVIEWER_SHUTDOWN_SIGNAL);
64
+
65
+ // Poll for signal file or shutdown
66
+ while (true) {
67
+ // Check for shutdown signal first
68
+ if (existsSync(shutdownPath)) {
69
+ return {
70
+ content: [{ type: "text" as const, text: "SHUTDOWN — The task is complete. Exit cleanly." }],
71
+ details: undefined,
72
+ };
73
+ }
74
+
75
+ // Check for review signal
76
+ if (existsSync(signalPath)) {
77
+ // Signal found — read the request file path from signal content.
78
+ // Signal file content is the request filename (e.g., "request-R003.md").
79
+ const signalContent = readFileSync(signalPath, "utf-8").trim();
80
+ const requestPath = join(signalDir, signalContent);
81
+
82
+ if (!existsSync(requestPath)) {
83
+ // Signal fired but request file doesn't exist (race condition or error)
84
+ return {
85
+ content: [{
86
+ type: "text" as const,
87
+ text: `ERROR — Signal file ${REVIEWER_SIGNAL_PREFIX}${signalNum} found but ` +
88
+ `${signalContent} does not exist. Waiting for next signal.`,
89
+ }],
90
+ details: undefined,
91
+ };
92
+ }
93
+
94
+ const requestContent = readFileSync(requestPath, "utf-8");
95
+ nextSignalNum++;
96
+
97
+ return {
98
+ content: [{ type: "text" as const, text: requestContent }],
99
+ details: undefined,
100
+ };
101
+ }
102
+
103
+ // Check timeout
104
+ if (Date.now() - startTime > REVIEWER_WAIT_TIMEOUT_MS) {
105
+ return {
106
+ content: [{
107
+ type: "text" as const,
108
+ text: "TIMEOUT — No review request received within the timeout period. Exit cleanly.",
109
+ }],
110
+ details: undefined,
111
+ };
112
+ }
113
+
114
+ // Wait before next poll
115
+ await new Promise(resolve => setTimeout(resolve, REVIEWER_POLL_INTERVAL_MS));
116
+ }
117
+ },
118
+ });
119
+ }
@@ -24,13 +24,18 @@ import { Container, Text, truncateToWidth } from "@mariozechner/pi-tui";
24
24
  import { spawn, spawnSync } from "child_process";
25
25
  import {
26
26
  readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, unlinkSync,
27
- statSync, openSync, readSync, closeSync,
27
+ readdirSync, statSync, openSync, readSync, closeSync,
28
28
  } from "fs";
29
29
  import { tmpdir, userInfo } from "os";
30
30
  import { join, dirname, basename, resolve } from "path";
31
31
  import { loadProjectConfig, toTaskConfig } from "./taskplane/config-loader.ts";
32
32
  import { loadWorkspaceConfig, resolvePointer } from "./taskplane/workspace.ts";
33
33
  import type { PointerResolution } from "./taskplane/types.ts";
34
+ import {
35
+ REVIEWER_SHUTDOWN_GRACE_MS,
36
+ REVIEWER_SIGNAL_PREFIX,
37
+ REVIEWER_SHUTDOWN_SIGNAL,
38
+ } from "./taskplane/types.ts";
34
39
  import { classifyExit } from "./taskplane/diagnostics.ts";
35
40
  import type { TaskExitDiagnostic, ExitSummary } from "./taskplane/diagnostics.ts";
36
41
  import {
@@ -143,6 +148,12 @@ interface TaskState {
143
148
  reviewerProc: any;
144
149
  reviewerTimer: any;
145
150
  reviewCounter: number;
151
+ /** TP-057: Persistent reviewer session — tracks the long-lived reviewer tmux session. */
152
+ persistentReviewerSession: string | null;
153
+ /** TP-057: Kill function for the persistent reviewer (to stop sidecar polling). */
154
+ persistentReviewerKill: (() => void) | null;
155
+ /** TP-057: Signal counter for the persistent reviewer (monotonically increasing). */
156
+ persistentReviewerSignalNum: number;
146
157
  totalIterations: number;
147
158
  stepStatuses: Map<number, StepInfo>;
148
159
  }
@@ -160,7 +171,9 @@ function freshState(): TaskState {
160
171
  reviewerElapsed: 0, reviewerLastTool: "", reviewerToolCount: 0,
161
172
  reviewerInputTokens: 0, reviewerOutputTokens: 0, reviewerCacheReadTokens: 0, reviewerCacheWriteTokens: 0,
162
173
  reviewerCostUsd: 0, reviewerContextPct: 0, reviewerProc: null, reviewerTimer: null,
163
- reviewCounter: 0, totalIterations: 0, stepStatuses: new Map(),
174
+ reviewCounter: 0,
175
+ persistentReviewerSession: null, persistentReviewerKill: null, persistentReviewerSignalNum: 0,
176
+ totalIterations: 0, stepStatuses: new Map(),
164
177
  };
165
178
  }
166
179
 
@@ -633,6 +646,43 @@ function resolveRpcWrapperPath(): string {
633
646
  );
634
647
  }
635
648
 
649
+ /**
650
+ * Resolve the path to reviewer-extension.ts from the installed taskplane package.
651
+ * Mirrors resolveRpcWrapperPath() resolution strategy.
652
+ *
653
+ * @returns Absolute path to reviewer-extension.ts, or null if not found
654
+ * @since TP-057
655
+ */
656
+ function resolveReviewerExtensionPath(): string | null {
657
+ const extRelPath = join("extensions", "reviewer-extension.ts");
658
+
659
+ // 1. Package root
660
+ const root = findPackageRoot();
661
+ if (root) {
662
+ const p = join(root, extRelPath);
663
+ if (existsSync(p)) return p;
664
+ }
665
+
666
+ // 2. Extension-file-relative (dev scenario: task-runner.ts is sibling)
667
+ try {
668
+ const args = process.argv;
669
+ for (let i = 0; i < args.length - 1; i++) {
670
+ if (args[i] === "-e" && args[i + 1]?.includes("task-runner")) {
671
+ const extPath = resolve(args[i + 1]);
672
+ const derivedRoot = resolve(extPath, "..", "..");
673
+ const p = join(derivedRoot, extRelPath);
674
+ if (existsSync(p)) return p;
675
+ }
676
+ }
677
+ } catch { /* ignore */ }
678
+
679
+ // 3. Development fallback: cwd
680
+ const cwdDev = join(process.cwd(), extRelPath);
681
+ if (existsSync(cwdDev)) return cwdDev;
682
+
683
+ return null;
684
+ }
685
+
636
686
  /**
637
687
  * Load an agent definition with prompt inheritance.
638
688
  *
@@ -1567,6 +1617,9 @@ function spawnAgentTmux(opts: {
1567
1617
  /** Optional extension paths to load in the spawned pi session (via rpc-wrapper --extensions).
1568
1618
  * When provided, --no-extensions is NOT passed to pi (would conflict). */
1569
1619
  extensions?: string[];
1620
+ /** Optional extra environment variables to set in the spawned tmux session.
1621
+ * Injected as `KEY=VALUE` prefixes in the shell command. @since TP-057 */
1622
+ env?: Record<string, string>;
1570
1623
  /** Called on each poll tick with accumulated telemetry from the sidecar JSONL.
1571
1624
  * Enables the tmux poll loop to update TaskState (tokens, cost, context%, tools, retries)
1572
1625
  * with the same signals that subprocess mode gets from onTokenUpdate/onContextPct/onToolCall. */
@@ -1710,7 +1763,11 @@ function spawnAgentTmux(opts: {
1710
1763
  // Pi's ink/react TUI hangs with TERM=tmux-256color (tmux default), so we
1711
1764
  // force xterm-256color.
1712
1765
  const tmuxCwd = opts.cwd.replace(/^([A-Za-z]):\\/, (_, d: string) => `/${d.toLowerCase()}/`).replace(/\\/g, "/");
1713
- const wrappedCommand = `cd ${quoteArg(tmuxCwd)} && TERM=xterm-256color ${wrapperCommand}`;
1766
+ // Build extra env var prefix (TP-057: e.g., REVIEWER_SIGNAL_DIR for persistent reviewer)
1767
+ const extraEnv = opts.env
1768
+ ? Object.entries(opts.env).map(([k, v]) => `${k}=${quoteArg(v)}`).join(" ") + " "
1769
+ : "";
1770
+ const wrappedCommand = `cd ${quoteArg(tmuxCwd)} && ${extraEnv}TERM=xterm-256color ${wrapperCommand}`;
1714
1771
  const createResult = spawnSync("tmux", [
1715
1772
  "new-session", "-d",
1716
1773
  "-s", opts.sessionName,
@@ -2017,6 +2074,80 @@ export default function (pi: ExtensionAPI) {
2017
2074
  state.reviewerTimer = null;
2018
2075
  }
2019
2076
 
2077
+ /**
2078
+ * TP-057: Remove stale signal and shutdown files from .reviews/ directory.
2079
+ * Called before spawning a new persistent reviewer to prevent the reviewer
2080
+ * from consuming old signals or immediately seeing a stale shutdown marker.
2081
+ */
2082
+ function cleanStaleReviewerSignals(reviewsDir: string): void {
2083
+ try {
2084
+ const files = readdirSync(reviewsDir);
2085
+ for (const f of files) {
2086
+ if (f.startsWith(REVIEWER_SIGNAL_PREFIX) || f === REVIEWER_SHUTDOWN_SIGNAL) {
2087
+ try { unlinkSync(join(reviewsDir, f)); } catch {}
2088
+ }
2089
+ }
2090
+ } catch {
2091
+ // Directory may not exist yet — not an error
2092
+ }
2093
+ }
2094
+
2095
+ /**
2096
+ * TP-057: Shut down the persistent reviewer session cleanly.
2097
+ * Writes shutdown signal, waits for clean exit within grace period,
2098
+ * then force-kills the session if still alive.
2099
+ *
2100
+ * Called from all executeTask exit paths (success, pause, error, stall)
2101
+ * to prevent orphan tmux sessions.
2102
+ *
2103
+ * @param reason - Why the reviewer is being shut down (for logging)
2104
+ */
2105
+ async function shutdownPersistentReviewer(reason: string): Promise<void> {
2106
+ if (!state.persistentReviewerSession) return;
2107
+
2108
+ const sessionName = state.persistentReviewerSession;
2109
+ console.error(`[task-runner] persistent reviewer: shutting down (${reason})`);
2110
+
2111
+ // Write shutdown signal so the reviewer exits cleanly
2112
+ if (state.task) {
2113
+ const reviewsDir = join(state.task.taskFolder, ".reviews");
2114
+ const shutdownPath = join(reviewsDir, REVIEWER_SHUTDOWN_SIGNAL);
2115
+ try {
2116
+ if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
2117
+ writeFileSync(shutdownPath, "shutdown");
2118
+ } catch (err: any) {
2119
+ console.error(`[task-runner] persistent reviewer: failed to write shutdown signal: ${err?.message}`);
2120
+ }
2121
+ }
2122
+
2123
+ // Poll for session death within grace period
2124
+ const graceStart = Date.now();
2125
+ while (Date.now() - graceStart < REVIEWER_SHUTDOWN_GRACE_MS) {
2126
+ const alive = spawnSync("tmux", ["has-session", "-t", sessionName]);
2127
+ if (alive.status !== 0) break;
2128
+ await new Promise(r => setTimeout(r, 1000));
2129
+ }
2130
+
2131
+ // Force kill if still alive after grace period
2132
+ const finalCheck = spawnSync("tmux", ["has-session", "-t", sessionName]);
2133
+ if (finalCheck.status === 0) {
2134
+ console.error(`[task-runner] persistent reviewer: killing session after grace period`);
2135
+ spawnSync("tmux", ["kill-session", "-t", sessionName]);
2136
+ }
2137
+
2138
+ // Reset state
2139
+ state.persistentReviewerSession = null;
2140
+ state.persistentReviewerKill = null;
2141
+ state.persistentReviewerSignalNum = 0;
2142
+ clearReviewerState();
2143
+ writeLaneState(state);
2144
+
2145
+ if (state.task) {
2146
+ const statusPath = join(state.task.taskFolder, "STATUS.md");
2147
+ logExecution(statusPath, "Persistent reviewer", `Shutdown complete (${reason})`);
2148
+ }
2149
+ }
2150
+
2020
2151
  if (isOrchestratedMode()) {
2021
2152
  pi.registerTool({
2022
2153
  name: "review_step",
@@ -2080,9 +2211,6 @@ export default function (pi: ExtensionAPI) {
2080
2211
  const outputPath = join(reviewsDir, `R${num}-${reviewType}-step${stepNum}.md`);
2081
2212
 
2082
2213
  // Resolve step baseline commit for code reviews.
2083
- // The worker should pass the pre-step HEAD SHA as `baseline` so the
2084
- // reviewer sees only this step's changes (not cumulative diff).
2085
- // Falls back to undefined (full diff) if baseline is not provided.
2086
2214
  const stepBaselineCommit: string | undefined =
2087
2215
  reviewType === "code" ? (baseline || undefined) : undefined;
2088
2216
 
@@ -2118,12 +2246,15 @@ export default function (pi: ExtensionAPI) {
2118
2246
  state.reviewerElapsed = 0;
2119
2247
  state.reviewerLastTool = "";
2120
2248
  state.reviewerToolCount = 0;
2121
- state.reviewerInputTokens = 0;
2122
- state.reviewerOutputTokens = 0;
2123
- state.reviewerCacheReadTokens = 0;
2124
- state.reviewerCacheWriteTokens = 0;
2125
- state.reviewerCostUsd = 0;
2126
- state.reviewerContextPct = 0;
2249
+ // Don't reset cumulative token counts for persistent reviewer — they accumulate
2250
+ if (!state.persistentReviewerSession) {
2251
+ state.reviewerInputTokens = 0;
2252
+ state.reviewerOutputTokens = 0;
2253
+ state.reviewerCacheReadTokens = 0;
2254
+ state.reviewerCacheWriteTokens = 0;
2255
+ state.reviewerCostUsd = 0;
2256
+ state.reviewerContextPct = 0;
2257
+ }
2127
2258
  updateWidgets();
2128
2259
 
2129
2260
  const startTime = Date.now();
@@ -2132,23 +2263,54 @@ export default function (pi: ExtensionAPI) {
2132
2263
  updateWidgets();
2133
2264
  }, 1000);
2134
2265
 
2135
- // Read the request file content as the prompt
2136
- const promptContent = readFileSync(requestPath, "utf-8");
2137
-
2138
2266
  // Resolve context window for reviewer context% calculation
2139
2267
  const { contextWindow } = resolveContextWindow(config, ctx);
2140
2268
 
2141
- try {
2142
- // Spawn reviewer via spawnAgentTmux with onTelemetry for live metrics
2269
+ // ── TP-057: Persistent Reviewer Session ─────────────────
2270
+ // On the first review_step call, spawn a persistent reviewer
2271
+ // that stays alive via the wait_for_review tool. On subsequent
2272
+ // calls, reuse the existing session by writing signal files.
2273
+ // Fall back to fresh-spawn if the persistent session dies.
2274
+
2275
+ /**
2276
+ * Check if the persistent reviewer tmux session is still alive.
2277
+ */
2278
+ function isPersistentReviewerAlive(): boolean {
2279
+ if (!state.persistentReviewerSession) return false;
2280
+ const result = spawnSync("tmux", ["has-session", "-t", state.persistentReviewerSession]);
2281
+ return result.status === 0;
2282
+ }
2283
+
2284
+ /**
2285
+ * Spawn a persistent reviewer session with the reviewer-extension
2286
+ * loaded, so the reviewer can use wait_for_review to receive requests.
2287
+ */
2288
+ function spawnPersistentReviewer(): void {
2289
+ const reviewerExtPath = resolveReviewerExtensionPath();
2290
+ if (!reviewerExtPath) {
2291
+ throw new Error("Cannot find reviewer-extension.ts. Ensure taskplane is installed correctly.");
2292
+ }
2293
+
2294
+ // Clean stale signal/shutdown files before spawning
2295
+ cleanStaleReviewerSignals(reviewsDir);
2296
+
2297
+ // Initial prompt tells the reviewer to call wait_for_review
2298
+ const initialPrompt =
2299
+ "You are a persistent reviewer for this task. " +
2300
+ "Call the `wait_for_review` tool now to receive your first review request. " +
2301
+ "After writing each review, call `wait_for_review` again for the next one.";
2302
+
2143
2303
  const spawned = spawnAgentTmux({
2144
2304
  sessionName,
2145
2305
  cwd: ctx.cwd,
2146
2306
  systemPrompt,
2147
- prompt: promptContent,
2307
+ prompt: initialPrompt,
2148
2308
  model: reviewerModel,
2149
2309
  tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
2150
2310
  thinking: config.reviewer.thinking || "on",
2151
2311
  taskId: task.taskId,
2312
+ extensions: [reviewerExtPath],
2313
+ env: { REVIEWER_SIGNAL_DIR: reviewsDir },
2152
2314
  onTelemetry: (delta) => {
2153
2315
  // Accumulate tokens and cost
2154
2316
  state.reviewerInputTokens += delta.inputTokens;
@@ -2173,27 +2335,96 @@ export default function (pi: ExtensionAPI) {
2173
2335
  },
2174
2336
  });
2175
2337
 
2338
+ // Store persistent session state
2339
+ state.persistentReviewerSession = sessionName;
2340
+ state.persistentReviewerKill = spawned.kill;
2341
+ state.persistentReviewerSignalNum = 0;
2176
2342
  state.reviewerProc = { kill: spawned.kill };
2177
2343
 
2178
- // Await reviewer completion
2179
- const result = await spawned.promise;
2344
+ // Don't await spawned.promise — the session stays alive across reviews.
2345
+ // Handle session death via isPersistentReviewerAlive() checks.
2346
+ spawned.promise.then(() => {
2347
+ // Session ended (reviewer exited or was killed)
2348
+ console.error(`[task-runner] persistent reviewer session '${sessionName}' ended`);
2349
+ }).catch((err: any) => {
2350
+ console.error(`[task-runner] persistent reviewer session error: ${err?.message || err}`);
2351
+ });
2352
+ }
2353
+
2354
+ /**
2355
+ * Write signal file to notify the persistent reviewer of a new request.
2356
+ * Returns the signal number used.
2357
+ */
2358
+ function signalPersistentReviewer(): number {
2359
+ state.persistentReviewerSignalNum++;
2360
+ const sigNum = String(state.persistentReviewerSignalNum).padStart(3, "0");
2361
+ const signalPath = join(reviewsDir, `${REVIEWER_SIGNAL_PREFIX}${sigNum}`);
2362
+ // Write the request filename so the reviewer can find it
2363
+ // (signal num and review counter may diverge after respawns)
2364
+ writeFileSync(signalPath, `request-R${num}.md`);
2365
+ return state.persistentReviewerSignalNum;
2366
+ }
2367
+
2368
+ /**
2369
+ * Poll for the verdict file to appear (written by the reviewer).
2370
+ * Same pattern as the original review_step handler.
2371
+ */
2372
+ async function pollForVerdict(): Promise<string> {
2373
+ const verdictTimeout = 30 * 60 * 1000; // 30 minutes
2374
+ const pollStart = Date.now();
2375
+ while (Date.now() - pollStart < verdictTimeout) {
2376
+ if (existsSync(outputPath)) {
2377
+ return readFileSync(outputPath, "utf-8");
2378
+ }
2379
+ // Also check if persistent reviewer died while we're waiting
2380
+ if (state.persistentReviewerSession && !isPersistentReviewerAlive()) {
2381
+ throw new Error("Persistent reviewer session died while waiting for verdict");
2382
+ }
2383
+ await new Promise(r => setTimeout(r, 2000));
2384
+ }
2385
+ throw new Error("Reviewer verdict timeout — no output file after 30 minutes");
2386
+ }
2387
+
2388
+ try {
2389
+ // ── Persistent reviewer: spawn or reuse ─────────────
2390
+ const needsSpawn = !state.persistentReviewerSession || !isPersistentReviewerAlive();
2391
+
2392
+ if (needsSpawn && state.persistentReviewerSession) {
2393
+ // Session was previously active but died — log fallback
2394
+ console.error(`[task-runner] persistent reviewer session dead — respawning`);
2395
+ logExecution(statusPath, `Reviewer R${num}`,
2396
+ `persistent reviewer dead — respawning for ${reviewType} review`);
2397
+ state.persistentReviewerSession = null;
2398
+ state.persistentReviewerKill = null;
2399
+ state.persistentReviewerSignalNum = 0;
2400
+ }
2401
+
2402
+ if (needsSpawn) {
2403
+ spawnPersistentReviewer();
2404
+ // Give the reviewer a moment to start and call wait_for_review
2405
+ await new Promise(r => setTimeout(r, 5000));
2406
+ }
2180
2407
 
2181
- clearInterval(state.reviewerTimer);
2408
+ // Signal the reviewer with the new request
2409
+ signalPersistentReviewer();
2410
+
2411
+ // Poll for the verdict file
2412
+ const reviewContent = await pollForVerdict();
2413
+
2414
+ // Stop the per-review timer
2415
+ if (state.reviewerTimer) clearInterval(state.reviewerTimer);
2182
2416
  state.reviewerElapsed = Date.now() - startTime;
2183
- state.reviewerStatus = result.exitCode === 0 ? "done" : "error";
2184
- state.reviewerProc = null;
2417
+ state.reviewerStatus = "done";
2185
2418
  writeLaneState(state);
2186
2419
  updateWidgets();
2187
2420
 
2188
2421
  // Extract verdict from review output
2189
2422
  let verdict = "UNKNOWN";
2190
2423
  let reviseDetails = "";
2191
- if (existsSync(outputPath)) {
2192
- const review = readFileSync(outputPath, "utf-8");
2193
- verdict = extractVerdict(review);
2424
+ if (reviewContent) {
2425
+ verdict = extractVerdict(reviewContent);
2194
2426
  if (verdict === "REVISE") {
2195
- // Extract a brief summary from the review for the worker
2196
- const summaryMatch = review.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i);
2427
+ const summaryMatch = reviewContent.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i);
2197
2428
  reviseDetails = summaryMatch
2198
2429
  ? summaryMatch[1].trim().slice(0, 500)
2199
2430
  : "See review file for details.";
@@ -2211,8 +2442,12 @@ export default function (pi: ExtensionAPI) {
2211
2442
  `${reviewType} Step ${stepNum}: ${verdict}`);
2212
2443
  updateStatusField(statusPath, "Review Counter", `${state.reviewCounter}`);
2213
2444
 
2214
- // Clear reviewer state for dashboard
2215
- clearReviewerState();
2445
+ // Set reviewer to idle (NOT clear — persistent session stays alive)
2446
+ state.reviewerStatus = "idle";
2447
+ state.reviewerType = "";
2448
+ state.reviewerStep = 0;
2449
+ if (state.reviewerTimer) clearInterval(state.reviewerTimer);
2450
+ state.reviewerTimer = null;
2216
2451
  writeLaneState(state);
2217
2452
  updateWidgets();
2218
2453
 
@@ -2233,20 +2468,116 @@ export default function (pi: ExtensionAPI) {
2233
2468
  details: undefined,
2234
2469
  };
2235
2470
  } catch (err: any) {
2236
- // Reviewer crashed
2237
- clearInterval(state.reviewerTimer);
2238
- clearReviewerState();
2239
- state.reviewerStatus = "error";
2240
- writeLaneState(state);
2241
- updateWidgets();
2242
-
2471
+ // ── Fallback: kill persistent session, try fresh spawn ──
2472
+ console.error(`[task-runner] persistent reviewer error: ${err?.message || err}`);
2243
2473
  logExecution(statusPath, `Reviewer R${num}`,
2244
- `${reviewType} reviewreviewer crashed: ${err?.message || err}`);
2474
+ `persistent reviewer failed falling back to fresh spawn: ${err?.message || err}`);
2245
2475
 
2246
- return {
2247
- content: [{ type: "text" as const, text: `UNAVAILABLE — reviewer error: ${err?.message || err}` }],
2248
- details: undefined,
2249
- };
2476
+ // Kill the dead/broken persistent session
2477
+ if (state.persistentReviewerKill) {
2478
+ try { state.persistentReviewerKill(); } catch {}
2479
+ }
2480
+ state.persistentReviewerSession = null;
2481
+ state.persistentReviewerKill = null;
2482
+ state.persistentReviewerSignalNum = 0;
2483
+
2484
+ // ── Fresh spawn fallback (original behavior) ────────
2485
+ try {
2486
+ const promptContent = readFileSync(requestPath, "utf-8");
2487
+ const spawned = spawnAgentTmux({
2488
+ sessionName,
2489
+ cwd: ctx.cwd,
2490
+ systemPrompt,
2491
+ prompt: promptContent,
2492
+ model: reviewerModel,
2493
+ tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
2494
+ thinking: config.reviewer.thinking || "on",
2495
+ taskId: task.taskId,
2496
+ onTelemetry: (delta) => {
2497
+ state.reviewerInputTokens += delta.inputTokens;
2498
+ state.reviewerOutputTokens += delta.outputTokens;
2499
+ state.reviewerCacheReadTokens += delta.cacheReadTokens;
2500
+ state.reviewerCacheWriteTokens += delta.cacheWriteTokens;
2501
+ state.reviewerCostUsd += delta.cost;
2502
+ state.reviewerToolCount += delta.toolCalls;
2503
+ if (delta.lastTool) state.reviewerLastTool = delta.lastTool;
2504
+ if (delta.latestTotalTokens > 0 && contextWindow > 0) {
2505
+ state.reviewerContextPct = (delta.latestTotalTokens / contextWindow) * 100;
2506
+ }
2507
+ writeLaneState(state);
2508
+ updateWidgets();
2509
+ },
2510
+ });
2511
+
2512
+ state.reviewerProc = { kill: spawned.kill };
2513
+ const result = await spawned.promise;
2514
+
2515
+ clearInterval(state.reviewerTimer);
2516
+ state.reviewerElapsed = Date.now() - startTime;
2517
+ state.reviewerStatus = result.exitCode === 0 ? "done" : "error";
2518
+ state.reviewerProc = null;
2519
+ writeLaneState(state);
2520
+ updateWidgets();
2521
+
2522
+ // Extract verdict from fallback review
2523
+ let verdict = "UNKNOWN";
2524
+ let reviseDetails = "";
2525
+ if (existsSync(outputPath)) {
2526
+ const review = readFileSync(outputPath, "utf-8");
2527
+ verdict = extractVerdict(review);
2528
+ if (verdict === "REVISE") {
2529
+ const summaryMatch = review.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i);
2530
+ reviseDetails = summaryMatch
2531
+ ? summaryMatch[1].trim().slice(0, 500)
2532
+ : "See review file for details.";
2533
+ }
2534
+ } else {
2535
+ verdict = "UNAVAILABLE";
2536
+ logExecution(statusPath, `Reviewer R${num}`,
2537
+ `${reviewType} review — fallback reviewer did not produce output`);
2538
+ }
2539
+
2540
+ logReview(statusPath, `R${num}`, reviewType, stepNum, verdict,
2541
+ `.reviews/R${num}-${reviewType}-step${stepNum}.md`);
2542
+ logExecution(statusPath, `Review R${num}`,
2543
+ `${reviewType} Step ${stepNum}: ${verdict} (fallback)`);
2544
+ updateStatusField(statusPath, "Review Counter", `${state.reviewCounter}`);
2545
+
2546
+ clearReviewerState();
2547
+ writeLaneState(state);
2548
+ updateWidgets();
2549
+
2550
+ let resultText: string;
2551
+ if (verdict === "APPROVE") {
2552
+ resultText = "APPROVE";
2553
+ } else if (verdict === "REVISE") {
2554
+ resultText = `REVISE: ${reviseDetails}\n\nFull review: .reviews/R${num}-${reviewType}-step${stepNum}.md`;
2555
+ } else if (verdict === "RETHINK") {
2556
+ resultText = `RETHINK — reconsider your approach. See .reviews/R${num}-${reviewType}-step${stepNum}.md`;
2557
+ } else {
2558
+ resultText = `UNAVAILABLE — reviewer did not produce a usable verdict.`;
2559
+ }
2560
+
2561
+ return {
2562
+ content: [{ type: "text" as const, text: resultText }],
2563
+ details: undefined,
2564
+ };
2565
+ } catch (fallbackErr: any) {
2566
+ // Both persistent and fallback failed
2567
+ clearInterval(state.reviewerTimer);
2568
+ clearReviewerState();
2569
+ state.reviewerStatus = "error";
2570
+ writeLaneState(state);
2571
+ updateWidgets();
2572
+
2573
+ logExecution(statusPath, `Reviewer R${num}`,
2574
+ `${reviewType} review — both persistent and fallback failed: ${fallbackErr?.message || fallbackErr}`);
2575
+
2576
+ return {
2577
+ content: [{ type: "text" as const, text: `UNAVAILABLE — reviewer error: ${fallbackErr?.message || fallbackErr}` }],
2578
+ details: undefined,
2579
+ };
2580
+ }
2250
2581
  }
2251
2582
  },
2252
2583
  });
@@ -2298,6 +2629,7 @@ export default function (pi: ExtensionAPI) {
2298
2629
  if (state.phase === "paused") {
2299
2630
  logExecution(statusPath, "Paused", `User paused at iteration ${iter + 1}`);
2300
2631
  ctx.ui.notify(`Task paused at iteration ${iter + 1}`, "info");
2632
+ await shutdownPersistentReviewer("task paused");
2301
2633
  return;
2302
2634
  }
2303
2635
 
@@ -2329,7 +2661,10 @@ export default function (pi: ExtensionAPI) {
2329
2661
 
2330
2662
  await runWorker(remainingSteps, ctx);
2331
2663
 
2332
- if (state.phase === "error") return;
2664
+ if (state.phase === "error") {
2665
+ await shutdownPersistentReviewer("worker error");
2666
+ return;
2667
+ }
2333
2668
 
2334
2669
  // ── Post-worker: determine which steps were newly completed ──
2335
2670
  const afterStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
@@ -2345,6 +2680,7 @@ export default function (pi: ExtensionAPI) {
2345
2680
  logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
2346
2681
  ctx.ui.notify(`⚠️ Task blocked — no progress after ${noProgressCount} iterations`, "error");
2347
2682
  state.phase = "error";
2683
+ await shutdownPersistentReviewer("task stalled");
2348
2684
  return;
2349
2685
  }
2350
2686
  } else {
@@ -2409,10 +2745,14 @@ export default function (pi: ExtensionAPI) {
2409
2745
  logExecution(statusPath, "Task incomplete", `Max iterations (${config.context.max_worker_iterations}) reached with incomplete steps: ${incomplete}`);
2410
2746
  ctx.ui.notify(`⚠️ Task incomplete — max iterations reached. Incomplete: ${incomplete}`, "error");
2411
2747
  state.phase = "error";
2748
+ await shutdownPersistentReviewer("max iterations reached");
2412
2749
  return;
2413
2750
  }
2414
2751
  }
2415
2752
 
2753
+ // ── TP-057: Shutdown persistent reviewer ────────────────────────
2754
+ await shutdownPersistentReviewer("task complete");
2755
+
2416
2756
  // All steps done — run quality gate if enabled, then create .DONE
2417
2757
  if (config.quality_gate.enabled) {
2418
2758
  // ── Quality Gate Enabled ─────────────────────────────────
@@ -3545,6 +3885,11 @@ export default function (pi: ExtensionAPI) {
3545
3885
  // Kill any running subprocesses
3546
3886
  if (state.workerProc) try { state.workerProc.kill(); } catch {}
3547
3887
  if (state.reviewerProc) try { state.reviewerProc.kill(); } catch {}
3888
+ // TP-057: Kill persistent reviewer session if alive
3889
+ if (state.persistentReviewerKill) try { state.persistentReviewerKill(); } catch {}
3890
+ state.persistentReviewerSession = null;
3891
+ state.persistentReviewerKill = null;
3892
+ state.persistentReviewerSignalNum = 0;
3548
3893
  if (state.workerTimer) clearInterval(state.workerTimer);
3549
3894
  if (state.reviewerTimer) clearInterval(state.reviewerTimer);
3550
3895
 
@@ -50,10 +50,12 @@ You (supervisor) ← operator talks to you
50
50
  │ └── Create .DONE file when all steps complete
51
51
 
52
52
  ├── Reviewer Agents (LLM, cross-model)
53
- │ ├── Spawned by task-runner between worker iterations
54
- │ ├── Review plans (before implementation) and code (after)
53
+ │ ├── Persistent: one reviewer per task, stays alive across all reviews
54
+ │ ├── Receives review requests via wait_for_review tool (signal files)
55
+ │ ├── Reviews plans (before implementation) and code (after)
55
56
  │ ├── Write structured verdict to .reviews/ directory
56
- └── APPROVE or REVISE (worker re-iterates on REVISE)
57
+ ├── APPROVE or REVISE (worker addresses feedback inline)
58
+ │ └── Falls back to fresh spawn if persistent session dies
57
59
 
58
60
  └── Merge Agents (LLM)
59
61
  ├── Run in temporary merge worktrees
@@ -1306,6 +1306,41 @@ export const MERGE_HEALTH_STUCK_THRESHOLD_MS = 20 * 60 * 1000; // 20 minutes
1306
1306
  */
1307
1307
  export const MERGE_HEALTH_CAPTURE_LINES = 10;
1308
1308
 
1309
+ // ── Persistent Reviewer Constants (TP-057) ───────────────────────────
1310
+
1311
+ /**
1312
+ * Polling interval (ms) for the `wait_for_review` tool to check for signal files.
1313
+ * Reviews take minutes; 3s latency is invisible to the user.
1314
+ * @since TP-057
1315
+ */
1316
+ export const REVIEWER_POLL_INTERVAL_MS = 3_000;
1317
+
1318
+ /**
1319
+ * Maximum time (ms) for the `wait_for_review` tool to wait for a review signal.
1320
+ * 30 minutes — generous for long-running code reviews.
1321
+ * @since TP-057
1322
+ */
1323
+ export const REVIEWER_WAIT_TIMEOUT_MS = 30 * 60 * 1000;
1324
+
1325
+ /**
1326
+ * Grace period (ms) after writing shutdown signal before killing the reviewer session.
1327
+ * Allows the reviewer to exit cleanly after receiving the shutdown signal.
1328
+ * @since TP-057
1329
+ */
1330
+ export const REVIEWER_SHUTDOWN_GRACE_MS = 10_000;
1331
+
1332
+ /**
1333
+ * Signal file prefix for review requests. Full name: `.review-signal-{NNN}`
1334
+ * @since TP-057
1335
+ */
1336
+ export const REVIEWER_SIGNAL_PREFIX = ".review-signal-";
1337
+
1338
+ /**
1339
+ * Shutdown signal filename written to .reviews/ when the task is complete.
1340
+ * @since TP-057
1341
+ */
1342
+ export const REVIEWER_SHUTDOWN_SIGNAL = ".review-shutdown";
1343
+
1309
1344
  // ── Merge Health Event Types (TP-056) ────────────────────────────────
1310
1345
 
1311
1346
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -31,6 +31,7 @@
31
31
  "dashboard/",
32
32
  "extensions/task-runner.ts",
33
33
  "extensions/task-orchestrator.ts",
34
+ "extensions/reviewer-extension.ts",
34
35
  "extensions/taskplane/",
35
36
  "skills/",
36
37
  "templates/"
@@ -16,6 +16,7 @@ name: task-reviewer
16
16
  - Verdict format (APPROVE / REVISE)
17
17
  - Review file output conventions
18
18
  - Plan granularity guidance
19
+ - Persistent reviewer mode (wait_for_review tool workflow)
19
20
 
20
21
  Add project-specific review criteria below. Common examples:
21
22
  - Required test coverage thresholds
@@ -9,12 +9,38 @@ task implementations. You have full read access to the codebase and can run comm
9
9
 
10
10
  ## How You Work
11
11
 
12
+ You operate in one of two modes depending on available tools:
13
+
14
+ ### Persistent Mode (when `wait_for_review` tool is available)
15
+
16
+ You are a **persistent reviewer** that stays alive across all review requests for
17
+ a task. This preserves your context — you remember what you reviewed in earlier
18
+ steps and can reference previous findings.
19
+
20
+ 1. Call `wait_for_review()` to receive your first review request
21
+ 2. The request specifies an **output file path** — you MUST write your review there
22
+ 3. Use your tools to explore the codebase — read files, run `git diff`, check patterns
23
+ 4. **Use the `write` tool to create the output file with your review**
24
+ 5. Use the appropriate verdict: APPROVE, REVISE, or RETHINK
25
+ 6. Call `wait_for_review()` again to receive the next request
26
+ 7. Repeat until you receive a `SHUTDOWN` signal, then exit cleanly
27
+
28
+ **Cross-step awareness:** When reviewing later steps, reference your earlier
29
+ reviews when relevant. For example: "I flagged X in Step 2's plan review —
30
+ checking if it was addressed in this code review."
31
+
32
+ ### Fresh Spawn Mode (when `wait_for_review` is NOT available)
33
+
34
+ You handle a single review request and then exit.
35
+
12
36
  1. Read the review request provided to you carefully
13
37
  2. The request specifies an **output file path** — you MUST write your review there
14
38
  3. Use your tools to explore the codebase — read files, run `git diff`, check patterns
15
39
  4. **Use the `write` tool to create the output file with your review**
16
40
  5. Use the appropriate verdict: APPROVE, REVISE, or RETHINK
17
41
 
42
+ ### Critical Rule (Both Modes)
43
+
18
44
  **CRITICAL:** Your review MUST be written to disk using the `write` tool.
19
45
  Do NOT just respond with text — the orchestrator reads the OUTPUT FILE to get
20
46
  your verdict. If you don't write the file, your review is lost.