taskplane 0.24.0 → 0.24.2

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.
@@ -101,6 +101,11 @@ function mergeV2LaneSnapshot(legacyLs, v2snap) {
101
101
  return base;
102
102
  }
103
103
 
104
+ function isReviewerActiveForTask(ls, task) {
105
+ if (!ls || !task) return false;
106
+ return !!(ls.reviewerStatus === "running" && task.status === "running" && (!ls.taskId || ls.taskId === task.taskId));
107
+ }
108
+
104
109
  /** Build a compact token summary string from lane state sidecar data.
105
110
  * Display: ↑total_input ↓output (cost)
106
111
  * Anthropic splits input into: uncached `input` + `cacheRead`.
@@ -612,10 +617,10 @@ function renderLanesTasks(batch, tmuxSessions) {
612
617
 
613
618
  // Worker stats from lane state sidecar + telemetry badges
614
619
  let workerHtml = "";
615
- // Reviewer sub-row should only appear under the task currently being reviewed,
616
- // not all tasks in the lane. The lane-state sidecar is per-lane (shared by all
617
- // tasks in the lane), so check that the sidecar's current taskId matches this task.
618
- const reviewerActive = ls && ls.reviewerStatus === "running" && ls.taskId === task.taskId;
620
+ // Reviewer sub-row should only appear under the active running task in this lane.
621
+ // Runtime V2 snapshots provide taskId; during early startup it can be briefly unset,
622
+ // so allow a task-status fallback while still avoiding duplicate rows.
623
+ const reviewerActive = isReviewerActiveForTask(ls, task);
619
624
  const telemBadges = task.status !== "pending" ? telemetryBadgesHtml(tel, reviewerActive) : "";
620
625
  if (ls && ls.workerStatus === "running" && task.status === "running") {
621
626
  const elapsed = ls.workerElapsed ? `${Math.round(ls.workerElapsed / 1000)}s` : "";
@@ -997,6 +997,43 @@ function computeBatchTotalCost(laneStates, telemetry) {
997
997
  return totalCost;
998
998
  }
999
999
 
1000
+ function synthesizeLaneStateFromSnapshot(key, snap, fallbackBatchId) {
1001
+ const w = snap.worker || {};
1002
+ const r = snap.reviewer || null;
1003
+ const statusMap = { running: "running", spawning: "running", exited: "done", crashed: "error", killed: "error", timed_out: "error", wrapping_up: "running" };
1004
+ const reviewerStatusMap = { running: "running", spawning: "running", wrapping_up: "running", exited: "done", crashed: "done", killed: "done", timed_out: "done" };
1005
+
1006
+ return {
1007
+ prefix: key,
1008
+ taskId: snap.taskId || null,
1009
+ phase: snap.status === "running" ? "worker-active" : snap.status === "complete" ? "complete" : "idle",
1010
+ workerStatus: statusMap[w.status] || w.status || "idle",
1011
+ workerElapsed: w.elapsedMs || 0,
1012
+ workerContextPct: w.contextPct || 0,
1013
+ workerLastTool: w.lastTool || "",
1014
+ workerToolCount: w.toolCalls || 0,
1015
+ workerInputTokens: w.inputTokens || 0,
1016
+ workerOutputTokens: w.outputTokens || 0,
1017
+ workerCacheReadTokens: w.cacheReadTokens || 0,
1018
+ workerCacheWriteTokens: w.cacheWriteTokens || 0,
1019
+ workerCostUsd: w.costUsd || 0,
1020
+ reviewerStatus: r ? (reviewerStatusMap[r.status] || r.status || "running") : "idle",
1021
+ reviewerElapsed: r?.elapsedMs || 0,
1022
+ reviewerContextPct: r?.contextPct || 0,
1023
+ reviewerLastTool: r?.lastTool || "",
1024
+ reviewerToolCount: r?.toolCalls || 0,
1025
+ reviewerCostUsd: r?.costUsd || 0,
1026
+ reviewerInputTokens: r?.inputTokens || 0,
1027
+ reviewerOutputTokens: r?.outputTokens || 0,
1028
+ reviewerCacheReadTokens: r?.cacheReadTokens || 0,
1029
+ reviewerCacheWriteTokens: r?.cacheWriteTokens || 0,
1030
+ reviewerType: r?.reviewType || "",
1031
+ reviewerStep: r?.reviewStep || 0,
1032
+ batchId: snap.batchId || fallbackBatchId,
1033
+ timestamp: snap.updatedAt || Date.now(),
1034
+ };
1035
+ }
1036
+
1000
1037
  /** Build full dashboard state object for the frontend. */
1001
1038
  function buildDashboardState() {
1002
1039
  const state = loadBatchState();
@@ -1046,26 +1083,7 @@ function buildDashboardState() {
1046
1083
  const laneRec = (state.lanes || []).find(l => l.laneNumber === Number(laneNum));
1047
1084
  const key = laneRec ? (laneRec.laneSessionId) : `lane-${laneNum}`;
1048
1085
  if (!laneStates[key] || (snap.updatedAt && snap.updatedAt > (laneStates[key].timestamp || 0))) {
1049
- const w = snap.worker || {};
1050
- const statusMap = { running: "running", spawning: "running", exited: "done", crashed: "error", killed: "error", timed_out: "error", wrapping_up: "running" };
1051
- laneStates[key] = {
1052
- prefix: key,
1053
- taskId: snap.taskId || null,
1054
- phase: snap.status === "running" ? "worker-active" : snap.status === "complete" ? "complete" : "idle",
1055
- workerStatus: statusMap[w.status] || w.status || "idle",
1056
- workerElapsed: w.elapsedMs || 0,
1057
- workerContextPct: w.contextPct || 0,
1058
- workerLastTool: w.lastTool || "",
1059
- workerToolCount: w.toolCalls || 0,
1060
- workerInputTokens: w.inputTokens || 0,
1061
- workerOutputTokens: w.outputTokens || 0,
1062
- workerCacheReadTokens: w.cacheReadTokens || 0,
1063
- workerCacheWriteTokens: w.cacheWriteTokens || 0,
1064
- workerCostUsd: w.costUsd || 0,
1065
- reviewerStatus: "idle",
1066
- batchId: snap.batchId || state.batchId,
1067
- timestamp: snap.updatedAt || Date.now(),
1068
- };
1086
+ laneStates[key] = synthesizeLaneStateFromSnapshot(key, snap, state.batchId);
1069
1087
  }
1070
1088
  }
1071
1089
  }
@@ -24,7 +24,7 @@
24
24
 
25
25
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
26
26
  import { Type } from "@mariozechner/pi-ai";
27
- import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } from "fs";
27
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "fs";
28
28
  import { join, dirname } from "path";
29
29
  import { spawn as nodeSpawn } from "child_process";
30
30
  import { randomBytes } from "crypto";
@@ -216,11 +216,44 @@ export default function (pi: ExtensionAPI) {
216
216
  return basePrompt;
217
217
  }
218
218
 
219
+ function reviewerStatePath(taskFolder: string): string {
220
+ return join(taskFolder, ".reviewer-state.json");
221
+ }
222
+
223
+ function writeReviewerState(taskFolder: string, state: {
224
+ status: "running" | "done" | "error";
225
+ elapsedMs: number;
226
+ toolCalls: number;
227
+ contextPct: number;
228
+ costUsd: number;
229
+ lastTool: string;
230
+ inputTokens: number;
231
+ outputTokens: number;
232
+ cacheReadTokens: number;
233
+ cacheWriteTokens: number;
234
+ updatedAt: number;
235
+ reviewType?: string;
236
+ reviewStep?: number;
237
+ }): void {
238
+ const filePath = reviewerStatePath(taskFolder);
239
+ const tmpPath = filePath + ".tmp";
240
+ writeFileSync(tmpPath, JSON.stringify(state, null, 2) + "\n", "utf-8");
241
+ renameSync(tmpPath, filePath);
242
+ }
243
+
244
+ function removeReviewerState(taskFolder: string): void {
245
+ const filePath = reviewerStatePath(taskFolder);
246
+ if (!existsSync(filePath)) return;
247
+ try { unlinkSync(filePath); } catch { /* best effort */ }
248
+ }
249
+
219
250
  /**
220
251
  * Spawn a reviewer Pi subprocess and wait for it to complete.
221
252
  * Returns the process exit code.
222
253
  */
223
- function spawnReviewer(prompt: string, systemPrompt: string, cwd: string): Promise<number> {
254
+ function spawnReviewer(prompt: string, systemPrompt: string, cwd: string, taskFolder: string, reviewType?: string, reviewStep?: number): Promise<number> {
255
+ // Pre-clean stale reviewer state from prior interrupted review
256
+ removeReviewerState(taskFolder);
224
257
  return new Promise((resolve) => {
225
258
  const cliPath = resolvePiCli();
226
259
  const args = [
@@ -235,23 +268,124 @@ export default function (pi: ExtensionAPI) {
235
268
  env: { ...process.env },
236
269
  });
237
270
 
238
- // Send prompt and close stdin after delay
271
+ const startedAt = Date.now();
272
+ let inputTokens = 0;
273
+ let outputTokens = 0;
274
+ let cacheReadTokens = 0;
275
+ let cacheWriteTokens = 0;
276
+ let costUsd = 0;
277
+ let toolCalls = 0;
278
+ let lastTool = "";
279
+ let contextPct = 0;
280
+ let stdoutBuf = "";
281
+ let finalized = false;
282
+
283
+ const emitState = (status: "running" | "done" | "error") => {
284
+ try {
285
+ writeReviewerState(taskFolder, {
286
+ status,
287
+ elapsedMs: Date.now() - startedAt,
288
+ toolCalls,
289
+ contextPct,
290
+ costUsd,
291
+ lastTool,
292
+ inputTokens,
293
+ outputTokens,
294
+ cacheReadTokens,
295
+ cacheWriteTokens,
296
+ updatedAt: Date.now(),
297
+ reviewType,
298
+ reviewStep,
299
+ });
300
+ } catch { /* best effort */ }
301
+ };
302
+
303
+ // Write initial "running" state immediately so dashboard shows
304
+ // the reviewer sub-row before the first message_end arrives.
305
+ emitState("running");
306
+
307
+ const closeStdin = () => {
308
+ setTimeout(() => {
309
+ try { proc.stdin?.end(); } catch { /* ignore */ }
310
+ }, 100);
311
+ };
312
+
313
+ const finalize = (code: number) => {
314
+ if (finalized) return;
315
+ finalized = true;
316
+ emitState(code === 0 ? "done" : "error");
317
+ resolve(code);
318
+ };
319
+
320
+ const handleEvent = (event: any) => {
321
+ if (!event || typeof event.type !== "string") return;
322
+ switch (event.type) {
323
+ case "message_end": {
324
+ const usage = event.message?.usage;
325
+ if (usage) {
326
+ inputTokens += usage.input || 0;
327
+ outputTokens += usage.output || 0;
328
+ cacheReadTokens += usage.cacheRead || 0;
329
+ cacheWriteTokens += usage.cacheWrite || 0;
330
+ if (usage.cost) {
331
+ costUsd += typeof usage.cost === "object"
332
+ ? (usage.cost.total || 0)
333
+ : (typeof usage.cost === "number" ? usage.cost : 0);
334
+ }
335
+ }
336
+ emitState("running");
337
+ break;
338
+ }
339
+ case "tool_execution_start": {
340
+ toolCalls++;
341
+ const toolName = event.toolName || "tool";
342
+ const argPreview = typeof event.args === "string"
343
+ ? event.args.slice(0, 80)
344
+ : (event.args && typeof Object.values(event.args)[0] === "string"
345
+ ? String(Object.values(event.args)[0]).slice(0, 80)
346
+ : "");
347
+ lastTool = argPreview ? `${toolName}: ${argPreview}` : toolName;
348
+ emitState("running");
349
+ break;
350
+ }
351
+ case "response": {
352
+ const pct = event.success === true ? event.data?.contextUsage?.percent : undefined;
353
+ if (typeof pct === "number" && Number.isFinite(pct)) {
354
+ contextPct = pct;
355
+ }
356
+ break;
357
+ }
358
+ case "agent_end": {
359
+ closeStdin();
360
+ break;
361
+ }
362
+ }
363
+ };
364
+
365
+ // Send prompt immediately
239
366
  proc.stdin?.write(JSON.stringify({ type: "prompt", message: prompt }) + "\n");
240
367
 
241
- // Watch for agent_end event to close stdin
242
- let buf = "";
243
- proc.stdout?.on("data", (chunk: Buffer) => {
244
- buf += chunk.toString();
245
- if (buf.includes('"agent_end"')) {
246
- setTimeout(() => { try { proc.stdin?.end(); } catch {} }, 100);
368
+ proc.stdout?.on("data", (chunk: Buffer | string) => {
369
+ stdoutBuf += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
370
+ let idx = -1;
371
+ while ((idx = stdoutBuf.indexOf("\n")) >= 0) {
372
+ let line = stdoutBuf.slice(0, idx);
373
+ stdoutBuf = stdoutBuf.slice(idx + 1);
374
+ if (line.endsWith("\r")) line = line.slice(0, -1);
375
+ if (!line.trim()) continue;
376
+ let event: any;
377
+ try { event = JSON.parse(line); } catch { continue; }
378
+ handleEvent(event);
247
379
  }
248
380
  });
249
381
 
250
- proc.on("close", (code) => resolve(code ?? 1));
251
- proc.on("error", () => resolve(1));
382
+ proc.on("close", (code) => finalize(code ?? 1));
383
+ proc.on("error", () => finalize(1));
252
384
 
253
385
  // Timeout: 10 minutes
254
- setTimeout(() => { try { proc.kill("SIGTERM"); } catch {} }, 10 * 60 * 1000);
386
+ setTimeout(() => {
387
+ try { proc.kill("SIGTERM"); } catch { /* ignore */ }
388
+ }, 10 * 60 * 1000);
255
389
  });
256
390
  }
257
391
 
@@ -367,7 +501,7 @@ export default function (pi: ExtensionAPI) {
367
501
 
368
502
  try {
369
503
  const systemPrompt = loadReviewerPrompt();
370
- const exitCode = await spawnReviewer(reviewPrompt, systemPrompt, cwd);
504
+ const exitCode = await spawnReviewer(reviewPrompt, systemPrompt, cwd, taskFolder, reviewType, stepNum);
371
505
 
372
506
  // Update review counter in STATUS.md
373
507
  try {
@@ -395,6 +529,8 @@ export default function (pi: ExtensionAPI) {
395
529
  writeFileSync(statusPath, status.trimEnd() + "\n" + logEntry);
396
530
  } catch { /* best effort */ }
397
531
 
532
+ removeReviewerState(taskFolder);
533
+
398
534
  const reviewFile = `.reviews/R${num}-${reviewType}-step${stepNum}.md`;
399
535
  if (verdict === "APPROVE") {
400
536
  return { content: [{ type: "text" as const, text: `APPROVE` }], details: undefined };
@@ -408,9 +544,11 @@ export default function (pi: ExtensionAPI) {
408
544
  return { content: [{ type: "text" as const, text: `Review complete (verdict unclear). See ${reviewFile}` }], details: undefined };
409
545
  }
410
546
  } else {
547
+ removeReviewerState(taskFolder);
411
548
  return { content: [{ type: "text" as const, text: `UNAVAILABLE — reviewer exited (code ${exitCode}) but produced no output.` }], details: undefined };
412
549
  }
413
550
  } catch (err) {
551
+ removeReviewerState(taskFolder);
414
552
  return {
415
553
  content: [{ type: "text" as const, text: `UNAVAILABLE — reviewer failed: ${err instanceof Error ? err.message : String(err)}` }],
416
554
  details: undefined,